798 lines
32 KiB
HTML
798 lines
32 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">
|
||
<select class="url-scheme">
|
||
<option value="file://">file://</option>
|
||
<option value="mssql://">mssql://</option>
|
||
<option value="oracle://">oracle://</option>
|
||
<option value="snowflake://">snowflake://</option>
|
||
<option value="duckdb://">duckdb://</option>
|
||
</select>
|
||
<input type="text" class="url-path" placeholder="/path/to/file or SERVER?db=DB&table=TABLE">
|
||
<button type="button" class="btn btn-secondary btn-sm btn-test-url">Inspect URL</button>
|
||
</div>
|
||
<span class="field-hint">
|
||
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-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>Index</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>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-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','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;
|
||
});
|
||
|
||
// Split stored URL into scheme dropdown + path input
|
||
if (data.url) {
|
||
const m = data.url.match(/^([a-z]+:\/\/)([\s\S]*)$/i);
|
||
if (m) {
|
||
const schemeEl = card.querySelector('.url-scheme');
|
||
const matchingOpt = Array.from(schemeEl.options).find(o => o.value === m[1].toLowerCase());
|
||
if (matchingOpt) schemeEl.value = matchingOpt.value;
|
||
card.querySelector('.url-path').value = m[2];
|
||
} else {
|
||
card.querySelector('.url-path').value = data.url;
|
||
}
|
||
}
|
||
|
||
// Populate list fields (comma-joined)
|
||
['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 — tick index columns derived from index_fields list
|
||
const schemaData = data.system_schema || {};
|
||
const indexSet = new Set(data.index_fields || []);
|
||
Object.entries(schemaData).forEach(([col, type]) => addSchemaRow(card, col, type, indexSet.has(col)));
|
||
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 onUrlChange = () => {
|
||
syncSpecVisibility(card);
|
||
card.querySelector('.url-test-result').setAttribute('hidden', '');
|
||
};
|
||
card.querySelector('.url-scheme').addEventListener('change', onUrlChange);
|
||
card.querySelector('.url-path').addEventListener('input', onUrlChange);
|
||
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 scheme = card.querySelector('.url-scheme').value;
|
||
const path = (card.querySelector('.url-path').value || '').toLowerCase();
|
||
const isFile = scheme === 'file://';
|
||
const isXml = isFile && /\.xml(\b|$|\?|#)/.test(path);
|
||
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, isIndex) {
|
||
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 idxCheck = document.createElement('input');
|
||
idxCheck.type = 'checkbox';
|
||
idxCheck.className = 'idx-check';
|
||
idxCheck.checked = !!isIndex;
|
||
idxCheck.title = 'Use as index / join key';
|
||
|
||
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, idxCheck, 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 scheme = card.querySelector('.url-scheme').value;
|
||
const url = scheme + (card.querySelector('.url-path').value || '');
|
||
const isFile = scheme === 'file://';
|
||
const isXml = isFile && /\.xml(\b|$)/.test(url.toLowerCase());
|
||
|
||
const schema = {};
|
||
const index_fields = [];
|
||
card.querySelectorAll('.schema-rows .schema-row').forEach(row => {
|
||
const name = row.querySelector('input[type="text"]').value.trim();
|
||
const type = row.querySelector('select').value;
|
||
if (!name) return;
|
||
schema[name] = type;
|
||
if (row.querySelector('.idx-check')?.checked) index_fields.push(name);
|
||
});
|
||
|
||
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,
|
||
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-scheme').value + card.querySelector('.url-path').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 = 'Inspecting…';
|
||
resultEl.removeAttribute('hidden');
|
||
resultEl.className = 'url-test-result url-test-loading';
|
||
resultEl.textContent = 'Inspecting URL…';
|
||
const csvSpec = {
|
||
delimiter: card.querySelector('[data-spec="csv"][data-field="delimiter"]')?.value || ',',
|
||
has_header: card.querySelector('[data-spec="csv"][data-field="header"]')?.checked !== false,
|
||
};
|
||
try {
|
||
const resp = await fetch('/api/configs/test-url', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url, csv_spec: csvSpec }),
|
||
});
|
||
const data = await resp.json();
|
||
renderUrlTestResult(resultEl, data, card);
|
||
} catch (err) {
|
||
resultEl.className = 'url-test-result url-test-error';
|
||
resultEl.textContent = `Error: ${err.message}`;
|
||
} finally {
|
||
btn.disabled = false; btn.textContent = 'Inspect URL';
|
||
}
|
||
}
|
||
|
||
function renderUrlTestResult(el, data, card) {
|
||
el.textContent = '';
|
||
|
||
if (data.type === 'db') {
|
||
el.setAttribute('hidden', '');
|
||
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';
|
||
|
||
// File stats
|
||
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})`);
|
||
});
|
||
const pre = document.createElement('span');
|
||
pre.textContent = lines.join('\n');
|
||
el.appendChild(pre);
|
||
|
||
// Schema
|
||
const schema = data.schema || {};
|
||
const colCount = Object.keys(schema).length;
|
||
if (colCount > 0) {
|
||
const schemaLine = document.createElement('div');
|
||
schemaLine.style.cssText = 'margin-top:6px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;';
|
||
const label = document.createElement('span');
|
||
label.textContent = ` ${colCount} column${colCount !== 1 ? 's' : ''} detected`;
|
||
const applyBtn = document.createElement('button');
|
||
applyBtn.type = 'button';
|
||
applyBtn.className = 'btn btn-primary btn-sm';
|
||
applyBtn.textContent = 'Apply Schema';
|
||
applyBtn.addEventListener('click', () => {
|
||
applySchema(card, schema);
|
||
applyBtn.textContent = '✓ Applied';
|
||
applyBtn.disabled = true;
|
||
});
|
||
schemaLine.append(label, applyBtn);
|
||
el.appendChild(schemaLine);
|
||
}
|
||
}
|
||
|
||
function applySchema(card, schema) {
|
||
const container = card.querySelector('.schema-rows');
|
||
// Preserve existing index ticks by column name before clearing
|
||
const existingIndex = new Set();
|
||
container.querySelectorAll('.schema-row').forEach(row => {
|
||
if (row.querySelector('.idx-check')?.checked)
|
||
existingIndex.add(row.querySelector('input[type="text"]').value.trim());
|
||
});
|
||
container.innerHTML = '';
|
||
Object.entries(schema).forEach(([col, type]) =>
|
||
addSchemaRow(card, col, type, existingIndex.has(col))
|
||
);
|
||
updateSchemaCount(card);
|
||
// Open the schema section so the user sees it
|
||
card.querySelector('.syscfg-section details, details.syscfg-section')?.setAttribute('open', '');
|
||
card.querySelector('.schema-builder')?.closest('details')?.setAttribute('open', '');
|
||
showToast(`Schema applied — ${Object.keys(schema).length} columns.`, 'ok');
|
||
}
|
||
|
||
// ── 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 %}
|