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:
@@ -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)}
|
||||
@@ -6,6 +6,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.api.reporting import router as reporting_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.views.auth import router as auth_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(reporting_router)
|
||||
app.include_router(configs_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(docs_router)
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
"""Fake reference-data enums.
|
||||
|
||||
The real `app.core.refdata` lives in the production repo. For this workspace
|
||||
we only need `ReconJobStatus` (consumed by `app.models.recon_job`).
|
||||
The real `app.core.refdata` lives in the production repo. This workspace
|
||||
extends it with the enums needed by the config editor.
|
||||
"""
|
||||
from enum import Enum
|
||||
|
||||
@@ -12,3 +12,25 @@ class ReconJobStatus(str, Enum):
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
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"
|
||||
|
||||
@@ -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 = ""
|
||||
@@ -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.service.fake_jobs import FakeReconJob, get_fake_jobs
|
||||
from app.service.fake_configs import FakeReconConfig, get_fake_configs
|
||||
from app.core.refdata import ReconPatterns, ReconConfigStatus
|
||||
|
||||
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/results">Results</a></li>
|
||||
<li><a href="/configs">Configs</a></li>
|
||||
<li><a href="/api/docs/">API</a></li>
|
||||
<li><a href="mailto:someone@example.com">Contact</a></li>
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-container dashboard-stack">
|
||||
|
||||
<div class="detail-header">
|
||||
<div class="detail-crumbs">
|
||||
<a href="/">Dashboard</a> ›
|
||||
<a href="/configs">Configs</a> ›
|
||||
<span class="muted">{{ title }}</span>
|
||||
</div>
|
||||
<h1>{{ title }}</h1>
|
||||
</div>
|
||||
|
||||
{# ── Toast notification ───────────────────────────────── #}
|
||||
<div id="toast" class="editor-toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<form id="config-form" novalidate>
|
||||
|
||||
{# ── Top meta strip ───────────────────────────────────── #}
|
||||
<div class="editor-meta-strip">
|
||||
|
||||
<div class="editor-field editor-field-wide">
|
||||
<label for="f-reference">Reference <span class="req">*</span></label>
|
||||
<input id="f-reference" name="reference" type="text" placeholder="e.g. fx-settlement"
|
||||
{% if not is_new %}readonly{% endif %}
|
||||
value="{{ reference if not is_new else '' }}">
|
||||
<span class="field-hint">Unique identifier — lowercase, hyphen-separated.</span>
|
||||
</div>
|
||||
|
||||
<div class="editor-field editor-field-wide">
|
||||
<label for="f-name">Display Name <span class="req">*</span></label>
|
||||
<input id="f-name" name="name" type="text" placeholder="e.g. FX Settlement Recon">
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label for="f-business-process">Business Process</label>
|
||||
<input id="f-business-process" name="business_process" type="text" placeholder="e.g. Treasury">
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label for="f-data-type">Data Type</label>
|
||||
<input id="f-data-type" name="data_type" type="text" placeholder="e.g. Transaction">
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label for="f-pattern">Pattern <span class="req">*</span></label>
|
||||
<select id="f-pattern" name="pattern">
|
||||
{% for p in patterns %}
|
||||
<option value="{{ p }}">{{ p | replace('_', ' ') | title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label for="f-status">Status <span class="req">*</span></label>
|
||||
<select id="f-status" name="status">
|
||||
{% for s in statuses %}
|
||||
<option value="{{ s }}">{{ s | title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="field-hint">Draft results go to staging tables; Published go to live dashboards.</span>
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label for="f-frequency">Frequency</label>
|
||||
<select id="f-frequency" name="frequency">
|
||||
{% for f in frequencies %}
|
||||
<option value="{{ f }}">{{ f }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label for="f-start-datetime">Schedule Start</label>
|
||||
<input id="f-start-datetime" name="start_datetime" type="datetime-local">
|
||||
</div>
|
||||
|
||||
<div class="editor-field editor-field-full">
|
||||
<label for="f-comment">Notes</label>
|
||||
<textarea id="f-comment" name="comment" rows="2" placeholder="Optional — visible to your team."></textarea>
|
||||
</div>
|
||||
|
||||
</div>{# /meta-strip #}
|
||||
|
||||
{# ── Two-column editor body ────────────────────────────── #}
|
||||
<div class="editor-body">
|
||||
|
||||
{# ── Sources column ────────────────────────────────── #}
|
||||
<section class="editor-section">
|
||||
<div class="editor-section-header">
|
||||
<h2>Sources</h2>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="btn-add-source">+ Add Source</button>
|
||||
</div>
|
||||
<div id="sources-container"></div>
|
||||
</section>
|
||||
|
||||
{# ── Destination column ────────────────────────────── #}
|
||||
<section class="editor-section">
|
||||
<div class="editor-section-header">
|
||||
<h2>Destination</h2>
|
||||
</div>
|
||||
<div id="destination-container"></div>
|
||||
</section>
|
||||
|
||||
</div>{# /editor-body #}
|
||||
|
||||
{# ── Field mapping ─────────────────────────────────────── #}
|
||||
<section class="editor-full-section">
|
||||
<div class="editor-section-header">
|
||||
<h2>Field Mapping</h2>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="btn-add-mapping">+ Add Mapping</button>
|
||||
</div>
|
||||
<p class="field-hint" style="margin-bottom:10px">Map source column names to destination column names when they differ. Leave empty if all column names already match.</p>
|
||||
<div id="field-mapping-container">
|
||||
<div class="mapping-row mapping-header">
|
||||
<span>Source Column</span>
|
||||
<span></span>
|
||||
<span>Destination Column</span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ── Action bar ─────────────────────────────────────────── #}
|
||||
<div class="editor-actions">
|
||||
<a href="/configs" class="btn btn-secondary">Cancel</a>
|
||||
<button type="button" class="btn btn-secondary" id="btn-preview-json">Preview JSON</button>
|
||||
<button type="submit" class="btn btn-primary" id="btn-submit">
|
||||
{{ 'Create Config' if is_new else 'Save Changes' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
{# ── JSON preview panel (hidden by default) ───────────── #}
|
||||
<div id="json-preview-panel" class="editor-json-panel" hidden>
|
||||
<div class="editor-section-header">
|
||||
<h2>JSON Preview</h2>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="btn-close-json">Close</button>
|
||||
</div>
|
||||
<pre id="json-preview-content" class="editor-json-pre"></pre>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# ── System Config Card Template ──────────────────────────────────────────── #}
|
||||
<template id="tpl-syscfg">
|
||||
<div class="syscfg-card" data-syscfg-index="">
|
||||
<div class="syscfg-card-header">
|
||||
<span class="syscfg-card-title">System</span>
|
||||
<div class="syscfg-card-actions">
|
||||
<button type="button" class="btn btn-secondary btn-sm btn-remove-syscfg">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="syscfg-grid">
|
||||
|
||||
<div class="editor-field editor-field-wide">
|
||||
<label>Name</label>
|
||||
<input type="text" data-field="name" placeholder="e.g. Core Banking">
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label>Day Offset</label>
|
||||
<input type="number" data-field="day_offset" value="0" min="-365" max="365">
|
||||
<span class="field-hint">Days ± relative to the job as-at date.</span>
|
||||
</div>
|
||||
|
||||
<div class="editor-field editor-field-wide">
|
||||
<label>Filter</label>
|
||||
<input type="text" data-field="filter" placeholder="e.g. status = 'ACTIVE'">
|
||||
<span class="field-hint">Appended to WHERE clause (SQL) or used as a pandas .query() expression (file).</span>
|
||||
</div>
|
||||
|
||||
<div class="editor-field editor-field-full">
|
||||
<label>URL <span class="req">*</span></label>
|
||||
<div class="url-input-row">
|
||||
<input type="text" data-field="url" placeholder="file:// or mssql:// or oracle:// ..." class="url-input">
|
||||
<button type="button" class="btn btn-secondary btn-sm btn-test-url">Try URL</button>
|
||||
</div>
|
||||
<span class="field-hint">
|
||||
Supports: <code>file://</code> <code>mssql://</code> <code>oracle://</code> <code>snowflake://</code> <code>duckdb://</code><br>
|
||||
Date templates: {% raw %}<code>{{as_at_date.strftime('%Y%m%d')}}</code> <code>{{today.strftime('%Y%m%d')}}</code>{% endraw %}
|
||||
</span>
|
||||
<div class="url-test-result" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="editor-field editor-field-full">
|
||||
<label>SQL Override</label>
|
||||
<textarea data-field="sql" rows="2" placeholder="Full SQL query — replaces auto-generated SELECT. Required for duckdb://."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="editor-field editor-field-wide">
|
||||
<label>Comment</label>
|
||||
<input type="text" data-field="comment" placeholder="Optional note about this system.">
|
||||
</div>
|
||||
|
||||
</div>{# /syscfg-grid #}
|
||||
|
||||
{# Schema builder #}
|
||||
<details class="syscfg-section">
|
||||
<summary>Schema <span class="syscfg-section-count schema-count">0 columns</span></summary>
|
||||
<div class="schema-builder">
|
||||
<div class="kv-table-header">
|
||||
<span>Column Name</span><span>Type</span><span></span>
|
||||
</div>
|
||||
<div class="schema-rows"></div>
|
||||
<button type="button" class="btn btn-secondary btn-sm btn-add-schema-row">+ Add Column</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{# CSV Spec #}
|
||||
<details class="syscfg-section syscfg-csv-spec" hidden>
|
||||
<summary>CSV Options</summary>
|
||||
<div class="syscfg-grid">
|
||||
<div class="editor-field">
|
||||
<label>Delimiter</label>
|
||||
<input type="text" data-spec="csv" data-field="delimiter" value="," maxlength="3" style="max-width:60px">
|
||||
</div>
|
||||
<div class="editor-field">
|
||||
<label>Encoding</label>
|
||||
<input type="text" data-spec="csv" data-field="encoding" value="utf_8" style="max-width:120px">
|
||||
</div>
|
||||
<div class="editor-field">
|
||||
<label>Trailer Rows</label>
|
||||
<input type="number" data-spec="csv" data-field="trailer_rows" value="0" min="0" style="max-width:80px">
|
||||
</div>
|
||||
<div class="editor-field">
|
||||
<label>Has Header</label>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" data-spec="csv" data-field="header" checked>
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="editor-field">
|
||||
<label>Quoting</label>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" data-spec="csv" data-field="quoting" checked>
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{# XML Spec #}
|
||||
<details class="syscfg-section syscfg-xml-spec" hidden>
|
||||
<summary>XML Options</summary>
|
||||
<div class="syscfg-grid">
|
||||
<div class="editor-field editor-field-wide">
|
||||
<label>XPath</label>
|
||||
<input type="text" data-spec="xml" data-field="xpathstr" value="./*">
|
||||
</div>
|
||||
<div class="editor-field">
|
||||
<label>Encoding</label>
|
||||
<input type="text" data-spec="xml" data-field="encoding" value="utf-8">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{# Advanced fields #}
|
||||
<details class="syscfg-section">
|
||||
<summary>Advanced</summary>
|
||||
<div class="syscfg-grid">
|
||||
<div class="editor-field editor-field-full">
|
||||
<label>Index Fields</label>
|
||||
<input type="text" data-field="index_fields" placeholder="Comma-separated field names used as join keys.">
|
||||
</div>
|
||||
<div class="editor-field editor-field-full">
|
||||
<label>Obfuscate Fields</label>
|
||||
<input type="text" data-field="obfuscate_fields" placeholder="Comma-separated — values shown as *** in reports.">
|
||||
</div>
|
||||
<div class="editor-field editor-field-full">
|
||||
<label>PCI Redact Fields</label>
|
||||
<input type="text" data-field="pci_redact_fields" placeholder="Comma-separated — completely stripped from output.">
|
||||
</div>
|
||||
<div class="editor-field editor-field-full">
|
||||
<label>Fixed Width Column Widths</label>
|
||||
<input type="text" data-field="field_widths" placeholder="Comma-separated integers, e.g. 10,20,5">
|
||||
<span class="field-hint">Only needed for fixed-width files (not delimited).</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div>{# /syscfg-card #}
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// ── Schema type options ─────────────────────────────────────────────────────
|
||||
const SCHEMA_TYPES = {{ schema_types | tojson }};
|
||||
const IS_NEW = {{ 'true' if is_new else 'false' }};
|
||||
const CONFIG_REF = {{ (reference | tojson) if not is_new else 'null' }};
|
||||
|
||||
// ── Build a syscfg card from a data object ──────────────────────────────────
|
||||
function buildSyscfgCard(data, role, index) {
|
||||
const tpl = document.getElementById('tpl-syscfg');
|
||||
const card = tpl.content.cloneNode(true).querySelector('.syscfg-card');
|
||||
card.dataset.syscfgIndex = index;
|
||||
card.dataset.role = role; // 'source' | 'destination'
|
||||
|
||||
const titleEl = card.querySelector('.syscfg-card-title');
|
||||
titleEl.textContent = role === 'destination' ? 'Destination' : `Source ${index + 1}`;
|
||||
|
||||
// Remove button (hide for destination)
|
||||
const removeBtn = card.querySelector('.btn-remove-syscfg');
|
||||
if (role === 'destination') {
|
||||
removeBtn.remove();
|
||||
} else {
|
||||
removeBtn.addEventListener('click', () => {
|
||||
card.remove();
|
||||
reindexSources();
|
||||
});
|
||||
}
|
||||
|
||||
// Populate simple fields
|
||||
const fields = ['name','url','day_offset','filter','sql','comment'];
|
||||
fields.forEach(f => {
|
||||
const el = card.querySelector(`[data-field="${f}"]`);
|
||||
if (!el) return;
|
||||
const val = data[f];
|
||||
if (val !== undefined && val !== null) el.value = val;
|
||||
});
|
||||
|
||||
// Populate list fields (comma-joined)
|
||||
['index_fields','obfuscate_fields','pci_redact_fields'].forEach(f => {
|
||||
const el = card.querySelector(`[data-field="${f}"]`);
|
||||
if (el && Array.isArray(data[f])) el.value = data[f].join(', ');
|
||||
});
|
||||
const fwEl = card.querySelector('[data-field="field_widths"]');
|
||||
if (fwEl && Array.isArray(data.field_widths)) fwEl.value = data.field_widths.join(', ');
|
||||
|
||||
// Schema
|
||||
const schemaData = data.system_schema || {};
|
||||
Object.entries(schemaData).forEach(([col, type]) => addSchemaRow(card, col, type));
|
||||
updateSchemaCount(card);
|
||||
|
||||
// CSV spec
|
||||
if (data.csv_spec) {
|
||||
card.querySelector('.syscfg-csv-spec').removeAttribute('hidden');
|
||||
const cs = data.csv_spec;
|
||||
setSpecField(card, 'csv', 'delimiter', cs.delimiter ?? ',');
|
||||
setSpecField(card, 'csv', 'encoding', cs.encoding ?? 'utf_8');
|
||||
setSpecField(card, 'csv', 'trailer_rows', cs.trailer_rows ?? 0);
|
||||
setSpecCheckbox(card, 'csv', 'header', cs.header !== false);
|
||||
setSpecCheckbox(card, 'csv', 'quoting', cs.quoting !== false);
|
||||
}
|
||||
|
||||
// XML spec
|
||||
if (data.xml_spec) {
|
||||
card.querySelector('.syscfg-xml-spec').removeAttribute('hidden');
|
||||
setSpecField(card, 'xml', 'xpathstr', data.xml_spec.xpathstr ?? './*');
|
||||
setSpecField(card, 'xml', 'encoding', data.xml_spec.encoding ?? 'utf-8');
|
||||
}
|
||||
|
||||
// URL change → show/hide specs, clear test result
|
||||
const urlInput = card.querySelector('.url-input');
|
||||
urlInput.addEventListener('input', () => {
|
||||
syncSpecVisibility(card);
|
||||
card.querySelector('.url-test-result').setAttribute('hidden', '');
|
||||
});
|
||||
syncSpecVisibility(card);
|
||||
|
||||
// Schema add button
|
||||
card.querySelector('.btn-add-schema-row').addEventListener('click', () => {
|
||||
addSchemaRow(card);
|
||||
updateSchemaCount(card);
|
||||
});
|
||||
|
||||
// Try URL button
|
||||
card.querySelector('.btn-test-url').addEventListener('click', () => testUrl(card));
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function setSpecField(card, spec, field, value) {
|
||||
const el = card.querySelector(`[data-spec="${spec}"][data-field="${field}"]`);
|
||||
if (el) el.value = value;
|
||||
}
|
||||
function setSpecCheckbox(card, spec, field, checked) {
|
||||
const el = card.querySelector(`[data-spec="${spec}"][data-field="${field}"]`);
|
||||
if (el) el.checked = checked;
|
||||
}
|
||||
|
||||
function syncSpecVisibility(card) {
|
||||
const url = (card.querySelector('.url-input').value || '').toLowerCase();
|
||||
const isFile = url.startsWith('file://');
|
||||
const isXml = isFile && /\.xml(\b|$|\?|#)/.test(url);
|
||||
const isCsv = isFile && !isXml;
|
||||
card.querySelector('.syscfg-csv-spec').toggleAttribute('hidden', !isCsv);
|
||||
card.querySelector('.syscfg-xml-spec').toggleAttribute('hidden', !isXml);
|
||||
}
|
||||
|
||||
function addSchemaRow(card, colName, colType) {
|
||||
const container = card.querySelector('.schema-rows');
|
||||
const row = document.createElement('div');
|
||||
row.className = 'schema-row';
|
||||
|
||||
const colInput = document.createElement('input');
|
||||
colInput.type = 'text';
|
||||
colInput.placeholder = 'column_name';
|
||||
colInput.value = colName || '';
|
||||
colInput.addEventListener('input', () => updateSchemaCount(card));
|
||||
|
||||
const typeSelect = document.createElement('select');
|
||||
SCHEMA_TYPES.forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t; opt.textContent = t;
|
||||
if (t === (colType || 'str')) opt.selected = true;
|
||||
typeSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'btn btn-secondary btn-sm';
|
||||
removeBtn.textContent = '×';
|
||||
removeBtn.addEventListener('click', () => { row.remove(); updateSchemaCount(card); });
|
||||
|
||||
row.append(colInput, typeSelect, removeBtn);
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
function updateSchemaCount(card) {
|
||||
const rows = card.querySelectorAll('.schema-rows .schema-row');
|
||||
const el = card.querySelector('.schema-count');
|
||||
el.textContent = `${rows.length} column${rows.length !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
// ── Re-index source card titles after removal ───────────────────────────────
|
||||
function reindexSources() {
|
||||
document.querySelectorAll('#sources-container .syscfg-card').forEach((card, i) => {
|
||||
card.querySelector('.syscfg-card-title').textContent = `Source ${i + 1}`;
|
||||
card.dataset.syscfgIndex = i;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Read a syscfg card into a data object ───────────────────────────────────
|
||||
function readSyscfgCard(card) {
|
||||
const g = (field) => {
|
||||
const el = card.querySelector(`[data-field="${field}"]`);
|
||||
return el ? (el.tagName === 'TEXTAREA' ? el.value : el.value) : '';
|
||||
};
|
||||
const toList = (val) => val.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const toIntList = (val) => val.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
|
||||
|
||||
const url = g('url');
|
||||
const urlLower = url.toLowerCase();
|
||||
const isFile = urlLower.startsWith('file://');
|
||||
const isXml = isFile && /\.xml(\b|$)/.test(urlLower);
|
||||
|
||||
const schema = {};
|
||||
card.querySelectorAll('.schema-rows .schema-row').forEach(row => {
|
||||
const name = row.querySelector('input').value.trim();
|
||||
const type = row.querySelector('select').value;
|
||||
if (name) schema[name] = type;
|
||||
});
|
||||
|
||||
const spec = (specName, fields) => {
|
||||
const obj = {};
|
||||
let hasSpec = false;
|
||||
fields.forEach(({ field, isCheck }) => {
|
||||
const el = card.querySelector(`[data-spec="${specName}"][data-field="${field}"]`);
|
||||
if (!el) return;
|
||||
hasSpec = true;
|
||||
obj[field] = isCheck ? el.checked : (el.type === 'number' ? Number(el.value) : el.value);
|
||||
});
|
||||
return hasSpec ? obj : null;
|
||||
};
|
||||
|
||||
const csvSpec = isFile && !isXml ? spec('csv', [
|
||||
{ field: 'delimiter' },
|
||||
{ field: 'encoding' },
|
||||
{ field: 'trailer_rows', isCheck: false },
|
||||
{ field: 'header', isCheck: true },
|
||||
{ field: 'quoting', isCheck: true },
|
||||
]) : null;
|
||||
const xmlSpec = isXml ? spec('xml', [{ field: 'xpathstr' }, { field: 'encoding' }]) : null;
|
||||
|
||||
return {
|
||||
name: g('name'),
|
||||
url,
|
||||
comment: g('comment'),
|
||||
day_offset: parseInt(g('day_offset') || '0', 10),
|
||||
system_schema: schema,
|
||||
filter: g('filter'),
|
||||
sql: g('sql'),
|
||||
index_fields: toList(g('index_fields')),
|
||||
obfuscate_fields: toList(g('obfuscate_fields')),
|
||||
pci_redact_fields: toList(g('pci_redact_fields')),
|
||||
field_widths: toIntList(g('field_widths')),
|
||||
profile_thresholds: [],
|
||||
csv_spec: csvSpec,
|
||||
xml_spec: xmlSpec,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Field mapping ───────────────────────────────────────────────────────────
|
||||
function addMappingRow(srcVal, dstVal) {
|
||||
const container = document.getElementById('field-mapping-container');
|
||||
const row = document.createElement('div');
|
||||
row.className = 'mapping-row';
|
||||
|
||||
const srcInput = document.createElement('input');
|
||||
srcInput.type = 'text'; srcInput.placeholder = 'source_column'; srcInput.value = srcVal || '';
|
||||
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'mapping-arrow'; arrow.textContent = '→';
|
||||
|
||||
const dstInput = document.createElement('input');
|
||||
dstInput.type = 'text'; dstInput.placeholder = 'destination_column'; dstInput.value = dstVal || '';
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button'; removeBtn.className = 'btn btn-secondary btn-sm';
|
||||
removeBtn.textContent = '×';
|
||||
removeBtn.addEventListener('click', () => row.remove());
|
||||
|
||||
row.append(srcInput, arrow, dstInput, removeBtn);
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
function readFieldMapping() {
|
||||
const mapping = {};
|
||||
document.querySelectorAll('#field-mapping-container .mapping-row').forEach(row => {
|
||||
const inputs = row.querySelectorAll('input');
|
||||
const src = inputs[0]?.value.trim();
|
||||
const dst = inputs[1]?.value.trim();
|
||||
if (src && dst) mapping[src] = dst;
|
||||
});
|
||||
return mapping;
|
||||
}
|
||||
|
||||
// ── Collect full form payload ───────────────────────────────────────────────
|
||||
function collectPayload() {
|
||||
const g = (id) => document.getElementById(id)?.value || '';
|
||||
const sources = Array.from(
|
||||
document.querySelectorAll('#sources-container .syscfg-card')
|
||||
).map(card => readSyscfgCard(card));
|
||||
|
||||
const destCards = document.querySelectorAll('#destination-container .syscfg-card');
|
||||
const destination = destCards.length > 0 ? readSyscfgCard(destCards[0]) : {};
|
||||
|
||||
return {
|
||||
reference: g('f-reference'),
|
||||
name: g('f-name'),
|
||||
business_process: g('f-business-process'),
|
||||
data_type: g('f-data-type'),
|
||||
comment: g('f-comment'),
|
||||
pattern: g('f-pattern'),
|
||||
status: g('f-status'),
|
||||
frequency: g('f-frequency'),
|
||||
start_datetime: g('f-start-datetime') || null,
|
||||
sources,
|
||||
destination,
|
||||
field_mapping: readFieldMapping(),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Try URL ─────────────────────────────────────────────────────────────────
|
||||
async function testUrl(card) {
|
||||
const url = card.querySelector('.url-input').value.trim();
|
||||
const resultEl = card.querySelector('.url-test-result');
|
||||
if (!url) { showToast('Enter a URL first.', 'warn'); return; }
|
||||
const btn = card.querySelector('.btn-test-url');
|
||||
btn.disabled = true; btn.textContent = 'Checking…';
|
||||
resultEl.removeAttribute('hidden');
|
||||
resultEl.className = 'url-test-result url-test-loading';
|
||||
resultEl.textContent = 'Checking URL…';
|
||||
try {
|
||||
const resp = await fetch('/api/configs/test-url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
renderUrlTestResult(resultEl, data);
|
||||
} catch (err) {
|
||||
resultEl.className = 'url-test-result url-test-error';
|
||||
resultEl.textContent = `Error: ${err.message}`;
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Try URL';
|
||||
}
|
||||
}
|
||||
|
||||
function renderUrlTestResult(el, data) {
|
||||
if (data.type === 'non-file') {
|
||||
el.className = 'url-test-result url-test-info';
|
||||
el.textContent = data.message;
|
||||
return;
|
||||
}
|
||||
if (!data.found) {
|
||||
el.className = 'url-test-result url-test-error';
|
||||
el.textContent = `Not found: ${data.message || data.resolved}`;
|
||||
return;
|
||||
}
|
||||
el.className = 'url-test-result url-test-ok';
|
||||
const lines = [`✓ ${data.matches} file${data.matches !== 1 ? 's' : ''} matched`];
|
||||
(data.files || []).forEach(f => {
|
||||
if (f.error) { lines.push(` ⚠ ${f.path}: ${f.error}`); return; }
|
||||
const kb = (f.size_bytes / 1024).toFixed(1);
|
||||
lines.push(` ${f.path.split('/').pop()} ${kb} KB (modified ${f.modified})`);
|
||||
});
|
||||
el.textContent = lines.join('\n');
|
||||
}
|
||||
|
||||
// ── Toast ───────────────────────────────────────────────────────────────────
|
||||
function showToast(msg, type = 'ok') {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = `editor-toast editor-toast-${type} editor-toast-visible`;
|
||||
clearTimeout(el._timer);
|
||||
el._timer = setTimeout(() => el.classList.remove('editor-toast-visible'), 3500);
|
||||
}
|
||||
|
||||
// ── Form submit ─────────────────────────────────────────────────────────────
|
||||
document.getElementById('config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const payload = collectPayload();
|
||||
if (!payload.reference) { showToast('Reference is required.', 'error'); return; }
|
||||
if (!payload.name) { showToast('Display name is required.', 'error'); return; }
|
||||
if (payload.sources.length === 0) { showToast('At least one source is required.', 'error'); return; }
|
||||
|
||||
const btn = document.getElementById('btn-submit');
|
||||
btn.disabled = true;
|
||||
const url = IS_NEW ? '/api/configs/' : `/api/configs/${CONFIG_REF}`;
|
||||
const method = IS_NEW ? 'POST' : 'PUT';
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast(IS_NEW ? 'Config created!' : 'Config saved!', 'ok');
|
||||
setTimeout(() => { window.location.href = '/configs'; }, 1200);
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||
showToast(`Error ${resp.status}: ${err.detail || JSON.stringify(err)}`, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`Network error: ${err.message}`, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ── JSON preview ────────────────────────────────────────────────────────────
|
||||
document.getElementById('btn-preview-json').addEventListener('click', () => {
|
||||
const panel = document.getElementById('json-preview-panel');
|
||||
document.getElementById('json-preview-content').textContent =
|
||||
JSON.stringify(collectPayload(), null, 2);
|
||||
panel.removeAttribute('hidden');
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
document.getElementById('btn-close-json').addEventListener('click', () => {
|
||||
document.getElementById('json-preview-panel').setAttribute('hidden', '');
|
||||
});
|
||||
|
||||
// ── Add source ──────────────────────────────────────────────────────────────
|
||||
document.getElementById('btn-add-source').addEventListener('click', () => {
|
||||
const container = document.getElementById('sources-container');
|
||||
const idx = container.querySelectorAll('.syscfg-card').length;
|
||||
container.appendChild(buildSyscfgCard({}, 'source', idx));
|
||||
});
|
||||
|
||||
// ── Add mapping row ─────────────────────────────────────────────────────────
|
||||
document.getElementById('btn-add-mapping').addEventListener('click', () => addMappingRow());
|
||||
|
||||
// ── Initialise from server data or blank ────────────────────────────────────
|
||||
(async function init() {
|
||||
let data = null;
|
||||
if (!IS_NEW && CONFIG_REF) {
|
||||
try {
|
||||
const resp = await fetch(`/api/configs/${CONFIG_REF}`);
|
||||
if (resp.ok) data = await resp.json();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (data) {
|
||||
// Populate top meta fields
|
||||
const setField = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el || val === null || val === undefined) return;
|
||||
el.value = val;
|
||||
};
|
||||
setField('f-reference', data.reference);
|
||||
setField('f-name', data.name);
|
||||
setField('f-business-process', data.business_process);
|
||||
setField('f-data-type', data.data_type);
|
||||
setField('f-comment', data.comment || '');
|
||||
setField('f-pattern', data.pattern);
|
||||
setField('f-status', data.status);
|
||||
setField('f-frequency', data.frequency);
|
||||
if (data.start_datetime) {
|
||||
// datetime-local needs "YYYY-MM-DDTHH:MM"
|
||||
setField('f-start-datetime', data.start_datetime.slice(0, 16));
|
||||
}
|
||||
|
||||
// Sources
|
||||
const srcContainer = document.getElementById('sources-container');
|
||||
(data.sources || []).forEach((s, i) => {
|
||||
srcContainer.appendChild(buildSyscfgCard(s, 'source', i));
|
||||
});
|
||||
|
||||
// Destination
|
||||
const dstContainer = document.getElementById('destination-container');
|
||||
if (data.destination) {
|
||||
dstContainer.appendChild(buildSyscfgCard(data.destination, 'destination', 0));
|
||||
}
|
||||
|
||||
// Field mapping
|
||||
Object.entries(data.field_mapping || {}).forEach(([src, dst]) => addMappingRow(src, dst));
|
||||
|
||||
} else {
|
||||
// Blank form: one default source + destination
|
||||
document.getElementById('sources-container')
|
||||
.appendChild(buildSyscfgCard({}, 'source', 0));
|
||||
document.getElementById('destination-container')
|
||||
.appendChild(buildSyscfgCard({}, 'destination', 0));
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-container dashboard-stack">
|
||||
|
||||
<div class="detail-header">
|
||||
<div class="detail-crumbs">
|
||||
<a href="/">Dashboard</a> › Configs
|
||||
</div>
|
||||
<h1 style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px;">
|
||||
Recon Configs
|
||||
<a href="/configs/new" class="btn btn-primary">+ New Config</a>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll" style="flex:1">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Reference</th>
|
||||
<th>Name</th>
|
||||
<th>Business Process</th>
|
||||
<th>Frequency</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in configs %}
|
||||
<tr>
|
||||
<td><code>{{ c.reference }}</code></td>
|
||||
<td>{{ c.name }}</td>
|
||||
<td>{{ c.business_process }}</td>
|
||||
<td>{{ c.frequency }}</td>
|
||||
<td>
|
||||
<span class="badge badge-config-{{ c.status }}">{{ c.status }}</span>
|
||||
</td>
|
||||
<td style="text-align:right">
|
||||
<a href="/configs/{{ c.reference }}/edit" class="btn btn-secondary btn-sm">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" style="text-align:center; padding:40px; color:#64748b">
|
||||
No configs yet. <a href="/configs/new" style="color:var(--asb-yellow)">Create one.</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user