feat: add ReconConfig API and UI for managing configurations

- Added a new API endpoint for managing ReconConfigs at /api/configs, including GET, POST, PUT, and a test URL feature.
- Implemented a new configuration editor UI at /configs for creating and editing ReconConfigs.
- Introduced a new configs list page at /configs to display existing configurations with options to edit.
- Updated base HTML template to include a link to the new configs page.
- Created stub configuration and authentication models for workspace development.
- Added a stub configuration module to handle database configuration without a real database.
This commit is contained in:
2026-05-26 21:58:04 +12:00
parent cf8ec5f094
commit 35d70a7746
10 changed files with 2181 additions and 725 deletions
+222
View File
@@ -0,0 +1,222 @@
"""ReconConfig API proxy / fake endpoints.
When RECON_API_BASE_URL is set in the environment, POST and PUT calls are
forwarded to the real API. GET calls always use fake data in this workspace
so the editor has something to load without a real database.
Also provides a /api/configs/test-url endpoint that inspects file:// URLs and
returns metadata (no file contents are returned).
"""
from __future__ import annotations
import glob
import os
from datetime import datetime
from pathlib import Path
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from app.core.refdata import ReconConfigStatus, ReconPatterns
from app.service.fake_configs import get_fake_configs
router = APIRouter(prefix="/api/configs", tags=["ReconConfig"])
_RECON_API_BASE = os.getenv("RECON_API_BASE_URL", "").rstrip("/")
# ── Helpers ────────────────────────────────────────────────────────────────
def _fake_config_full(ref: str, now: datetime) -> dict[str, Any] | None:
"""Build a full ReconConfigRequest-shaped dict from a FakeReconConfig."""
fake = next((c for c in get_fake_configs(now) if c.reference == ref), None)
if fake is None:
return None
return {
"reference": fake.reference,
"name": fake.name,
"business_process": fake.business_process,
"comment": "",
"data_type": "Unknown",
"pattern": ReconPatterns.ONE_TO_ONE.value,
"status": fake.status,
"frequency": fake.frequency,
"start_datetime": fake.start_datetime.isoformat() if fake.start_datetime else None,
"field_mapping": {},
"sources": [
{
"name": "Source System",
"url": "file:///data/input/{{as_at_date.strftime('%Y%m%d')}}.csv",
"comment": "",
"day_offset": 0,
"system_schema": {},
"obfuscate_fields": [],
"pci_redact_fields": [],
"field_widths": [],
"profile_thresholds": [],
"index_fields": [],
"filter": "",
"sql": "",
"csv_spec": {"delimiter": ",", "header": True, "encoding": "utf_8", "trailer_rows": 0, "quoting": True},
"xml_spec": None,
}
],
"destination": {
"name": "Destination System",
"url": "mssql://GROUPDW?db=BIODS_PROCESSING&table=EXAMPLE",
"comment": "",
"day_offset": 0,
"system_schema": {},
"obfuscate_fields": [],
"pci_redact_fields": [],
"field_widths": [],
"profile_thresholds": [],
"index_fields": [],
"filter": "",
"sql": "",
"csv_spec": None,
"xml_spec": None,
},
}
# ── List ────────────────────────────────────────────────────────────────────
@router.get("/")
async def list_configs():
now = datetime.now()
items = []
for c in get_fake_configs(now):
items.append({
"reference": c.reference,
"name": c.name,
"status": c.status,
"frequency": c.frequency,
"business_process": c.business_process,
})
return items
# ── Get one ─────────────────────────────────────────────────────────────────
@router.get("/{reference}")
async def get_config(reference: str):
now = datetime.now()
if _RECON_API_BASE:
async with httpx.AsyncClient(verify=False) as client:
resp = await client.get(f"{_RECON_API_BASE}/api/configs/{reference}")
if resp.status_code == 404:
raise HTTPException(404, f"Config '{reference}' not found")
return resp.json()
data = _fake_config_full(reference, now)
if data is None:
raise HTTPException(404, f"Config '{reference}' not found")
return data
# ── Create ──────────────────────────────────────────────────────────────────
@router.post("/")
async def create_config(request: Request):
body = await request.json()
if _RECON_API_BASE:
async with httpx.AsyncClient(verify=False) as client:
resp = await client.post(f"{_RECON_API_BASE}/api/configs/", json=body)
return JSONResponse(content=resp.json(), status_code=resp.status_code)
# Workspace stub: echo back with a fake revision
return JSONResponse(
content={**body, "revision_number": 1, "user": {"username": "workspace"}},
status_code=201,
)
# ── Update ──────────────────────────────────────────────────────────────────
@router.put("/{reference}")
async def update_config(reference: str, request: Request):
body = await request.json()
if _RECON_API_BASE:
async with httpx.AsyncClient(verify=False) as client:
resp = await client.put(f"{_RECON_API_BASE}/api/configs/{reference}", json=body)
return JSONResponse(content=resp.json(), status_code=resp.status_code)
return JSONResponse(
content={**body, "revision_number": body.get("revision_number", 1) + 1, "user": {"username": "workspace"}},
status_code=200,
)
# ── Test URL ────────────────────────────────────────────────────────────────
@router.post("/test-url")
async def test_url(request: Request):
"""Return metadata for a URL pattern — no file contents ever returned."""
body = await request.json()
url: str = body.get("url", "")
as_at_date: str = body.get("as_at_date", datetime.now().strftime("%Y%m%d"))
if not url.startswith("file://"):
return {"type": "non-file", "message": "Only file:// URLs can be tested here. Database connections are validated at run time."}
# Resolve the path part (strip file://)
raw_path = url[7:]
# Replace simple template variables for preview purposes
from datetime import date as date_type
try:
as_at = datetime.strptime(as_at_date, "%Y%m%d").date()
except ValueError:
as_at = date_type.today()
resolved = (
raw_path
.replace("{{as_at_date.strftime('%Y%m%d')}}", as_at.strftime("%Y%m%d"))
.replace("{{today.strftime('%Y%m%d')}}", date_type.today().strftime("%Y%m%d"))
.replace("{{as_at_date.strftime('%Y-%m-%d')}}", as_at.strftime("%Y-%m-%d"))
.replace("{{today.strftime('%Y-%m-%d')}}", date_type.today().strftime("%Y-%m-%d"))
)
# Treat as glob pattern for anything still containing {
if "*" in resolved or "?" in resolved or "{" in resolved:
matches = glob.glob(resolved)
if not matches:
return {"type": "file", "resolved": resolved, "found": False, "message": "No files matched the pattern."}
file_infos = []
for p in sorted(matches)[:10]:
try:
st = os.stat(p)
file_infos.append({
"path": p,
"size_bytes": st.st_size,
"modified": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"),
})
except OSError:
file_infos.append({"path": p, "error": "Could not stat file"})
return {"type": "file", "resolved": resolved, "found": True, "matches": len(matches), "files": file_infos}
# Exact path
p = Path(resolved)
if not p.exists():
return {"type": "file", "resolved": resolved, "found": False, "message": f"File not found: {resolved}"}
try:
st = p.stat()
return {
"type": "file",
"resolved": resolved,
"found": True,
"matches": 1,
"files": [{
"path": str(p),
"size_bytes": st.st_size,
"modified": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"),
}],
}
except OSError as e:
return {"type": "file", "resolved": resolved, "found": False, "message": str(e)}
+2
View File
@@ -6,6 +6,7 @@ from fastapi.staticfiles import StaticFiles
from app.api.reporting import router as reporting_router from app.api.reporting import router as reporting_router
from app.api.transactions import router as transactions_router from app.api.transactions import router as transactions_router
from app.api.configs import router as configs_router
from app.core.settings import get_settings from app.core.settings import get_settings
from app.views.auth import router as auth_router from app.views.auth import router as auth_router
from app.views.views import router as dashboard_router from app.views.views import router as dashboard_router
@@ -34,6 +35,7 @@ def create_app() -> FastAPI:
app.include_router(transactions_router) app.include_router(transactions_router)
app.include_router(reporting_router) app.include_router(reporting_router)
app.include_router(configs_router)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(dashboard_router) app.include_router(dashboard_router)
app.include_router(docs_router) app.include_router(docs_router)
+29
View File
@@ -0,0 +1,29 @@
"""Stub config module for the workspace.
The production repo has a full config with database URLs and connection maps.
This stub satisfies imports without a real database.
"""
from functools import lru_cache
from os import getenv
db_config_map = {
"NETREVEAL": "netreveal",
"DATAPRODUCT": "dataproduct",
"GROUPDW": "groupdw",
"SNOWFLAKE": "snowflake",
}
class DBConfig:
def __init__(self) -> None:
self.db_url = getenv("DATABASE_URL", "sqlite+aiosqlite:///./recon_ranger.db")
@lru_cache
def get_db_config() -> DBConfig:
return DBConfig()
db_config = get_db_config()
config = db_config
+24 -2
View File
@@ -1,7 +1,7 @@
"""Fake reference-data enums. """Fake reference-data enums.
The real `app.core.refdata` lives in the production repo. For this workspace The real `app.core.refdata` lives in the production repo. This workspace
we only need `ReconJobStatus` (consumed by `app.models.recon_job`). extends it with the enums needed by the config editor.
""" """
from enum import Enum from enum import Enum
@@ -12,3 +12,25 @@ class ReconJobStatus(str, Enum):
COMPLETED = "completed" COMPLETED = "completed"
FAILED = "failed" FAILED = "failed"
CANCELLED = "cancelled" CANCELLED = "cancelled"
class ReconConfigStatus(str, Enum):
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived"
class ReconPatterns(str, Enum):
ONE_TO_ONE = "one_to_one"
MANY_TO_ONE = "many_to_one"
ONE_TO_MANY = "one_to_many"
POSITIONAL = "positional"
class ProfileFields(str, Enum):
ROW_COUNT = "row_count"
NULL_COUNT = "null_count"
DISTINCT_COUNT = "distinct_count"
SUM = "sum"
MIN = "min"
MAX = "max"
+7
View File
@@ -0,0 +1,7 @@
"""Stub auth model for workspace — production version lives in the real repo."""
from pydantic import BaseModel
class UserResponse(BaseModel):
username: str
email: str = ""
+57
View File
@@ -14,6 +14,7 @@ from fastapi.templating import Jinja2Templates
from app.models.recon_job import ReconJob # noqa: F401 (validates that the real model imports cleanly) from app.models.recon_job import ReconJob # noqa: F401 (validates that the real model imports cleanly)
from app.service.fake_jobs import FakeReconJob, get_fake_jobs from app.service.fake_jobs import FakeReconJob, get_fake_jobs
from app.service.fake_configs import FakeReconConfig, get_fake_configs from app.service.fake_configs import FakeReconConfig, get_fake_configs
from app.core.refdata import ReconPatterns, ReconConfigStatus
router = APIRouter(prefix="", tags=["User_Interface"]) router = APIRouter(prefix="", tags=["User_Interface"])
@@ -505,3 +506,59 @@ async def job_detail_view(request: Request, job_id: int):
}, },
}, },
) )
# ── Config editor ────────────────────────────────────────────────────────────
_EDITOR_CONTEXT = {
"patterns": [p.value for p in ReconPatterns],
"statuses": [s.value for s in ReconConfigStatus],
"frequencies": ["Ad Hoc", "Intra Day", "Daily", "Weekly", "Monthly", "Quarterly"],
"schema_types": ["str", "int", "float", "date('%Y-%m-%d')", "datetime('%Y-%m-%d %H:%M:%S')", "bool"],
}
@router.get("/configs/new", response_class=HTMLResponse)
async def config_new_view(request: Request):
return templates.TemplateResponse(
request=request,
name="config_editor.html",
context={
"title": "New Config",
"config": None,
"is_new": True,
**_EDITOR_CONTEXT,
},
)
@router.get("/configs/{reference}/edit", response_class=HTMLResponse)
async def config_edit_view(request: Request, reference: str):
now = datetime.now()
configs = get_fake_configs(now)
config = next((c for c in configs if c.reference == reference), None)
return templates.TemplateResponse(
request=request,
name="config_editor.html",
context={
"title": f"Edit — {reference}" if config else "Edit Config",
"config": config,
"reference": reference,
"is_new": False,
**_EDITOR_CONTEXT,
},
)
@router.get("/configs", response_class=HTMLResponse)
async def configs_list_view(request: Request):
now = datetime.now()
configs = get_fake_configs(now)
return templates.TemplateResponse(
request=request,
name="configs_list.html",
context={
"title": "Recon Configs",
"configs": configs,
},
)
+1064 -723
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -19,6 +19,7 @@
<ul> <ul>
<li><a href="/">Home</a></li> <li><a href="/">Home</a></li>
<li><a href="/results">Results</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="/api/docs/">API</a></li>
<li><a href="mailto:someone@example.com">Contact</a></li> <li><a href="mailto:someone@example.com">Contact</a></li>
</ul> </ul>
+721
View File
@@ -0,0 +1,721 @@
{% extends "base.html" %}
{% block content %}
<div class="dashboard-container dashboard-stack">
<div class="detail-header">
<div class="detail-crumbs">
<a href="/">Dashboard</a>
<a href="/configs">Configs</a>
<span class="muted">{{ title }}</span>
</div>
<h1>{{ title }}</h1>
</div>
{# ── Toast notification ───────────────────────────────── #}
<div id="toast" class="editor-toast" role="status" aria-live="polite"></div>
<form id="config-form" novalidate>
{# ── Top meta strip ───────────────────────────────────── #}
<div class="editor-meta-strip">
<div class="editor-field editor-field-wide">
<label for="f-reference">Reference <span class="req">*</span></label>
<input id="f-reference" name="reference" type="text" placeholder="e.g. fx-settlement"
{% if not is_new %}readonly{% endif %}
value="{{ reference if not is_new else '' }}">
<span class="field-hint">Unique identifier — lowercase, hyphen-separated.</span>
</div>
<div class="editor-field editor-field-wide">
<label for="f-name">Display Name <span class="req">*</span></label>
<input id="f-name" name="name" type="text" placeholder="e.g. FX Settlement Recon">
</div>
<div class="editor-field">
<label for="f-business-process">Business Process</label>
<input id="f-business-process" name="business_process" type="text" placeholder="e.g. Treasury">
</div>
<div class="editor-field">
<label for="f-data-type">Data Type</label>
<input id="f-data-type" name="data_type" type="text" placeholder="e.g. Transaction">
</div>
<div class="editor-field">
<label for="f-pattern">Pattern <span class="req">*</span></label>
<select id="f-pattern" name="pattern">
{% for p in patterns %}
<option value="{{ p }}">{{ p | replace('_', ' ') | title }}</option>
{% endfor %}
</select>
</div>
<div class="editor-field">
<label for="f-status">Status <span class="req">*</span></label>
<select id="f-status" name="status">
{% for s in statuses %}
<option value="{{ s }}">{{ s | title }}</option>
{% endfor %}
</select>
<span class="field-hint">Draft results go to staging tables; Published go to live dashboards.</span>
</div>
<div class="editor-field">
<label for="f-frequency">Frequency</label>
<select id="f-frequency" name="frequency">
{% for f in frequencies %}
<option value="{{ f }}">{{ f }}</option>
{% endfor %}
</select>
</div>
<div class="editor-field">
<label for="f-start-datetime">Schedule Start</label>
<input id="f-start-datetime" name="start_datetime" type="datetime-local">
</div>
<div class="editor-field editor-field-full">
<label for="f-comment">Notes</label>
<textarea id="f-comment" name="comment" rows="2" placeholder="Optional — visible to your team."></textarea>
</div>
</div>{# /meta-strip #}
{# ── Two-column editor body ────────────────────────────── #}
<div class="editor-body">
{# ── Sources column ────────────────────────────────── #}
<section class="editor-section">
<div class="editor-section-header">
<h2>Sources</h2>
<button type="button" class="btn btn-secondary btn-sm" id="btn-add-source">+ Add Source</button>
</div>
<div id="sources-container"></div>
</section>
{# ── Destination column ────────────────────────────── #}
<section class="editor-section">
<div class="editor-section-header">
<h2>Destination</h2>
</div>
<div id="destination-container"></div>
</section>
</div>{# /editor-body #}
{# ── Field mapping ─────────────────────────────────────── #}
<section class="editor-full-section">
<div class="editor-section-header">
<h2>Field Mapping</h2>
<button type="button" class="btn btn-secondary btn-sm" id="btn-add-mapping">+ Add Mapping</button>
</div>
<p class="field-hint" style="margin-bottom:10px">Map source column names to destination column names when they differ. Leave empty if all column names already match.</p>
<div id="field-mapping-container">
<div class="mapping-row mapping-header">
<span>Source Column</span>
<span></span>
<span>Destination Column</span>
<span></span>
</div>
</div>
</section>
{# ── Action bar ─────────────────────────────────────────── #}
<div class="editor-actions">
<a href="/configs" class="btn btn-secondary">Cancel</a>
<button type="button" class="btn btn-secondary" id="btn-preview-json">Preview JSON</button>
<button type="submit" class="btn btn-primary" id="btn-submit">
{{ 'Create Config' if is_new else 'Save Changes' }}
</button>
</div>
</form>
{# ── JSON preview panel (hidden by default) ───────────── #}
<div id="json-preview-panel" class="editor-json-panel" hidden>
<div class="editor-section-header">
<h2>JSON Preview</h2>
<button type="button" class="btn btn-secondary btn-sm" id="btn-close-json">Close</button>
</div>
<pre id="json-preview-content" class="editor-json-pre"></pre>
</div>
</div>
{# ── System Config Card Template ──────────────────────────────────────────── #}
<template id="tpl-syscfg">
<div class="syscfg-card" data-syscfg-index="">
<div class="syscfg-card-header">
<span class="syscfg-card-title">System</span>
<div class="syscfg-card-actions">
<button type="button" class="btn btn-secondary btn-sm btn-remove-syscfg">Remove</button>
</div>
</div>
<div class="syscfg-grid">
<div class="editor-field editor-field-wide">
<label>Name</label>
<input type="text" data-field="name" placeholder="e.g. Core Banking">
</div>
<div class="editor-field">
<label>Day Offset</label>
<input type="number" data-field="day_offset" value="0" min="-365" max="365">
<span class="field-hint">Days ± relative to the job as-at date.</span>
</div>
<div class="editor-field editor-field-wide">
<label>Filter</label>
<input type="text" data-field="filter" placeholder="e.g. status = 'ACTIVE'">
<span class="field-hint">Appended to WHERE clause (SQL) or used as a pandas .query() expression (file).</span>
</div>
<div class="editor-field editor-field-full">
<label>URL <span class="req">*</span></label>
<div class="url-input-row">
<input type="text" data-field="url" placeholder="file:// or mssql:// or oracle:// ..." class="url-input">
<button type="button" class="btn btn-secondary btn-sm btn-test-url">Try URL</button>
</div>
<span class="field-hint">
Supports: <code>file://</code> <code>mssql://</code> <code>oracle://</code> <code>snowflake://</code> <code>duckdb://</code><br>
Date templates: {% raw %}<code>{{as_at_date.strftime('%Y%m%d')}}</code> <code>{{today.strftime('%Y%m%d')}}</code>{% endraw %}
</span>
<div class="url-test-result" hidden></div>
</div>
<div class="editor-field editor-field-full">
<label>SQL Override</label>
<textarea data-field="sql" rows="2" placeholder="Full SQL query — replaces auto-generated SELECT. Required for duckdb://."></textarea>
</div>
<div class="editor-field editor-field-wide">
<label>Comment</label>
<input type="text" data-field="comment" placeholder="Optional note about this system.">
</div>
</div>{# /syscfg-grid #}
{# Schema builder #}
<details class="syscfg-section">
<summary>Schema <span class="syscfg-section-count schema-count">0 columns</span></summary>
<div class="schema-builder">
<div class="kv-table-header">
<span>Column Name</span><span>Type</span><span></span>
</div>
<div class="schema-rows"></div>
<button type="button" class="btn btn-secondary btn-sm btn-add-schema-row">+ Add Column</button>
</div>
</details>
{# CSV Spec #}
<details class="syscfg-section syscfg-csv-spec" hidden>
<summary>CSV Options</summary>
<div class="syscfg-grid">
<div class="editor-field">
<label>Delimiter</label>
<input type="text" data-spec="csv" data-field="delimiter" value="," maxlength="3" style="max-width:60px">
</div>
<div class="editor-field">
<label>Encoding</label>
<input type="text" data-spec="csv" data-field="encoding" value="utf_8" style="max-width:120px">
</div>
<div class="editor-field">
<label>Trailer Rows</label>
<input type="number" data-spec="csv" data-field="trailer_rows" value="0" min="0" style="max-width:80px">
</div>
<div class="editor-field">
<label>Has Header</label>
<label class="toggle-label">
<input type="checkbox" data-spec="csv" data-field="header" checked>
<span class="toggle-track"></span>
</label>
</div>
<div class="editor-field">
<label>Quoting</label>
<label class="toggle-label">
<input type="checkbox" data-spec="csv" data-field="quoting" checked>
<span class="toggle-track"></span>
</label>
</div>
</div>
</details>
{# XML Spec #}
<details class="syscfg-section syscfg-xml-spec" hidden>
<summary>XML Options</summary>
<div class="syscfg-grid">
<div class="editor-field editor-field-wide">
<label>XPath</label>
<input type="text" data-spec="xml" data-field="xpathstr" value="./*">
</div>
<div class="editor-field">
<label>Encoding</label>
<input type="text" data-spec="xml" data-field="encoding" value="utf-8">
</div>
</div>
</details>
{# Advanced fields #}
<details class="syscfg-section">
<summary>Advanced</summary>
<div class="syscfg-grid">
<div class="editor-field editor-field-full">
<label>Index Fields</label>
<input type="text" data-field="index_fields" placeholder="Comma-separated field names used as join keys.">
</div>
<div class="editor-field editor-field-full">
<label>Obfuscate Fields</label>
<input type="text" data-field="obfuscate_fields" placeholder="Comma-separated — values shown as *** in reports.">
</div>
<div class="editor-field editor-field-full">
<label>PCI Redact Fields</label>
<input type="text" data-field="pci_redact_fields" placeholder="Comma-separated — completely stripped from output.">
</div>
<div class="editor-field editor-field-full">
<label>Fixed Width Column Widths</label>
<input type="text" data-field="field_widths" placeholder="Comma-separated integers, e.g. 10,20,5">
<span class="field-hint">Only needed for fixed-width files (not delimited).</span>
</div>
</div>
</details>
</div>{# /syscfg-card #}
</template>
<script>
// ── Schema type options ─────────────────────────────────────────────────────
const SCHEMA_TYPES = {{ schema_types | tojson }};
const IS_NEW = {{ 'true' if is_new else 'false' }};
const CONFIG_REF = {{ (reference | tojson) if not is_new else 'null' }};
// ── Build a syscfg card from a data object ──────────────────────────────────
function buildSyscfgCard(data, role, index) {
const tpl = document.getElementById('tpl-syscfg');
const card = tpl.content.cloneNode(true).querySelector('.syscfg-card');
card.dataset.syscfgIndex = index;
card.dataset.role = role; // 'source' | 'destination'
const titleEl = card.querySelector('.syscfg-card-title');
titleEl.textContent = role === 'destination' ? 'Destination' : `Source ${index + 1}`;
// Remove button (hide for destination)
const removeBtn = card.querySelector('.btn-remove-syscfg');
if (role === 'destination') {
removeBtn.remove();
} else {
removeBtn.addEventListener('click', () => {
card.remove();
reindexSources();
});
}
// Populate simple fields
const fields = ['name','url','day_offset','filter','sql','comment'];
fields.forEach(f => {
const el = card.querySelector(`[data-field="${f}"]`);
if (!el) return;
const val = data[f];
if (val !== undefined && val !== null) el.value = val;
});
// Populate list fields (comma-joined)
['index_fields','obfuscate_fields','pci_redact_fields'].forEach(f => {
const el = card.querySelector(`[data-field="${f}"]`);
if (el && Array.isArray(data[f])) el.value = data[f].join(', ');
});
const fwEl = card.querySelector('[data-field="field_widths"]');
if (fwEl && Array.isArray(data.field_widths)) fwEl.value = data.field_widths.join(', ');
// Schema
const schemaData = data.system_schema || {};
Object.entries(schemaData).forEach(([col, type]) => addSchemaRow(card, col, type));
updateSchemaCount(card);
// CSV spec
if (data.csv_spec) {
card.querySelector('.syscfg-csv-spec').removeAttribute('hidden');
const cs = data.csv_spec;
setSpecField(card, 'csv', 'delimiter', cs.delimiter ?? ',');
setSpecField(card, 'csv', 'encoding', cs.encoding ?? 'utf_8');
setSpecField(card, 'csv', 'trailer_rows', cs.trailer_rows ?? 0);
setSpecCheckbox(card, 'csv', 'header', cs.header !== false);
setSpecCheckbox(card, 'csv', 'quoting', cs.quoting !== false);
}
// XML spec
if (data.xml_spec) {
card.querySelector('.syscfg-xml-spec').removeAttribute('hidden');
setSpecField(card, 'xml', 'xpathstr', data.xml_spec.xpathstr ?? './*');
setSpecField(card, 'xml', 'encoding', data.xml_spec.encoding ?? 'utf-8');
}
// URL change → show/hide specs, clear test result
const urlInput = card.querySelector('.url-input');
urlInput.addEventListener('input', () => {
syncSpecVisibility(card);
card.querySelector('.url-test-result').setAttribute('hidden', '');
});
syncSpecVisibility(card);
// Schema add button
card.querySelector('.btn-add-schema-row').addEventListener('click', () => {
addSchemaRow(card);
updateSchemaCount(card);
});
// Try URL button
card.querySelector('.btn-test-url').addEventListener('click', () => testUrl(card));
return card;
}
function setSpecField(card, spec, field, value) {
const el = card.querySelector(`[data-spec="${spec}"][data-field="${field}"]`);
if (el) el.value = value;
}
function setSpecCheckbox(card, spec, field, checked) {
const el = card.querySelector(`[data-spec="${spec}"][data-field="${field}"]`);
if (el) el.checked = checked;
}
function syncSpecVisibility(card) {
const url = (card.querySelector('.url-input').value || '').toLowerCase();
const isFile = url.startsWith('file://');
const isXml = isFile && /\.xml(\b|$|\?|#)/.test(url);
const isCsv = isFile && !isXml;
card.querySelector('.syscfg-csv-spec').toggleAttribute('hidden', !isCsv);
card.querySelector('.syscfg-xml-spec').toggleAttribute('hidden', !isXml);
}
function addSchemaRow(card, colName, colType) {
const container = card.querySelector('.schema-rows');
const row = document.createElement('div');
row.className = 'schema-row';
const colInput = document.createElement('input');
colInput.type = 'text';
colInput.placeholder = 'column_name';
colInput.value = colName || '';
colInput.addEventListener('input', () => updateSchemaCount(card));
const typeSelect = document.createElement('select');
SCHEMA_TYPES.forEach(t => {
const opt = document.createElement('option');
opt.value = t; opt.textContent = t;
if (t === (colType || 'str')) opt.selected = true;
typeSelect.appendChild(opt);
});
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-secondary btn-sm';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => { row.remove(); updateSchemaCount(card); });
row.append(colInput, typeSelect, removeBtn);
container.appendChild(row);
}
function updateSchemaCount(card) {
const rows = card.querySelectorAll('.schema-rows .schema-row');
const el = card.querySelector('.schema-count');
el.textContent = `${rows.length} column${rows.length !== 1 ? 's' : ''}`;
}
// ── Re-index source card titles after removal ───────────────────────────────
function reindexSources() {
document.querySelectorAll('#sources-container .syscfg-card').forEach((card, i) => {
card.querySelector('.syscfg-card-title').textContent = `Source ${i + 1}`;
card.dataset.syscfgIndex = i;
});
}
// ── Read a syscfg card into a data object ───────────────────────────────────
function readSyscfgCard(card) {
const g = (field) => {
const el = card.querySelector(`[data-field="${field}"]`);
return el ? (el.tagName === 'TEXTAREA' ? el.value : el.value) : '';
};
const toList = (val) => val.split(',').map(s => s.trim()).filter(Boolean);
const toIntList = (val) => val.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
const url = g('url');
const urlLower = url.toLowerCase();
const isFile = urlLower.startsWith('file://');
const isXml = isFile && /\.xml(\b|$)/.test(urlLower);
const schema = {};
card.querySelectorAll('.schema-rows .schema-row').forEach(row => {
const name = row.querySelector('input').value.trim();
const type = row.querySelector('select').value;
if (name) schema[name] = type;
});
const spec = (specName, fields) => {
const obj = {};
let hasSpec = false;
fields.forEach(({ field, isCheck }) => {
const el = card.querySelector(`[data-spec="${specName}"][data-field="${field}"]`);
if (!el) return;
hasSpec = true;
obj[field] = isCheck ? el.checked : (el.type === 'number' ? Number(el.value) : el.value);
});
return hasSpec ? obj : null;
};
const csvSpec = isFile && !isXml ? spec('csv', [
{ field: 'delimiter' },
{ field: 'encoding' },
{ field: 'trailer_rows', isCheck: false },
{ field: 'header', isCheck: true },
{ field: 'quoting', isCheck: true },
]) : null;
const xmlSpec = isXml ? spec('xml', [{ field: 'xpathstr' }, { field: 'encoding' }]) : null;
return {
name: g('name'),
url,
comment: g('comment'),
day_offset: parseInt(g('day_offset') || '0', 10),
system_schema: schema,
filter: g('filter'),
sql: g('sql'),
index_fields: toList(g('index_fields')),
obfuscate_fields: toList(g('obfuscate_fields')),
pci_redact_fields: toList(g('pci_redact_fields')),
field_widths: toIntList(g('field_widths')),
profile_thresholds: [],
csv_spec: csvSpec,
xml_spec: xmlSpec,
};
}
// ── Field mapping ───────────────────────────────────────────────────────────
function addMappingRow(srcVal, dstVal) {
const container = document.getElementById('field-mapping-container');
const row = document.createElement('div');
row.className = 'mapping-row';
const srcInput = document.createElement('input');
srcInput.type = 'text'; srcInput.placeholder = 'source_column'; srcInput.value = srcVal || '';
const arrow = document.createElement('span');
arrow.className = 'mapping-arrow'; arrow.textContent = '→';
const dstInput = document.createElement('input');
dstInput.type = 'text'; dstInput.placeholder = 'destination_column'; dstInput.value = dstVal || '';
const removeBtn = document.createElement('button');
removeBtn.type = 'button'; removeBtn.className = 'btn btn-secondary btn-sm';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => row.remove());
row.append(srcInput, arrow, dstInput, removeBtn);
container.appendChild(row);
}
function readFieldMapping() {
const mapping = {};
document.querySelectorAll('#field-mapping-container .mapping-row').forEach(row => {
const inputs = row.querySelectorAll('input');
const src = inputs[0]?.value.trim();
const dst = inputs[1]?.value.trim();
if (src && dst) mapping[src] = dst;
});
return mapping;
}
// ── Collect full form payload ───────────────────────────────────────────────
function collectPayload() {
const g = (id) => document.getElementById(id)?.value || '';
const sources = Array.from(
document.querySelectorAll('#sources-container .syscfg-card')
).map(card => readSyscfgCard(card));
const destCards = document.querySelectorAll('#destination-container .syscfg-card');
const destination = destCards.length > 0 ? readSyscfgCard(destCards[0]) : {};
return {
reference: g('f-reference'),
name: g('f-name'),
business_process: g('f-business-process'),
data_type: g('f-data-type'),
comment: g('f-comment'),
pattern: g('f-pattern'),
status: g('f-status'),
frequency: g('f-frequency'),
start_datetime: g('f-start-datetime') || null,
sources,
destination,
field_mapping: readFieldMapping(),
};
}
// ── Try URL ─────────────────────────────────────────────────────────────────
async function testUrl(card) {
const url = card.querySelector('.url-input').value.trim();
const resultEl = card.querySelector('.url-test-result');
if (!url) { showToast('Enter a URL first.', 'warn'); return; }
const btn = card.querySelector('.btn-test-url');
btn.disabled = true; btn.textContent = 'Checking…';
resultEl.removeAttribute('hidden');
resultEl.className = 'url-test-result url-test-loading';
resultEl.textContent = 'Checking URL…';
try {
const resp = await fetch('/api/configs/test-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
const data = await resp.json();
renderUrlTestResult(resultEl, data);
} catch (err) {
resultEl.className = 'url-test-result url-test-error';
resultEl.textContent = `Error: ${err.message}`;
} finally {
btn.disabled = false; btn.textContent = 'Try URL';
}
}
function renderUrlTestResult(el, data) {
if (data.type === 'non-file') {
el.className = 'url-test-result url-test-info';
el.textContent = data.message;
return;
}
if (!data.found) {
el.className = 'url-test-result url-test-error';
el.textContent = `Not found: ${data.message || data.resolved}`;
return;
}
el.className = 'url-test-result url-test-ok';
const lines = [`${data.matches} file${data.matches !== 1 ? 's' : ''} matched`];
(data.files || []).forEach(f => {
if (f.error) { lines.push(`${f.path}: ${f.error}`); return; }
const kb = (f.size_bytes / 1024).toFixed(1);
lines.push(` ${f.path.split('/').pop()} ${kb} KB (modified ${f.modified})`);
});
el.textContent = lines.join('\n');
}
// ── Toast ───────────────────────────────────────────────────────────────────
function showToast(msg, type = 'ok') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = `editor-toast editor-toast-${type} editor-toast-visible`;
clearTimeout(el._timer);
el._timer = setTimeout(() => el.classList.remove('editor-toast-visible'), 3500);
}
// ── Form submit ─────────────────────────────────────────────────────────────
document.getElementById('config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const payload = collectPayload();
if (!payload.reference) { showToast('Reference is required.', 'error'); return; }
if (!payload.name) { showToast('Display name is required.', 'error'); return; }
if (payload.sources.length === 0) { showToast('At least one source is required.', 'error'); return; }
const btn = document.getElementById('btn-submit');
btn.disabled = true;
const url = IS_NEW ? '/api/configs/' : `/api/configs/${CONFIG_REF}`;
const method = IS_NEW ? 'POST' : 'PUT';
try {
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
showToast(IS_NEW ? 'Config created!' : 'Config saved!', 'ok');
setTimeout(() => { window.location.href = '/configs'; }, 1200);
} else {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
showToast(`Error ${resp.status}: ${err.detail || JSON.stringify(err)}`, 'error');
}
} catch (err) {
showToast(`Network error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
}
});
// ── JSON preview ────────────────────────────────────────────────────────────
document.getElementById('btn-preview-json').addEventListener('click', () => {
const panel = document.getElementById('json-preview-panel');
document.getElementById('json-preview-content').textContent =
JSON.stringify(collectPayload(), null, 2);
panel.removeAttribute('hidden');
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
document.getElementById('btn-close-json').addEventListener('click', () => {
document.getElementById('json-preview-panel').setAttribute('hidden', '');
});
// ── Add source ──────────────────────────────────────────────────────────────
document.getElementById('btn-add-source').addEventListener('click', () => {
const container = document.getElementById('sources-container');
const idx = container.querySelectorAll('.syscfg-card').length;
container.appendChild(buildSyscfgCard({}, 'source', idx));
});
// ── Add mapping row ─────────────────────────────────────────────────────────
document.getElementById('btn-add-mapping').addEventListener('click', () => addMappingRow());
// ── Initialise from server data or blank ────────────────────────────────────
(async function init() {
let data = null;
if (!IS_NEW && CONFIG_REF) {
try {
const resp = await fetch(`/api/configs/${CONFIG_REF}`);
if (resp.ok) data = await resp.json();
} catch (_) {}
}
if (data) {
// Populate top meta fields
const setField = (id, val) => {
const el = document.getElementById(id);
if (!el || val === null || val === undefined) return;
el.value = val;
};
setField('f-reference', data.reference);
setField('f-name', data.name);
setField('f-business-process', data.business_process);
setField('f-data-type', data.data_type);
setField('f-comment', data.comment || '');
setField('f-pattern', data.pattern);
setField('f-status', data.status);
setField('f-frequency', data.frequency);
if (data.start_datetime) {
// datetime-local needs "YYYY-MM-DDTHH:MM"
setField('f-start-datetime', data.start_datetime.slice(0, 16));
}
// Sources
const srcContainer = document.getElementById('sources-container');
(data.sources || []).forEach((s, i) => {
srcContainer.appendChild(buildSyscfgCard(s, 'source', i));
});
// Destination
const dstContainer = document.getElementById('destination-container');
if (data.destination) {
dstContainer.appendChild(buildSyscfgCard(data.destination, 'destination', 0));
}
// Field mapping
Object.entries(data.field_mapping || {}).forEach(([src, dst]) => addMappingRow(src, dst));
} else {
// Blank form: one default source + destination
document.getElementById('sources-container')
.appendChild(buildSyscfgCard({}, 'source', 0));
document.getElementById('destination-container')
.appendChild(buildSyscfgCard({}, 'destination', 0));
}
})();
</script>
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block content %}
<div class="dashboard-container dashboard-stack">
<div class="detail-header">
<div class="detail-crumbs">
<a href="/">Dashboard</a> Configs
</div>
<h1 style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px;">
Recon Configs
<a href="/configs/new" class="btn btn-primary">+ New Config</a>
</h1>
</div>
<div class="table-scroll" style="flex:1">
<table class="data-table">
<thead>
<tr>
<th>Reference</th>
<th>Name</th>
<th>Business Process</th>
<th>Frequency</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for c in configs %}
<tr>
<td><code>{{ c.reference }}</code></td>
<td>{{ c.name }}</td>
<td>{{ c.business_process }}</td>
<td>{{ c.frequency }}</td>
<td>
<span class="badge badge-config-{{ c.status }}">{{ c.status }}</span>
</td>
<td style="text-align:right">
<a href="/configs/{{ c.reference }}/edit" class="btn btn-secondary btn-sm">Edit</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" style="text-align:center; padding:40px; color:#64748b">
No configs yet. <a href="/configs/new" style="color:var(--asb-yellow)">Create one.</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}