feat: add ReconConfig API and UI for managing configurations

- 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.
This commit is contained in:
2026-05-26 21:58:04 +12:00
parent cf8ec5f094
commit 35d70a7746
10 changed files with 2181 additions and 725 deletions
+1064 -723
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -19,6 +19,7 @@
<ul>
<li><a href="/">Home</a></li>
<li><a href="/results">Results</a></li>
<li><a href="/configs">Configs</a></li>
<li><a href="/api/docs/">API</a></li>
<li><a href="mailto:someone@example.com">Contact</a></li>
</ul>
+721
View File
@@ -0,0 +1,721 @@
{% 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 %}
+54
View File
@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block content %}
<div class="dashboard-container dashboard-stack">
<div class="detail-header">
<div class="detail-crumbs">
<a href="/">Dashboard</a> Configs
</div>
<h1 style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px;">
Recon Configs
<a href="/configs/new" class="btn btn-primary">+ New Config</a>
</h1>
</div>
<div class="table-scroll" style="flex:1">
<table class="data-table">
<thead>
<tr>
<th>Reference</th>
<th>Name</th>
<th>Business Process</th>
<th>Frequency</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for c in configs %}
<tr>
<td><code>{{ c.reference }}</code></td>
<td>{{ c.name }}</td>
<td>{{ c.business_process }}</td>
<td>{{ c.frequency }}</td>
<td>
<span class="badge badge-config-{{ c.status }}">{{ c.status }}</span>
</td>
<td style="text-align:right">
<a href="/configs/{{ c.reference }}/edit" class="btn btn-secondary btn-sm">Edit</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" style="text-align:center; padding:40px; color:#64748b">
No configs yet. <a href="/configs/new" style="color:var(--asb-yellow)">Create one.</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}