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:
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user