Files
css-test/data/templates/config_editor.html
T

888 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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&amp;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 DATE_FORMATS = [
'%Y-%m-%d', '%d/%m/%Y', '%m/%d/%Y', '%d-%m-%Y',
'%Y%m%d', '%d %b %Y', '%d %B %Y', '%b %d, %Y',
];
const DATETIME_FORMATS = [
'%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S',
'%d/%m/%Y %H:%M:%S', '%d/%m/%Y %H:%M',
'%Y-%m-%d %H:%M', '%Y%m%d%H%M%S', '%d %b %Y %H:%M:%S',
];
function parseSchemaType(typeStr) {
if (!typeStr) return { base: 'str' };
let m;
if ((m = typeStr.match(/^date\('([^']+)'\)$/))) return { base: 'date', format: m[1] };
if ((m = typeStr.match(/^datetime\('([^']+)'\)$/))) return { base: 'datetime', format: m[1] };
if ((m = typeStr.match(/^decimal\((\d+),\s*(\d+)\)$/))) return { base: 'decimal', precision: +m[1], scale: +m[2] };
return { base: typeStr };
}
function composeSchemaType(row) {
const base = row.querySelector('.schema-type-select').value;
if (base === 'date' || base === 'datetime') {
const fmt = row.querySelector('.schema-fmt-input')?.value.trim()
|| (base === 'date' ? '%Y-%m-%d' : '%Y-%m-%d %H:%M:%S');
return `${base}('${fmt}')`;
}
if (base === 'decimal') {
const prec = parseInt(row.querySelector('.schema-dec-prec')?.value) || 18;
const scale = parseInt(row.querySelector('.schema-dec-scale')?.value) || 2;
return `decimal(${prec}, ${scale})`;
}
return base;
}
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 parsed = parseSchemaType(colType);
const colInput = document.createElement('input');
colInput.type = 'text';
colInput.placeholder = 'column_name';
colInput.value = colName || '';
colInput.addEventListener('input', () => updateSchemaCount(card));
// ── Type cell ────────────────────────────────────────────────
const typeCell = document.createElement('div');
typeCell.className = 'schema-type-cell';
const typeSelect = document.createElement('select');
typeSelect.className = 'schema-type-select';
SCHEMA_TYPES.forEach(t => {
const opt = document.createElement('option');
opt.value = t; opt.textContent = t;
if (t === parsed.base) opt.selected = true;
typeSelect.appendChild(opt);
});
// Date / datetime format combobox
const fmtId = `fmt-${Math.random().toString(36).slice(2)}`;
const fmtWrapper = document.createElement('span');
fmtWrapper.className = 'schema-fmt-wrapper';
const fmtInput = document.createElement('input');
fmtInput.type = 'text';
fmtInput.className = 'schema-fmt-input';
fmtInput.setAttribute('list', fmtId);
if (parsed.format) fmtInput.value = parsed.format;
const fmtList = document.createElement('datalist');
fmtList.id = fmtId;
fmtWrapper.append(fmtInput, fmtList);
// Decimal precision + scale inputs
const decWrapper = document.createElement('span');
decWrapper.className = 'schema-dec-wrapper';
const precInput = document.createElement('input');
precInput.type = 'number';
precInput.className = 'schema-dec-prec';
precInput.placeholder = 'prec';
precInput.min = 1; precInput.max = 38;
if (parsed.precision != null) precInput.value = parsed.precision;
const scaleInput = document.createElement('input');
scaleInput.type = 'number';
scaleInput.className = 'schema-dec-scale';
scaleInput.placeholder = 'scale';
scaleInput.min = 0; scaleInput.max = 38;
if (parsed.scale != null) scaleInput.value = parsed.scale;
decWrapper.append(precInput, scaleInput);
function syncExtras() {
const base = typeSelect.value;
const isDate = base === 'date', isDt = base === 'datetime';
fmtWrapper.hidden = !(isDate || isDt);
decWrapper.hidden = base !== 'decimal';
if (isDate || isDt) {
fmtList.innerHTML = '';
(isDate ? DATE_FORMATS : DATETIME_FORMATS).forEach(f => {
const o = document.createElement('option'); o.value = f; fmtList.appendChild(o);
});
if (!fmtInput.value) fmtInput.value = isDate ? '%Y-%m-%d' : '%Y-%m-%d %H:%M:%S';
fmtInput.placeholder = isDate ? '%Y-%m-%d' : '%Y-%m-%d %H:%M:%S';
}
}
typeSelect.addEventListener('change', syncExtras);
syncExtras();
typeCell.append(typeSelect, fmtWrapper, decWrapper);
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, typeCell, 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 = composeSchemaType(row);
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 %}