35d70a7746
- Added a new API endpoint for managing ReconConfigs at /api/configs, including GET, POST, PUT, and a test URL feature. - Implemented a new configuration editor UI at /configs for creating and editing ReconConfigs. - Introduced a new configs list page at /configs to display existing configurations with options to edit. - Updated base HTML template to include a link to the new configs page. - Created stub configuration and authentication models for workspace development. - Added a stub configuration module to handle database configuration without a real database.
722 lines
29 KiB
HTML
722 lines
29 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block content %}
|
||
<div class="dashboard-container dashboard-stack">
|
||
|
||
<div class="detail-header">
|
||
<div class="detail-crumbs">
|
||
<a href="/">Dashboard</a> ›
|
||
<a href="/configs">Configs</a> ›
|
||
<span class="muted">{{ title }}</span>
|
||
</div>
|
||
<h1>{{ title }}</h1>
|
||
</div>
|
||
|
||
{# ── Toast notification ───────────────────────────────── #}
|
||
<div id="toast" class="editor-toast" role="status" aria-live="polite"></div>
|
||
|
||
<form id="config-form" novalidate>
|
||
|
||
{# ── Top meta strip ───────────────────────────────────── #}
|
||
<div class="editor-meta-strip">
|
||
|
||
<div class="editor-field editor-field-wide">
|
||
<label for="f-reference">Reference <span class="req">*</span></label>
|
||
<input id="f-reference" name="reference" type="text" placeholder="e.g. fx-settlement"
|
||
{% if not is_new %}readonly{% endif %}
|
||
value="{{ reference if not is_new else '' }}">
|
||
<span class="field-hint">Unique identifier — lowercase, hyphen-separated.</span>
|
||
</div>
|
||
|
||
<div class="editor-field editor-field-wide">
|
||
<label for="f-name">Display Name <span class="req">*</span></label>
|
||
<input id="f-name" name="name" type="text" placeholder="e.g. FX Settlement Recon">
|
||
</div>
|
||
|
||
<div class="editor-field">
|
||
<label for="f-business-process">Business Process</label>
|
||
<input id="f-business-process" name="business_process" type="text" placeholder="e.g. Treasury">
|
||
</div>
|
||
|
||
<div class="editor-field">
|
||
<label for="f-data-type">Data Type</label>
|
||
<input id="f-data-type" name="data_type" type="text" placeholder="e.g. Transaction">
|
||
</div>
|
||
|
||
<div class="editor-field">
|
||
<label for="f-pattern">Pattern <span class="req">*</span></label>
|
||
<select id="f-pattern" name="pattern">
|
||
{% for p in patterns %}
|
||
<option value="{{ p }}">{{ p | replace('_', ' ') | title }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
|
||
<div class="editor-field">
|
||
<label for="f-status">Status <span class="req">*</span></label>
|
||
<select id="f-status" name="status">
|
||
{% for s in statuses %}
|
||
<option value="{{ s }}">{{ s | title }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<span class="field-hint">Draft results go to staging tables; Published go to live dashboards.</span>
|
||
</div>
|
||
|
||
<div class="editor-field">
|
||
<label for="f-frequency">Frequency</label>
|
||
<select id="f-frequency" name="frequency">
|
||
{% for f in frequencies %}
|
||
<option value="{{ f }}">{{ f }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
|
||
<div class="editor-field">
|
||
<label for="f-start-datetime">Schedule Start</label>
|
||
<input id="f-start-datetime" name="start_datetime" type="datetime-local">
|
||
</div>
|
||
|
||
<div class="editor-field editor-field-full">
|
||
<label for="f-comment">Notes</label>
|
||
<textarea id="f-comment" name="comment" rows="2" placeholder="Optional — visible to your team."></textarea>
|
||
</div>
|
||
|
||
</div>{# /meta-strip #}
|
||
|
||
{# ── Two-column editor body ────────────────────────────── #}
|
||
<div class="editor-body">
|
||
|
||
{# ── Sources column ────────────────────────────────── #}
|
||
<section class="editor-section">
|
||
<div class="editor-section-header">
|
||
<h2>Sources</h2>
|
||
<button type="button" class="btn btn-secondary btn-sm" id="btn-add-source">+ Add Source</button>
|
||
</div>
|
||
<div id="sources-container"></div>
|
||
</section>
|
||
|
||
{# ── Destination column ────────────────────────────── #}
|
||
<section class="editor-section">
|
||
<div class="editor-section-header">
|
||
<h2>Destination</h2>
|
||
</div>
|
||
<div id="destination-container"></div>
|
||
</section>
|
||
|
||
</div>{# /editor-body #}
|
||
|
||
{# ── Field mapping ─────────────────────────────────────── #}
|
||
<section class="editor-full-section">
|
||
<div class="editor-section-header">
|
||
<h2>Field Mapping</h2>
|
||
<button type="button" class="btn btn-secondary btn-sm" id="btn-add-mapping">+ Add Mapping</button>
|
||
</div>
|
||
<p class="field-hint" style="margin-bottom:10px">Map source column names to destination column names when they differ. Leave empty if all column names already match.</p>
|
||
<div id="field-mapping-container">
|
||
<div class="mapping-row mapping-header">
|
||
<span>Source Column</span>
|
||
<span></span>
|
||
<span>Destination Column</span>
|
||
<span></span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{# ── Action bar ─────────────────────────────────────────── #}
|
||
<div class="editor-actions">
|
||
<a href="/configs" class="btn btn-secondary">Cancel</a>
|
||
<button type="button" class="btn btn-secondary" id="btn-preview-json">Preview JSON</button>
|
||
<button type="submit" class="btn btn-primary" id="btn-submit">
|
||
{{ 'Create Config' if is_new else 'Save Changes' }}
|
||
</button>
|
||
</div>
|
||
|
||
</form>
|
||
|
||
{# ── JSON preview panel (hidden by default) ───────────── #}
|
||
<div id="json-preview-panel" class="editor-json-panel" hidden>
|
||
<div class="editor-section-header">
|
||
<h2>JSON Preview</h2>
|
||
<button type="button" class="btn btn-secondary btn-sm" id="btn-close-json">Close</button>
|
||
</div>
|
||
<pre id="json-preview-content" class="editor-json-pre"></pre>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
{# ── System Config Card Template ──────────────────────────────────────────── #}
|
||
<template id="tpl-syscfg">
|
||
<div class="syscfg-card" data-syscfg-index="">
|
||
<div class="syscfg-card-header">
|
||
<span class="syscfg-card-title">System</span>
|
||
<div class="syscfg-card-actions">
|
||
<button type="button" class="btn btn-secondary btn-sm btn-remove-syscfg">Remove</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="syscfg-grid">
|
||
|
||
<div class="editor-field editor-field-wide">
|
||
<label>Name</label>
|
||
<input type="text" data-field="name" placeholder="e.g. Core Banking">
|
||
</div>
|
||
|
||
<div class="editor-field">
|
||
<label>Day Offset</label>
|
||
<input type="number" data-field="day_offset" value="0" min="-365" max="365">
|
||
<span class="field-hint">Days ± relative to the job as-at date.</span>
|
||
</div>
|
||
|
||
<div class="editor-field editor-field-wide">
|
||
<label>Filter</label>
|
||
<input type="text" data-field="filter" placeholder="e.g. status = 'ACTIVE'">
|
||
<span class="field-hint">Appended to WHERE clause (SQL) or used as a pandas .query() expression (file).</span>
|
||
</div>
|
||
|
||
<div class="editor-field editor-field-full">
|
||
<label>URL <span class="req">*</span></label>
|
||
<div class="url-input-row">
|
||
<input type="text" data-field="url" placeholder="file:// or mssql:// or oracle:// ..." class="url-input">
|
||
<button type="button" class="btn btn-secondary btn-sm btn-test-url">Try URL</button>
|
||
</div>
|
||
<span class="field-hint">
|
||
Supports: <code>file://</code> <code>mssql://</code> <code>oracle://</code> <code>snowflake://</code> <code>duckdb://</code><br>
|
||
Date templates: {% raw %}<code>{{as_at_date.strftime('%Y%m%d')}}</code> <code>{{today.strftime('%Y%m%d')}}</code>{% endraw %}
|
||
</span>
|
||
<div class="url-test-result" hidden></div>
|
||
</div>
|
||
|
||
<div class="editor-field editor-field-full">
|
||
<label>SQL Override</label>
|
||
<textarea data-field="sql" rows="2" placeholder="Full SQL query — replaces auto-generated SELECT. Required for duckdb://."></textarea>
|
||
</div>
|
||
|
||
<div class="editor-field editor-field-wide">
|
||
<label>Comment</label>
|
||
<input type="text" data-field="comment" placeholder="Optional note about this system.">
|
||
</div>
|
||
|
||
</div>{# /syscfg-grid #}
|
||
|
||
{# Schema builder #}
|
||
<details class="syscfg-section">
|
||
<summary>Schema <span class="syscfg-section-count schema-count">0 columns</span></summary>
|
||
<div class="schema-builder">
|
||
<div class="kv-table-header">
|
||
<span>Column Name</span><span>Type</span><span></span>
|
||
</div>
|
||
<div class="schema-rows"></div>
|
||
<button type="button" class="btn btn-secondary btn-sm btn-add-schema-row">+ Add Column</button>
|
||
</div>
|
||
</details>
|
||
|
||
{# CSV Spec #}
|
||
<details class="syscfg-section syscfg-csv-spec" hidden>
|
||
<summary>CSV Options</summary>
|
||
<div class="syscfg-grid">
|
||
<div class="editor-field">
|
||
<label>Delimiter</label>
|
||
<input type="text" data-spec="csv" data-field="delimiter" value="," maxlength="3" style="max-width:60px">
|
||
</div>
|
||
<div class="editor-field">
|
||
<label>Encoding</label>
|
||
<input type="text" data-spec="csv" data-field="encoding" value="utf_8" style="max-width:120px">
|
||
</div>
|
||
<div class="editor-field">
|
||
<label>Trailer Rows</label>
|
||
<input type="number" data-spec="csv" data-field="trailer_rows" value="0" min="0" style="max-width:80px">
|
||
</div>
|
||
<div class="editor-field">
|
||
<label>Has Header</label>
|
||
<label class="toggle-label">
|
||
<input type="checkbox" data-spec="csv" data-field="header" checked>
|
||
<span class="toggle-track"></span>
|
||
</label>
|
||
</div>
|
||
<div class="editor-field">
|
||
<label>Quoting</label>
|
||
<label class="toggle-label">
|
||
<input type="checkbox" data-spec="csv" data-field="quoting" checked>
|
||
<span class="toggle-track"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
{# XML Spec #}
|
||
<details class="syscfg-section syscfg-xml-spec" hidden>
|
||
<summary>XML Options</summary>
|
||
<div class="syscfg-grid">
|
||
<div class="editor-field editor-field-wide">
|
||
<label>XPath</label>
|
||
<input type="text" data-spec="xml" data-field="xpathstr" value="./*">
|
||
</div>
|
||
<div class="editor-field">
|
||
<label>Encoding</label>
|
||
<input type="text" data-spec="xml" data-field="encoding" value="utf-8">
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
{# Advanced fields #}
|
||
<details class="syscfg-section">
|
||
<summary>Advanced</summary>
|
||
<div class="syscfg-grid">
|
||
<div class="editor-field editor-field-full">
|
||
<label>Index Fields</label>
|
||
<input type="text" data-field="index_fields" placeholder="Comma-separated field names used as join keys.">
|
||
</div>
|
||
<div class="editor-field editor-field-full">
|
||
<label>Obfuscate Fields</label>
|
||
<input type="text" data-field="obfuscate_fields" placeholder="Comma-separated — values shown as *** in reports.">
|
||
</div>
|
||
<div class="editor-field editor-field-full">
|
||
<label>PCI Redact Fields</label>
|
||
<input type="text" data-field="pci_redact_fields" placeholder="Comma-separated — completely stripped from output.">
|
||
</div>
|
||
<div class="editor-field editor-field-full">
|
||
<label>Fixed Width Column Widths</label>
|
||
<input type="text" data-field="field_widths" placeholder="Comma-separated integers, e.g. 10,20,5">
|
||
<span class="field-hint">Only needed for fixed-width files (not delimited).</span>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
</div>{# /syscfg-card #}
|
||
</template>
|
||
|
||
<script>
|
||
// ── Schema type options ─────────────────────────────────────────────────────
|
||
const SCHEMA_TYPES = {{ schema_types | tojson }};
|
||
const IS_NEW = {{ 'true' if is_new else 'false' }};
|
||
const CONFIG_REF = {{ (reference | tojson) if not is_new else 'null' }};
|
||
|
||
// ── Build a syscfg card from a data object ──────────────────────────────────
|
||
function buildSyscfgCard(data, role, index) {
|
||
const tpl = document.getElementById('tpl-syscfg');
|
||
const card = tpl.content.cloneNode(true).querySelector('.syscfg-card');
|
||
card.dataset.syscfgIndex = index;
|
||
card.dataset.role = role; // 'source' | 'destination'
|
||
|
||
const titleEl = card.querySelector('.syscfg-card-title');
|
||
titleEl.textContent = role === 'destination' ? 'Destination' : `Source ${index + 1}`;
|
||
|
||
// Remove button (hide for destination)
|
||
const removeBtn = card.querySelector('.btn-remove-syscfg');
|
||
if (role === 'destination') {
|
||
removeBtn.remove();
|
||
} else {
|
||
removeBtn.addEventListener('click', () => {
|
||
card.remove();
|
||
reindexSources();
|
||
});
|
||
}
|
||
|
||
// Populate simple fields
|
||
const fields = ['name','url','day_offset','filter','sql','comment'];
|
||
fields.forEach(f => {
|
||
const el = card.querySelector(`[data-field="${f}"]`);
|
||
if (!el) return;
|
||
const val = data[f];
|
||
if (val !== undefined && val !== null) el.value = val;
|
||
});
|
||
|
||
// Populate list fields (comma-joined)
|
||
['index_fields','obfuscate_fields','pci_redact_fields'].forEach(f => {
|
||
const el = card.querySelector(`[data-field="${f}"]`);
|
||
if (el && Array.isArray(data[f])) el.value = data[f].join(', ');
|
||
});
|
||
const fwEl = card.querySelector('[data-field="field_widths"]');
|
||
if (fwEl && Array.isArray(data.field_widths)) fwEl.value = data.field_widths.join(', ');
|
||
|
||
// Schema
|
||
const schemaData = data.system_schema || {};
|
||
Object.entries(schemaData).forEach(([col, type]) => addSchemaRow(card, col, type));
|
||
updateSchemaCount(card);
|
||
|
||
// CSV spec
|
||
if (data.csv_spec) {
|
||
card.querySelector('.syscfg-csv-spec').removeAttribute('hidden');
|
||
const cs = data.csv_spec;
|
||
setSpecField(card, 'csv', 'delimiter', cs.delimiter ?? ',');
|
||
setSpecField(card, 'csv', 'encoding', cs.encoding ?? 'utf_8');
|
||
setSpecField(card, 'csv', 'trailer_rows', cs.trailer_rows ?? 0);
|
||
setSpecCheckbox(card, 'csv', 'header', cs.header !== false);
|
||
setSpecCheckbox(card, 'csv', 'quoting', cs.quoting !== false);
|
||
}
|
||
|
||
// XML spec
|
||
if (data.xml_spec) {
|
||
card.querySelector('.syscfg-xml-spec').removeAttribute('hidden');
|
||
setSpecField(card, 'xml', 'xpathstr', data.xml_spec.xpathstr ?? './*');
|
||
setSpecField(card, 'xml', 'encoding', data.xml_spec.encoding ?? 'utf-8');
|
||
}
|
||
|
||
// URL change → show/hide specs, clear test result
|
||
const urlInput = card.querySelector('.url-input');
|
||
urlInput.addEventListener('input', () => {
|
||
syncSpecVisibility(card);
|
||
card.querySelector('.url-test-result').setAttribute('hidden', '');
|
||
});
|
||
syncSpecVisibility(card);
|
||
|
||
// Schema add button
|
||
card.querySelector('.btn-add-schema-row').addEventListener('click', () => {
|
||
addSchemaRow(card);
|
||
updateSchemaCount(card);
|
||
});
|
||
|
||
// Try URL button
|
||
card.querySelector('.btn-test-url').addEventListener('click', () => testUrl(card));
|
||
|
||
return card;
|
||
}
|
||
|
||
function setSpecField(card, spec, field, value) {
|
||
const el = card.querySelector(`[data-spec="${spec}"][data-field="${field}"]`);
|
||
if (el) el.value = value;
|
||
}
|
||
function setSpecCheckbox(card, spec, field, checked) {
|
||
const el = card.querySelector(`[data-spec="${spec}"][data-field="${field}"]`);
|
||
if (el) el.checked = checked;
|
||
}
|
||
|
||
function syncSpecVisibility(card) {
|
||
const url = (card.querySelector('.url-input').value || '').toLowerCase();
|
||
const isFile = url.startsWith('file://');
|
||
const isXml = isFile && /\.xml(\b|$|\?|#)/.test(url);
|
||
const isCsv = isFile && !isXml;
|
||
card.querySelector('.syscfg-csv-spec').toggleAttribute('hidden', !isCsv);
|
||
card.querySelector('.syscfg-xml-spec').toggleAttribute('hidden', !isXml);
|
||
}
|
||
|
||
function addSchemaRow(card, colName, colType) {
|
||
const container = card.querySelector('.schema-rows');
|
||
const row = document.createElement('div');
|
||
row.className = 'schema-row';
|
||
|
||
const colInput = document.createElement('input');
|
||
colInput.type = 'text';
|
||
colInput.placeholder = 'column_name';
|
||
colInput.value = colName || '';
|
||
colInput.addEventListener('input', () => updateSchemaCount(card));
|
||
|
||
const typeSelect = document.createElement('select');
|
||
SCHEMA_TYPES.forEach(t => {
|
||
const opt = document.createElement('option');
|
||
opt.value = t; opt.textContent = t;
|
||
if (t === (colType || 'str')) opt.selected = true;
|
||
typeSelect.appendChild(opt);
|
||
});
|
||
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.type = 'button';
|
||
removeBtn.className = 'btn btn-secondary btn-sm';
|
||
removeBtn.textContent = '×';
|
||
removeBtn.addEventListener('click', () => { row.remove(); updateSchemaCount(card); });
|
||
|
||
row.append(colInput, typeSelect, removeBtn);
|
||
container.appendChild(row);
|
||
}
|
||
|
||
function updateSchemaCount(card) {
|
||
const rows = card.querySelectorAll('.schema-rows .schema-row');
|
||
const el = card.querySelector('.schema-count');
|
||
el.textContent = `${rows.length} column${rows.length !== 1 ? 's' : ''}`;
|
||
}
|
||
|
||
// ── Re-index source card titles after removal ───────────────────────────────
|
||
function reindexSources() {
|
||
document.querySelectorAll('#sources-container .syscfg-card').forEach((card, i) => {
|
||
card.querySelector('.syscfg-card-title').textContent = `Source ${i + 1}`;
|
||
card.dataset.syscfgIndex = i;
|
||
});
|
||
}
|
||
|
||
// ── Read a syscfg card into a data object ───────────────────────────────────
|
||
function readSyscfgCard(card) {
|
||
const g = (field) => {
|
||
const el = card.querySelector(`[data-field="${field}"]`);
|
||
return el ? (el.tagName === 'TEXTAREA' ? el.value : el.value) : '';
|
||
};
|
||
const toList = (val) => val.split(',').map(s => s.trim()).filter(Boolean);
|
||
const toIntList = (val) => val.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
|
||
|
||
const url = g('url');
|
||
const urlLower = url.toLowerCase();
|
||
const isFile = urlLower.startsWith('file://');
|
||
const isXml = isFile && /\.xml(\b|$)/.test(urlLower);
|
||
|
||
const schema = {};
|
||
card.querySelectorAll('.schema-rows .schema-row').forEach(row => {
|
||
const name = row.querySelector('input').value.trim();
|
||
const type = row.querySelector('select').value;
|
||
if (name) schema[name] = type;
|
||
});
|
||
|
||
const spec = (specName, fields) => {
|
||
const obj = {};
|
||
let hasSpec = false;
|
||
fields.forEach(({ field, isCheck }) => {
|
||
const el = card.querySelector(`[data-spec="${specName}"][data-field="${field}"]`);
|
||
if (!el) return;
|
||
hasSpec = true;
|
||
obj[field] = isCheck ? el.checked : (el.type === 'number' ? Number(el.value) : el.value);
|
||
});
|
||
return hasSpec ? obj : null;
|
||
};
|
||
|
||
const csvSpec = isFile && !isXml ? spec('csv', [
|
||
{ field: 'delimiter' },
|
||
{ field: 'encoding' },
|
||
{ field: 'trailer_rows', isCheck: false },
|
||
{ field: 'header', isCheck: true },
|
||
{ field: 'quoting', isCheck: true },
|
||
]) : null;
|
||
const xmlSpec = isXml ? spec('xml', [{ field: 'xpathstr' }, { field: 'encoding' }]) : null;
|
||
|
||
return {
|
||
name: g('name'),
|
||
url,
|
||
comment: g('comment'),
|
||
day_offset: parseInt(g('day_offset') || '0', 10),
|
||
system_schema: schema,
|
||
filter: g('filter'),
|
||
sql: g('sql'),
|
||
index_fields: toList(g('index_fields')),
|
||
obfuscate_fields: toList(g('obfuscate_fields')),
|
||
pci_redact_fields: toList(g('pci_redact_fields')),
|
||
field_widths: toIntList(g('field_widths')),
|
||
profile_thresholds: [],
|
||
csv_spec: csvSpec,
|
||
xml_spec: xmlSpec,
|
||
};
|
||
}
|
||
|
||
// ── Field mapping ───────────────────────────────────────────────────────────
|
||
function addMappingRow(srcVal, dstVal) {
|
||
const container = document.getElementById('field-mapping-container');
|
||
const row = document.createElement('div');
|
||
row.className = 'mapping-row';
|
||
|
||
const srcInput = document.createElement('input');
|
||
srcInput.type = 'text'; srcInput.placeholder = 'source_column'; srcInput.value = srcVal || '';
|
||
|
||
const arrow = document.createElement('span');
|
||
arrow.className = 'mapping-arrow'; arrow.textContent = '→';
|
||
|
||
const dstInput = document.createElement('input');
|
||
dstInput.type = 'text'; dstInput.placeholder = 'destination_column'; dstInput.value = dstVal || '';
|
||
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.type = 'button'; removeBtn.className = 'btn btn-secondary btn-sm';
|
||
removeBtn.textContent = '×';
|
||
removeBtn.addEventListener('click', () => row.remove());
|
||
|
||
row.append(srcInput, arrow, dstInput, removeBtn);
|
||
container.appendChild(row);
|
||
}
|
||
|
||
function readFieldMapping() {
|
||
const mapping = {};
|
||
document.querySelectorAll('#field-mapping-container .mapping-row').forEach(row => {
|
||
const inputs = row.querySelectorAll('input');
|
||
const src = inputs[0]?.value.trim();
|
||
const dst = inputs[1]?.value.trim();
|
||
if (src && dst) mapping[src] = dst;
|
||
});
|
||
return mapping;
|
||
}
|
||
|
||
// ── Collect full form payload ───────────────────────────────────────────────
|
||
function collectPayload() {
|
||
const g = (id) => document.getElementById(id)?.value || '';
|
||
const sources = Array.from(
|
||
document.querySelectorAll('#sources-container .syscfg-card')
|
||
).map(card => readSyscfgCard(card));
|
||
|
||
const destCards = document.querySelectorAll('#destination-container .syscfg-card');
|
||
const destination = destCards.length > 0 ? readSyscfgCard(destCards[0]) : {};
|
||
|
||
return {
|
||
reference: g('f-reference'),
|
||
name: g('f-name'),
|
||
business_process: g('f-business-process'),
|
||
data_type: g('f-data-type'),
|
||
comment: g('f-comment'),
|
||
pattern: g('f-pattern'),
|
||
status: g('f-status'),
|
||
frequency: g('f-frequency'),
|
||
start_datetime: g('f-start-datetime') || null,
|
||
sources,
|
||
destination,
|
||
field_mapping: readFieldMapping(),
|
||
};
|
||
}
|
||
|
||
// ── Try URL ─────────────────────────────────────────────────────────────────
|
||
async function testUrl(card) {
|
||
const url = card.querySelector('.url-input').value.trim();
|
||
const resultEl = card.querySelector('.url-test-result');
|
||
if (!url) { showToast('Enter a URL first.', 'warn'); return; }
|
||
const btn = card.querySelector('.btn-test-url');
|
||
btn.disabled = true; btn.textContent = 'Checking…';
|
||
resultEl.removeAttribute('hidden');
|
||
resultEl.className = 'url-test-result url-test-loading';
|
||
resultEl.textContent = 'Checking URL…';
|
||
try {
|
||
const resp = await fetch('/api/configs/test-url', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url }),
|
||
});
|
||
const data = await resp.json();
|
||
renderUrlTestResult(resultEl, data);
|
||
} catch (err) {
|
||
resultEl.className = 'url-test-result url-test-error';
|
||
resultEl.textContent = `Error: ${err.message}`;
|
||
} finally {
|
||
btn.disabled = false; btn.textContent = 'Try URL';
|
||
}
|
||
}
|
||
|
||
function renderUrlTestResult(el, data) {
|
||
if (data.type === 'non-file') {
|
||
el.className = 'url-test-result url-test-info';
|
||
el.textContent = data.message;
|
||
return;
|
||
}
|
||
if (!data.found) {
|
||
el.className = 'url-test-result url-test-error';
|
||
el.textContent = `Not found: ${data.message || data.resolved}`;
|
||
return;
|
||
}
|
||
el.className = 'url-test-result url-test-ok';
|
||
const lines = [`✓ ${data.matches} file${data.matches !== 1 ? 's' : ''} matched`];
|
||
(data.files || []).forEach(f => {
|
||
if (f.error) { lines.push(` ⚠ ${f.path}: ${f.error}`); return; }
|
||
const kb = (f.size_bytes / 1024).toFixed(1);
|
||
lines.push(` ${f.path.split('/').pop()} ${kb} KB (modified ${f.modified})`);
|
||
});
|
||
el.textContent = lines.join('\n');
|
||
}
|
||
|
||
// ── Toast ───────────────────────────────────────────────────────────────────
|
||
function showToast(msg, type = 'ok') {
|
||
const el = document.getElementById('toast');
|
||
el.textContent = msg;
|
||
el.className = `editor-toast editor-toast-${type} editor-toast-visible`;
|
||
clearTimeout(el._timer);
|
||
el._timer = setTimeout(() => el.classList.remove('editor-toast-visible'), 3500);
|
||
}
|
||
|
||
// ── Form submit ─────────────────────────────────────────────────────────────
|
||
document.getElementById('config-form').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const payload = collectPayload();
|
||
if (!payload.reference) { showToast('Reference is required.', 'error'); return; }
|
||
if (!payload.name) { showToast('Display name is required.', 'error'); return; }
|
||
if (payload.sources.length === 0) { showToast('At least one source is required.', 'error'); return; }
|
||
|
||
const btn = document.getElementById('btn-submit');
|
||
btn.disabled = true;
|
||
const url = IS_NEW ? '/api/configs/' : `/api/configs/${CONFIG_REF}`;
|
||
const method = IS_NEW ? 'POST' : 'PUT';
|
||
try {
|
||
const resp = await fetch(url, {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (resp.ok) {
|
||
showToast(IS_NEW ? 'Config created!' : 'Config saved!', 'ok');
|
||
setTimeout(() => { window.location.href = '/configs'; }, 1200);
|
||
} else {
|
||
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||
showToast(`Error ${resp.status}: ${err.detail || JSON.stringify(err)}`, 'error');
|
||
}
|
||
} catch (err) {
|
||
showToast(`Network error: ${err.message}`, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
|
||
// ── JSON preview ────────────────────────────────────────────────────────────
|
||
document.getElementById('btn-preview-json').addEventListener('click', () => {
|
||
const panel = document.getElementById('json-preview-panel');
|
||
document.getElementById('json-preview-content').textContent =
|
||
JSON.stringify(collectPayload(), null, 2);
|
||
panel.removeAttribute('hidden');
|
||
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
});
|
||
document.getElementById('btn-close-json').addEventListener('click', () => {
|
||
document.getElementById('json-preview-panel').setAttribute('hidden', '');
|
||
});
|
||
|
||
// ── Add source ──────────────────────────────────────────────────────────────
|
||
document.getElementById('btn-add-source').addEventListener('click', () => {
|
||
const container = document.getElementById('sources-container');
|
||
const idx = container.querySelectorAll('.syscfg-card').length;
|
||
container.appendChild(buildSyscfgCard({}, 'source', idx));
|
||
});
|
||
|
||
// ── Add mapping row ─────────────────────────────────────────────────────────
|
||
document.getElementById('btn-add-mapping').addEventListener('click', () => addMappingRow());
|
||
|
||
// ── Initialise from server data or blank ────────────────────────────────────
|
||
(async function init() {
|
||
let data = null;
|
||
if (!IS_NEW && CONFIG_REF) {
|
||
try {
|
||
const resp = await fetch(`/api/configs/${CONFIG_REF}`);
|
||
if (resp.ok) data = await resp.json();
|
||
} catch (_) {}
|
||
}
|
||
|
||
if (data) {
|
||
// Populate top meta fields
|
||
const setField = (id, val) => {
|
||
const el = document.getElementById(id);
|
||
if (!el || val === null || val === undefined) return;
|
||
el.value = val;
|
||
};
|
||
setField('f-reference', data.reference);
|
||
setField('f-name', data.name);
|
||
setField('f-business-process', data.business_process);
|
||
setField('f-data-type', data.data_type);
|
||
setField('f-comment', data.comment || '');
|
||
setField('f-pattern', data.pattern);
|
||
setField('f-status', data.status);
|
||
setField('f-frequency', data.frequency);
|
||
if (data.start_datetime) {
|
||
// datetime-local needs "YYYY-MM-DDTHH:MM"
|
||
setField('f-start-datetime', data.start_datetime.slice(0, 16));
|
||
}
|
||
|
||
// Sources
|
||
const srcContainer = document.getElementById('sources-container');
|
||
(data.sources || []).forEach((s, i) => {
|
||
srcContainer.appendChild(buildSyscfgCard(s, 'source', i));
|
||
});
|
||
|
||
// Destination
|
||
const dstContainer = document.getElementById('destination-container');
|
||
if (data.destination) {
|
||
dstContainer.appendChild(buildSyscfgCard(data.destination, 'destination', 0));
|
||
}
|
||
|
||
// Field mapping
|
||
Object.entries(data.field_mapping || {}).forEach(([src, dst]) => addMappingRow(src, dst));
|
||
|
||
} else {
|
||
// Blank form: one default source + destination
|
||
document.getElementById('sources-container')
|
||
.appendChild(buildSyscfgCard({}, 'source', 0));
|
||
document.getElementById('destination-container')
|
||
.appendChild(buildSyscfgCard({}, 'destination', 0));
|
||
}
|
||
})();
|
||
</script>
|
||
{% endblock %}
|