diff --git a/app/api/configs.py b/app/api/configs.py new file mode 100644 index 0000000..fc7ac40 --- /dev/null +++ b/app/api/configs.py @@ -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)} diff --git a/app/core/app_factory.py b/app/core/app_factory.py index 8b0202c..6824afd 100644 --- a/app/core/app_factory.py +++ b/app/core/app_factory.py @@ -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) diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..dab9bbb --- /dev/null +++ b/app/core/config.py @@ -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 diff --git a/app/core/refdata.py b/app/core/refdata.py index 93efeef..5ea2536 100644 --- a/app/core/refdata.py +++ b/app/core/refdata.py @@ -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" diff --git a/app/models/recon_auth.py b/app/models/recon_auth.py new file mode 100644 index 0000000..331ce09 --- /dev/null +++ b/app/models/recon_auth.py @@ -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 = "" diff --git a/app/views/views.py b/app/views/views.py index b54eba1..b205245 100644 --- a/app/views/views.py +++ b/app/views/views.py @@ -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, + }, + ) diff --git a/data/static/css/styles.css b/data/static/css/styles.css index 25f6c32..87a456f 100644 --- a/data/static/css/styles.css +++ b/data/static/css/styles.css @@ -1,723 +1,1064 @@ -/* ── Brand font ──────────────────────────────────────────── - ASB uses Overpass (open-source, SIL OFL) as their primary brand - typeface. Self-hosted as a single variable woff2 covering 400–700. */ -@font-face { - font-family: 'Overpass'; - font-style: normal; - font-weight: 400 700; - font-display: swap; - src: url(../fonts/overpass-latin.woff2) format('woff2'); -} - -*{ - margin: 0; - padding: 0; - box-sizing: border-box; - font-family: 'Overpass', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -} -:root { - /* ASB brand yellow — used for highlights, hover, and the nav accent bar. */ - --asb-yellow: #FFCC00; - --asb-yellow-dim: rgba(255, 204, 0, 0.5); -} -::selection{ - color: #000; - background: var(--asb-yellow); -} -nav{ - position: fixed; - background: #000; - width: 100%; - padding: 10px 0; - z-index: 12; - border-bottom: 3px solid var(--asb-yellow); -} -nav .menu{ - max-width: none; - width: 100%; - margin: auto; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 clamp(8px, 1vw, 16px); -} -.menu .logo a{ - text-decoration: none; - color: var(--asb-yellow); - font-size: 35px; - font-weight: 600; -} -.menu ul{ - display: flex; -} -.menu ul li{ - list-style: none; - margin-left: 7px; -} -.menu ul li:first-child{ - margin-left: 0px; -} -.menu ul li a{ - text-decoration: none; - color: #fff; - font-size: 18px; - font-weight: 500; - padding: 0px 15px; - border-radius: 5px; - transition: all 0.3s ease; -} -.menu ul li a:hover{ - background-color: var(--asb-yellow); - color: #000; - padding: 10px 15px; -} -/* ── Body & layout ─────────────────────────────────────────── */ -body { - background: url(../images/recon_ranger_bg.jpg) no-repeat center center fixed; - background-size: cover; - color: #e2e8f0; - min-height: 100vh; - display: flex; - flex-direction: column; -} -body::before { - content: ''; - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.55); - z-index: 0; - pointer-events: none; -} -main { - flex: 1; - padding-top: 70px; /* clear fixed nav */ - padding-left: clamp(4px, 0.8vw, 12px); - padding-right: clamp(4px, 0.8vw, 12px); - position: relative; - z-index: 1; -} -footer { - position: relative; - z-index: 1; - background: rgba(0, 0, 0, 0.85); - color: #888; - text-align: center; - padding: 18px 20px; - font-size: 14px; - letter-spacing: 0.03em; - border-top: 1px solid #2d2d2d; -} - -/* ── Dashboard content container ────────────────────────── */ -.dashboard-container { - width: 100%; - margin: 8px 0; - padding: 14px clamp(10px, 1vw, 18px); - background: rgba(15, 17, 23, 0.45); - backdrop-filter: blur(6px); - border-radius: 12px; - display: flex; - flex-direction: column; - height: calc(100vh - 86px); -} -.dashboard-container h1 { - font-size: 28px; - font-weight: 600; - color: #f1f5f9; - margin-bottom: 8px; - padding-bottom: 8px; - border-bottom: 2px solid var(--asb-yellow); - letter-spacing: 0.02em; - flex-shrink: 0; -} - -/* ── Data table ─────────────────────────────────────────── */ -.table-scroll { - overflow-x: auto; - overflow-y: auto; - flex: 1; - border-radius: 10px; - min-height: 0; -} -.table-scroll .data-table thead th { - position: sticky; - top: 0; - z-index: 1; -} -.data-table { - width: 100%; - border-collapse: collapse; - font-size: 14px; - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); -} -.data-table thead { - background: #1b1b2e; -} -.data-table thead th { - padding: 14px 18px; - text-align: left; - font-size: 12px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - color: #94a3b8; - border-bottom: 1px solid #2d2d3a; - white-space: nowrap; -} -.data-table tbody tr { - background: rgba(22, 22, 34, 0.72); - transition: background 0.15s ease; -} -.data-table tbody tr:nth-child(even) { - background: rgba(26, 26, 40, 0.72); -} -.data-table tbody tr:hover { - background: rgba(37, 37, 64, 0.85); -} -.data-table tbody td { - padding: 13px 18px; - border-bottom: 1px solid #22222e; - color: #cbd5e1; - vertical-align: middle; -} -.data-table tbody tr:last-child td { - border-bottom: none; -} - -/* ── Status badges ──────────────────────────────────────── */ -.badge { - display: inline-block; - padding: 3px 10px; - border-radius: 999px; - font-size: 12px; - font-weight: 600; - letter-spacing: 0.04em; - white-space: nowrap; -} -.badge-matched { background: rgba(34,197,94,0.15); color: #4ade80; } -.badge-unmatched { background: rgba(239,68,68,0.15); color: #f87171; } -.badge-pending { background: rgba(234,179,8,0.15); color: #facc15; } -.badge-none { background: rgba(100,116,139,0.15); color: #94a3b8; } -.badge-flag { background: rgba(249,115,22,0.15); color: #fb923c; } - -/* ── Filter bar ─────────────────────────────────────────── */ -.filter-bar { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: 16px; - margin-bottom: 8px; - padding: 12px 16px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid #2d2d3a; - border-radius: 10px; - flex-shrink: 0; -} -.filter-group { - display: flex; - flex-direction: column; - gap: 6px; -} -.filter-group label { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.07em; - color: #94a3b8; -} -.filter-group input { - background: #0f1117; - border: 1px solid #2d2d3a; - border-radius: 6px; - color: #e2e8f0; - font-size: 14px; - padding: 8px 12px; - min-width: 200px; - transition: border-color 0.15s ease; -} -.filter-group input:focus { - outline: none; - border-color: #6366f1; -} -.filter-group input[type="date"]::-webkit-calendar-picker-indicator { - filter: invert(0.6); - cursor: pointer; -} -.filter-actions { - display: flex; - gap: 8px; - align-items: flex-end; -} - -/* ── Buttons ────────────────────────────────────────────── */ -.btn { - display: inline-block; - padding: 8px 18px; - border-radius: 6px; - font-size: 13px; - font-weight: 600; - letter-spacing: 0.03em; - cursor: pointer; - text-decoration: none; - border: none; - transition: opacity 0.15s ease, background 0.15s ease; - white-space: nowrap; -} -.btn-primary { - background: #6366f1; - color: #fff; -} -.btn-primary:hover { - background: #4f46e5; -} -.btn-secondary { - background: rgba(255, 255, 255, 0.07); - color: #cbd5e1; - border: 1px solid #2d2d3a; -} -.btn-secondary:hover:not(.btn-disabled) { - background: rgba(255, 255, 255, 0.13); -} -.btn-disabled { - opacity: 0.35; - cursor: default; - pointer-events: none; -} - -/* ── Pagination ─────────────────────────────────────────── */ -.pagination { - display: flex; - gap: 10px; - justify-content: flex-end; - margin-top: 8px; - flex-shrink: 0; -} - -/* ── Dashboard timelines ────────────────────────────────── */ -.timeline-wrap { - display: flex; - flex-direction: column; - gap: 22px; - overflow-y: auto; - flex: 1; - min-height: 0; - padding-right: 4px; -} -.timeline-day { - background: rgba(0, 0, 0, 0.3); - border: 1px solid #2d2d3a; - border-radius: 10px; - padding: 14px 18px 22px; -} -.timeline-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 14px; - flex-wrap: wrap; - gap: 10px; -} -.timeline-header h2 { - font-size: 16px; - font-weight: 600; - color: #f1f5f9; - letter-spacing: 0.02em; -} -.timeline-today-tag { - display: inline-block; - margin-left: 8px; - padding: 2px 8px; - border-radius: 999px; - background: rgba(99, 102, 241, 0.18); - color: #a5b4fc; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; -} -.timeline-legend { - display: flex; - gap: 14px; - font-size: 12px; - color: #94a3b8; -} -.legend-item { - display: inline-flex; - align-items: center; - gap: 6px; -} -.swatch { - display: inline-block; - width: 12px; - height: 12px; - border-radius: 3px; -} -.swatch-completed { background: #4ade80; } -.swatch-running { background: #facc15; } -.swatch-scheduled { background: #818cf8; } - -.timeline-axis { - position: relative; - height: 18px; - margin: 0 0 4px; -} -.tick { - position: absolute; - top: 0; - transform: translateX(-50%); - color: #64748b; - font-size: 10px; - letter-spacing: 0.05em; -} -.tick-label { user-select: none; } - -.timeline-track { - --lane-count: 1; - --lane-height: 26px; - position: relative; - background: #0f1117; - border: 1px solid #22222e; - border-radius: 6px; - min-height: 32px; - height: calc(var(--lane-count) * var(--lane-height) + 6px); - padding: 3px 0; - overflow: visible; -} -.gridline { - position: absolute; - top: 0; - bottom: 0; - width: 1px; - background: rgba(148, 163, 184, 0.08); - pointer-events: none; -} -.now-marker { - position: absolute; - top: 0; - bottom: 0; - width: 2px; - background: #ef4444; - box-shadow: 0 0 6px rgba(239, 68, 68, 0.6); - pointer-events: none; - z-index: 3; -} -.timeline-empty { - color: #475569; - font-size: 12px; - text-align: center; - padding: 30px 0; -} -.job-bar { - --lane: 0; - position: absolute; - height: 22px; - top: calc(var(--lane) * var(--lane-height, 26px) + 3px); - border-radius: 4px; - padding: 0 6px; - display: flex; - align-items: center; - color: #0f172a; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.02em; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); - z-index: 2; - min-width: 4px; -} -.job-bar-label { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - flex: 1; - min-width: 0; -} -.job-bar-completed { background: rgba(74, 222, 128, 0.85); } -.job-bar-running { background: rgba(250, 204, 21, 0.85); } -.job-bar-scheduled { - background: rgba(129, 140, 248, 0.85); - border: 1px dashed rgba(255, 255, 255, 0.25); - color: #1e1b4b; -} - -/* ── Dashboard metrics cards ────────────────────────────── */ -.metrics-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 14px; - margin-top: 18px; - padding-bottom: 4px; -} -.metric-card { - background: rgba(0, 0, 0, 0.3); - border: 1px solid #2d2d3a; - border-radius: 10px; - padding: 14px 16px 16px; - display: flex; - flex-direction: column; - gap: 10px; - min-width: 0; -} -.metric-card h3 { - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; - color: #94a3b8; - margin: 0; - padding-bottom: 6px; - border-bottom: 1px solid var(--asb-yellow); -} -.metric-card .num { font-variant-numeric: tabular-nums; text-align: right; } -.metric-card .good { color: #4ade80; } -.metric-card .bad { color: #f87171; } -.metric-card .warn { color: #facc15; } -.metric-card .muted { color: #64748b; font-size: 12px; } - -.big-stat { - display: flex; - align-items: baseline; - gap: 8px; -} -.big-stat-value { - font-size: 36px; - font-weight: 700; - color: #f1f5f9; - letter-spacing: -0.01em; - line-height: 1; -} -.big-stat-sub { color: #94a3b8; font-size: 12px; } - -.kv-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 6px; -} -.kv-list li { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 10px; - font-size: 13px; - color: #cbd5e1; - line-height: 1.35; -} -.kv-list li span:first-child { - overflow: hidden; - text-overflow: ellipsis; -} - -.metric-table-scroll { - /* Let the table scroll horizontally inside a narrow card instead of - spilling out. The card itself stays put. */ - overflow-x: auto; - margin: 0 -4px; -} -.metric-table { - width: 100%; - border-collapse: collapse; - font-size: 12px; -} -.metric-table th, .metric-table td { - padding: 6px 6px; - text-align: right; - color: #cbd5e1; - white-space: nowrap; -} -.metric-table thead th { - color: #94a3b8; - font-weight: 600; - font-size: 11px; - letter-spacing: 0.04em; - border-bottom: 1px solid #2d2d3a; -} -.metric-table tbody th { - text-align: left; - color: #94a3b8; - font-weight: 500; -} -.metric-table tbody tr + tr td, -.metric-table tbody tr + tr th { border-top: 1px solid rgba(45, 45, 58, 0.6); } - -.empty-note { - font-size: 13px; - padding: 6px 0; -} - -/* Dashboard variant: allow content to flow & page to scroll naturally. */ -.dashboard-container.dashboard-stack { - height: auto; - min-height: calc(100vh - 86px); -} -.dashboard-container.dashboard-stack .timeline-wrap { - flex: 0 0 auto; - overflow: visible; -} - -/* ── Clickable rows / job links ─────────────────────────── */ -.row-link { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 10px; - width: 100%; - color: inherit; - text-decoration: none; - padding: 2px 4px; - margin: -2px -4px; - border-radius: 4px; - transition: background 0.15s ease; -} -.row-link:hover { background: rgba(99, 102, 241, 0.12); } - -.job-link { - color: var(--asb-yellow); - text-decoration: none; - font-weight: 600; -} -.job-link:hover { text-decoration: underline; } -.job-link .muted { font-weight: 400; } - -a.job-bar { text-decoration: none; cursor: pointer; } -a.job-bar:hover { filter: brightness(1.1); box-shadow: 0 0 0 1px rgba(255,255,255,0.4); } - -/* ── Job detail page ────────────────────────────────────── */ -.detail-header { - margin-bottom: 14px; - padding-bottom: 10px; - border-bottom: 2px solid var(--asb-yellow); -} -.detail-header h1 { - border-bottom: none; - margin-bottom: 4px; - padding-bottom: 0; - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; -} -.detail-crumbs { - font-size: 13px; - color: #94a3b8; - margin-bottom: 6px; -} -.detail-crumbs a { - color: var(--asb-yellow); - text-decoration: none; -} -.detail-crumbs a:hover { text-decoration: underline; } -.detail-subtitle { - font-size: 13px; - margin-top: 4px; -} -.detail-subtitle code, -.kv-list code, -.metric-table code { - background: rgba(255,255,255,0.06); - padding: 1px 6px; - border-radius: 4px; - font-size: 12px; - color: #cbd5e1; -} - -.swatch-failed { background: #f87171; } - -.badge-status-completed { background: rgba(34,197,94,0.18); color: #4ade80; font-size: 13px; padding: 4px 12px; } -.badge-status-failed { background: rgba(239,68,68,0.18); color: #f87171; font-size: 13px; padding: 4px 12px; } -.badge-status-running { background: rgba(234,179,8,0.18); color: #facc15; font-size: 13px; padding: 4px 12px; } -.badge-status-created { background: rgba(129,140,248,0.18); color: #a5b4fc; font-size: 13px; padding: 4px 12px; } -.badge-status-cancelled { background: rgba(100,116,139,0.18); color: #94a3b8; font-size: 13px; padding: 4px 12px; } - -.history-timeline { padding: 14px 18px 16px; } -.history-track { - position: relative; - background: #0f1117; - border: 1px solid #22222e; - border-radius: 6px; - height: 56px; - margin-bottom: 6px; - overflow: visible; -} -.history-dot { - position: absolute; - top: 50%; - width: 14px; - height: 14px; - margin-left: -7px; - margin-top: -7px; - border-radius: 50%; - background: #4ade80; - box-shadow: 0 0 0 2px rgba(15, 17, 23, 0.9); - transition: transform 0.15s ease, box-shadow 0.15s ease; - z-index: 2; -} -.history-dot:hover { - transform: scale(1.35); - z-index: 3; -} -.history-dot-completed { background: #4ade80; } -.history-dot-failed { background: #f87171; } -.history-dot-running { background: #facc15; } -.history-dot-scheduled { - background: #818cf8; - border: 1px dashed rgba(255,255,255,0.35); -} -.history-dot.is-current { - width: 18px; - height: 18px; - margin-left: -9px; - margin-top: -9px; - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.55), 0 0 12px rgba(99,102,241,0.6); - z-index: 4; -} -.history-footnote { - font-size: 12px; - padding-top: 4px; -} - -.metric-card-wide { - grid-column: 1 / -1; -} -.results-table tbody th { - width: 30%; - padding-right: 14px; -} -.results-table tbody td { - text-align: left; -} -.nested-item { display: block; font-size: 13px; } -.nested-key { color: #94a3b8; margin-right: 6px; } - -/* ── Instant CSS tooltip (replaces native title= delay) ── */ -/* Note: do NOT add `position: relative` here — it would override the - `position: absolute` on .job-bar / .history-dot. The pseudo-element below - positions relative to whichever ancestor is already positioned. */ -[data-tip]::after { - content: attr(data-tip); - position: absolute; - bottom: calc(100% + 6px); - left: 50%; - transform: translateX(-50%); - background: #0f1117; - color: #e2e8f0; - border: 1px solid #2d2d3a; - border-radius: 6px; - padding: 6px 9px; - font-size: 12px; - font-weight: 500; - white-space: nowrap; - pointer-events: none; - opacity: 0; - visibility: hidden; - z-index: 50; - box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5); - letter-spacing: 0.01em; -} -[data-tip]:hover::after, -[data-tip]:focus-visible::after { - opacity: 1; - visibility: visible; - transition-delay: 0s; -} +/* ── Brand font ──────────────────────────────────────────── + ASB uses Overpass (open-source, SIL OFL) as their primary brand + typeface. Self-hosted as a single variable woff2 covering 400–700. */ +@font-face { + font-family: 'Overpass'; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url(../fonts/overpass-latin.woff2) format('woff2'); +} + +*{ + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Overpass', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} +:root { + /* ASB brand yellow — used for highlights, hover, and the nav accent bar. */ + --asb-yellow: #FFCC00; + --asb-yellow-dim: rgba(255, 204, 0, 0.5); + /* Shared tokens used across panels, inputs, and focus rings. */ + --border: #2d2d3a; + --border-dk: #22222e; + --bg-input: #0f1117; + --accent: #6366f1; +} +::selection{ + color: #000; + background: var(--asb-yellow); +} +nav{ + position: fixed; + background: #000; + width: 100%; + padding: 10px 0; + z-index: 12; + border-bottom: 3px solid var(--asb-yellow); +} +nav .menu{ + max-width: none; + width: 100%; + margin: auto; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 clamp(8px, 1vw, 16px); +} +.menu .logo a{ + text-decoration: none; + color: var(--asb-yellow); + font-size: 35px; + font-weight: 600; +} +.menu ul{ + display: flex; +} +.menu ul li{ + list-style: none; + margin-left: 7px; +} +.menu ul li:first-child{ + margin-left: 0px; +} +.menu ul li a{ + text-decoration: none; + color: #fff; + font-size: 18px; + font-weight: 500; + padding: 0px 15px; + border-radius: 5px; + transition: all 0.3s ease; +} +.menu ul li a:hover{ + background-color: var(--asb-yellow); + color: #000; + padding: 10px 15px; +} +/* ── Body & layout ─────────────────────────────────────────── */ +body { + background: url(../images/recon_ranger_bg.jpg) no-repeat center center fixed; + background-size: cover; + color: #e2e8f0; + min-height: 100vh; + display: flex; + flex-direction: column; +} +body::before { + content: ''; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 0; + pointer-events: none; +} +main { + flex: 1; + padding-top: 70px; /* clear fixed nav */ + padding-left: clamp(4px, 0.8vw, 12px); + padding-right: clamp(4px, 0.8vw, 12px); + position: relative; + z-index: 1; +} +footer { + position: relative; + z-index: 1; + background: rgba(0, 0, 0, 0.85); + color: #888; + text-align: center; + padding: 18px 20px; + font-size: 14px; + letter-spacing: 0.03em; + border-top: 1px solid #2d2d2d; +} + +/* ── Dashboard content container ────────────────────────── */ +.dashboard-container { + width: 100%; + margin: 8px 0; + padding: 14px clamp(10px, 1vw, 18px); + background: rgba(15, 17, 23, 0.45); + backdrop-filter: blur(6px); + border-radius: 12px; + display: flex; + flex-direction: column; + height: calc(100vh - 86px); +} +.dashboard-container h1 { + font-size: 28px; + font-weight: 600; + color: #f1f5f9; + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 2px solid var(--asb-yellow); + letter-spacing: 0.02em; + flex-shrink: 0; +} + +/* ── Data table ─────────────────────────────────────────── */ +.table-scroll { + overflow-x: auto; + overflow-y: auto; + flex: 1; + border-radius: 10px; + min-height: 0; +} +.table-scroll .data-table thead th { + position: sticky; + top: 0; + z-index: 1; +} +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); +} +.data-table thead { + background: #1b1b2e; +} +.data-table thead th { + padding: 14px 18px; + text-align: left; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #94a3b8; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} +.data-table tbody tr { + background: rgba(22, 22, 34, 0.72); + transition: background 0.15s ease; +} +.data-table tbody tr:nth-child(even) { + background: rgba(26, 26, 40, 0.72); +} +.data-table tbody tr:hover { + background: rgba(37, 37, 64, 0.85); +} +.data-table tbody td { + padding: 13px 18px; + border-bottom: 1px solid var(--border-dk); + color: #cbd5e1; + vertical-align: middle; +} +.data-table tbody tr:last-child td { + border-bottom: none; +} + +/* ── Status badges ──────────────────────────────────────── */ +.badge { + display: inline-block; + padding: 3px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + white-space: nowrap; +} +.badge-matched { background: rgba(34,197,94,0.15); color: #4ade80; } +.badge-unmatched { background: rgba(239,68,68,0.15); color: #f87171; } +.badge-pending { background: rgba(234,179,8,0.15); color: #facc15; } +.badge-none { background: rgba(100,116,139,0.15); color: #94a3b8; } +.badge-flag { background: rgba(249,115,22,0.15); color: #fb923c; } + +/* ── Panel chrome ───────────────────────────────────────── */ +/* Shared border + radius for every dark panel/card. Individual rules add + their own background, padding, and layout on top. */ +.filter-bar, .timeline-day, .metric-card, +.editor-section, .editor-full-section, .editor-meta-strip, .editor-json-panel { + border: 1px solid var(--border); + border-radius: 10px; +} + +/* ── Filter bar ─────────────────────────────────────────── */ +.filter-bar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 16px; + margin-bottom: 8px; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.04); + flex-shrink: 0; +} +.filter-group { + display: flex; + flex-direction: column; + gap: 6px; +} +.filter-group input { + font-size: 14px; + padding: 8px 12px; + border-radius: 6px; + min-width: 200px; +} +.filter-group input[type="date"]::-webkit-calendar-picker-indicator { + filter: invert(0.6); + cursor: pointer; +} +.filter-actions { + display: flex; + gap: 8px; + align-items: flex-end; +} + +/* ── Form label — shared by filter bar and editor ───────── */ +.filter-group label, +.editor-field label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: #94a3b8; +} + +/* ── Form input base — shared bg/border/color ───────────── */ +.filter-group input, +.editor-field input, .editor-field select, .editor-field textarea, +.schema-row input, .schema-row select, +.mapping-row input { + background: var(--bg-input); + border: 1px solid var(--border); + color: #e2e8f0; +} + +/* ── Focus ring — all interactive inputs share this ─────── */ +.filter-group input:focus, +.editor-field input:focus, .editor-field select:focus, .editor-field textarea:focus, +.schema-row input:focus, .schema-row select:focus, +.mapping-row input:focus { + outline: none; + border-color: var(--accent); +} + +/* ── Buttons ────────────────────────────────────────────── */ +.btn { + display: inline-block; + padding: 8px 18px; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.03em; + cursor: pointer; + text-decoration: none; + border: none; + transition: opacity 0.15s ease, background 0.15s ease; + white-space: nowrap; +} +.btn-primary { + background: var(--accent); + color: #fff; +} +.btn-primary:hover { + background: #4f46e5; +} +.btn-secondary { + background: rgba(255, 255, 255, 0.07); + color: #cbd5e1; + border: 1px solid var(--border); +} +.btn-secondary:hover:not(.btn-disabled) { + background: rgba(255, 255, 255, 0.13); +} +.btn-disabled { + opacity: 0.35; + cursor: default; + pointer-events: none; +} +.btn-sm { + padding: 5px 12px; + font-size: 12px; +} + +/* ── Pagination ─────────────────────────────────────────── */ +.pagination { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 8px; + flex-shrink: 0; +} + +/* ── Dashboard timelines ────────────────────────────────── */ +.timeline-wrap { + display: flex; + flex-direction: column; + gap: 22px; + overflow-y: auto; + flex: 1; + min-height: 0; + padding-right: 4px; +} +.timeline-day { + background: rgba(0, 0, 0, 0.3); + padding: 14px 18px 22px; +} +.timeline-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; + flex-wrap: wrap; + gap: 10px; +} +.timeline-header h2 { + font-size: 16px; + font-weight: 600; + color: #f1f5f9; + letter-spacing: 0.02em; +} +.timeline-today-tag { + display: inline-block; + margin-left: 8px; + padding: 2px 8px; + border-radius: 999px; + background: rgba(99, 102, 241, 0.18); + color: #a5b4fc; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.timeline-legend { + display: flex; + gap: 14px; + font-size: 12px; + color: #94a3b8; +} +.legend-item { + display: inline-flex; + align-items: center; + gap: 6px; +} +.swatch { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 3px; +} +.swatch-completed { background: #4ade80; } +.swatch-running { background: #facc15; } +.swatch-scheduled { background: #818cf8; } + +.timeline-axis { + position: relative; + height: 18px; + margin: 0 0 4px; +} +.tick { + position: absolute; + top: 0; + transform: translateX(-50%); + color: #64748b; + font-size: 10px; + letter-spacing: 0.05em; +} +.tick-label { user-select: none; } + +.timeline-track { + --lane-count: 1; + --lane-height: 26px; + position: relative; + background: var(--bg-input); + border: 1px solid var(--border-dk); + border-radius: 6px; + min-height: 32px; + height: calc(var(--lane-count) * var(--lane-height) + 6px); + padding: 3px 0; + overflow: visible; +} +.gridline { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: rgba(148, 163, 184, 0.08); + pointer-events: none; +} +.now-marker { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background: #ef4444; + box-shadow: 0 0 6px rgba(239, 68, 68, 0.6); + pointer-events: none; + z-index: 3; +} +.timeline-empty { + color: #475569; + font-size: 12px; + text-align: center; + padding: 30px 0; +} +.job-bar { + --lane: 0; + position: absolute; + height: 22px; + top: calc(var(--lane) * var(--lane-height, 26px) + 3px); + border-radius: 4px; + padding: 0 6px; + display: flex; + align-items: center; + color: #0f172a; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + z-index: 2; + min-width: 4px; +} +.job-bar-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} +.job-bar-completed { background: rgba(74, 222, 128, 0.85); } +.job-bar-running { background: rgba(250, 204, 21, 0.85); } +.job-bar-scheduled { + background: rgba(129, 140, 248, 0.85); + border: 1px dashed rgba(255, 255, 255, 0.25); + color: #1e1b4b; +} + +/* ── Dashboard metrics cards ────────────────────────────── */ +.metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 14px; + margin-top: 18px; + padding-bottom: 4px; +} +.metric-card { + background: rgba(0, 0, 0, 0.3); + padding: 14px 16px 16px; + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} +.metric-card h3 { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #94a3b8; + margin: 0; + padding-bottom: 6px; + border-bottom: 1px solid var(--asb-yellow); +} +.metric-card .num { font-variant-numeric: tabular-nums; text-align: right; } +.metric-card .good { color: #4ade80; } +.metric-card .bad { color: #f87171; } +.metric-card .warn { color: #facc15; } +.metric-card .muted { color: #64748b; font-size: 12px; } + +.big-stat { + display: flex; + align-items: baseline; + gap: 8px; +} +.big-stat-value { + font-size: 36px; + font-weight: 700; + color: #f1f5f9; + letter-spacing: -0.01em; + line-height: 1; +} +.big-stat-sub { color: #94a3b8; font-size: 12px; } + +.kv-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.kv-list li { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 10px; + font-size: 13px; + color: #cbd5e1; + line-height: 1.35; +} +.kv-list li span:first-child { + overflow: hidden; + text-overflow: ellipsis; +} + +.metric-table-scroll { + /* Let the table scroll horizontally inside a narrow card instead of + spilling out. The card itself stays put. */ + overflow-x: auto; + margin: 0 -4px; +} +.metric-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} +.metric-table th, .metric-table td { + padding: 6px 6px; + text-align: right; + color: #cbd5e1; + white-space: nowrap; +} +.metric-table thead th { + color: #94a3b8; + font-weight: 600; + font-size: 11px; + letter-spacing: 0.04em; + border-bottom: 1px solid var(--border); +} +.metric-table tbody th { + text-align: left; + color: #94a3b8; + font-weight: 500; +} +.metric-table tbody tr + tr td, +.metric-table tbody tr + tr th { border-top: 1px solid rgba(45, 45, 58, 0.6); } + +.empty-note { + font-size: 13px; + padding: 6px 0; +} + +/* Dashboard variant: allow content to flow & page to scroll naturally. */ +.dashboard-container.dashboard-stack { + height: auto; + min-height: calc(100vh - 86px); +} +.dashboard-container.dashboard-stack .timeline-wrap { + flex: 0 0 auto; + overflow: visible; +} + +/* ── Clickable rows / job links ─────────────────────────── */ +.row-link { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 10px; + width: 100%; + color: inherit; + text-decoration: none; + padding: 2px 4px; + margin: -2px -4px; + border-radius: 4px; + transition: background 0.15s ease; +} +.row-link:hover { background: rgba(99, 102, 241, 0.12); } + +.job-link { + color: var(--asb-yellow); + text-decoration: none; + font-weight: 600; +} +.job-link:hover { text-decoration: underline; } +.job-link .muted { font-weight: 400; } + +a.job-bar { text-decoration: none; cursor: pointer; } +a.job-bar:hover { filter: brightness(1.1); box-shadow: 0 0 0 1px rgba(255,255,255,0.4); } + +/* ── Job detail page ────────────────────────────────────── */ +.detail-header { + margin-bottom: 14px; + padding-bottom: 10px; + border-bottom: 2px solid var(--asb-yellow); +} +.detail-header h1 { + border-bottom: none; + margin-bottom: 4px; + padding-bottom: 0; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +.detail-crumbs { + font-size: 13px; + color: #94a3b8; + margin-bottom: 6px; +} +.detail-crumbs a { + color: var(--asb-yellow); + text-decoration: none; +} +.detail-crumbs a:hover { text-decoration: underline; } +.detail-subtitle { + font-size: 13px; + margin-top: 4px; +} +.detail-subtitle code, +.kv-list code, +.metric-table code { + background: rgba(255,255,255,0.06); + padding: 1px 6px; + border-radius: 4px; + font-size: 12px; + color: #cbd5e1; +} + +.swatch-failed { background: #f87171; } + +/* Shared size for all job/config status badges (colour set per variant below) */ +[class*="badge-status-"] { font-size: 13px; padding: 4px 12px; } +.badge-status-completed { background: rgba(34,197,94,0.18); color: #4ade80; } +.badge-status-failed { background: rgba(239,68,68,0.18); color: #f87171; } +.badge-status-running { background: rgba(234,179,8,0.18); color: #facc15; } +.badge-status-created { background: rgba(129,140,248,0.18); color: #a5b4fc; } +.badge-status-cancelled { background: rgba(100,116,139,0.18); color: #94a3b8; } + +/* Config lifecycle status badges */ +.badge-config-published { background: rgba(34,197,94,0.15); color: #4ade80; } +.badge-config-draft { background: rgba(234,179,8,0.15); color: #facc15; } +.badge-config-archived { background: rgba(100,116,139,0.15); color: #94a3b8; } + +.history-timeline { padding: 14px 18px 16px; } +.history-track { + position: relative; + background: var(--bg-input); + border: 1px solid var(--border-dk); + border-radius: 6px; + height: 56px; + margin-bottom: 6px; + overflow: visible; +} +.history-dot { + position: absolute; + top: 50%; + width: 14px; + height: 14px; + margin-left: -7px; + margin-top: -7px; + border-radius: 50%; + background: #4ade80; + box-shadow: 0 0 0 2px rgba(15, 17, 23, 0.9); + transition: transform 0.15s ease, box-shadow 0.15s ease; + z-index: 2; +} +.history-dot:hover { + transform: scale(1.35); + z-index: 3; +} +.history-dot-completed { background: #4ade80; } +.history-dot-failed { background: #f87171; } +.history-dot-running { background: #facc15; } +.history-dot-scheduled { + background: #818cf8; + border: 1px dashed rgba(255,255,255,0.35); +} +.history-dot.is-current { + width: 18px; + height: 18px; + margin-left: -9px; + margin-top: -9px; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.55), 0 0 12px rgba(99,102,241,0.6); + z-index: 4; +} +.history-footnote { + font-size: 12px; + padding-top: 4px; +} + +.metric-card-wide { + grid-column: 1 / -1; +} +.results-table tbody th { + width: 30%; + padding-right: 14px; +} +.results-table tbody td { + text-align: left; +} +.nested-item { display: block; font-size: 13px; } +.nested-key { color: #94a3b8; margin-right: 6px; } + +/* ── Instant CSS tooltip (replaces native title= delay) ── */ +/* Note: do NOT add `position: relative` here — it would override the + `position: absolute` on .job-bar / .history-dot. The pseudo-element below + positions relative to whichever ancestor is already positioned. */ +[data-tip]::after { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + background: var(--bg-input); + color: #e2e8f0; + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 9px; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + pointer-events: none; + opacity: 0; + visibility: hidden; + z-index: 50; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5); + letter-spacing: 0.01em; +} +[data-tip]:hover::after, +[data-tip]:focus-visible::after { + opacity: 1; + visibility: visible; + transition-delay: 0s; +} + +/* ── Config editor layout ───────────────────────────────── */ + +/* Meta strip — wrapping flex row of form fields */ +.editor-meta-strip { + display: flex; + flex-wrap: wrap; + gap: 14px 20px; + padding: 16px 18px; + background: rgba(0,0,0,0.25); + margin-bottom: 16px; +} + +/* Two-column body */ +.editor-body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 16px; +} +@media (max-width: 860px) { + .editor-body { grid-template-columns: 1fr; } +} + +/* Section wrappers — shared chrome handled by the panel block above */ +.editor-section, +.editor-full-section { + background: rgba(0,0,0,0.25); + padding: 14px 16px; +} +.editor-section { + display: flex; + flex-direction: column; + gap: 10px; +} +.editor-full-section { + margin-bottom: 16px; +} + +.editor-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} +.editor-section-header h2 { + font-size: 13px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #94a3b8; + padding-bottom: 6px; + border-bottom: 1px solid var(--asb-yellow); + flex: 1; + margin-right: 12px; +} + +/* Individual form fields */ +.editor-field { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 160px; +} +.editor-field-wide { min-width: 260px; flex: 1; } +.editor-field-full { flex-basis: 100%; width: 100%; } + +/* .editor-field label shares its rule with .filter-group label above */ + +.editor-field input, +.editor-field select, +.editor-field textarea { + border-radius: 6px; + font-size: 13px; + padding: 7px 11px; + transition: border-color 0.15s ease; + width: 100%; +} +.editor-field input[readonly] { + opacity: 0.6; + cursor: not-allowed; +} +.editor-field textarea { resize: vertical; } +.field-hint { + font-size: 11px; + color: #64748b; + line-height: 1.4; +} +.field-hint code { + background: rgba(255,255,255,0.06); + padding: 1px 5px; + border-radius: 3px; + font-size: 11px; +} +.req { color: #f87171; } + +/* System config card */ +.syscfg-card { + background: rgba(255,255,255,0.03); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 10px; +} +.syscfg-card:last-child { margin-bottom: 0; } +.syscfg-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} +.syscfg-card-title { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--asb-yellow); +} +.syscfg-grid { + display: flex; + flex-wrap: wrap; + gap: 12px 16px; +} +.syscfg-section { + margin-top: 10px; + border-top: 1px solid var(--border); + padding-top: 8px; +} +.syscfg-section summary { + font-size: 12px; + font-weight: 600; + color: #94a3b8; + cursor: pointer; + user-select: none; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; +} +.syscfg-section summary:hover { color: #e2e8f0; } +.syscfg-section-count { + font-size: 11px; + color: #64748b; + font-weight: 400; + font-style: italic; +} +.syscfg-section > .syscfg-grid, +.syscfg-section > .schema-builder { + padding-top: 10px; +} + +/* URL test row */ +.url-input-row { + display: flex; + gap: 8px; + align-items: stretch; +} +.url-input { flex: 1; } +.url-test-result { + margin-top: 6px; + padding: 8px 10px; + border-radius: 6px; + font-size: 12px; + font-family: monospace; + white-space: pre-wrap; + line-height: 1.5; +} +.url-test-loading { background: rgba(99,102,241,0.1); color: #a5b4fc; border: 1px solid #3730a3; } +.url-test-ok { background: rgba(34,197,94,0.08); color: #86efac; border: 1px solid #166534; } +.url-test-error { background: rgba(239,68,68,0.1); color: #fca5a5; border: 1px solid #991b1b; } +.url-test-info { background: rgba(234,179,8,0.08); color: #fde68a; border: 1px solid #78350f; } + +/* Schema builder */ +.schema-builder { display: flex; flex-direction: column; gap: 6px; } +.kv-table-header { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: 8px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.07em; + color: #64748b; + padding: 0 2px; +} +.schema-row { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: 8px; + align-items: center; +} +.schema-row input, +.schema-row select { + border-radius: 5px; + font-size: 12px; + padding: 5px 8px; + width: 100%; +} + +/* Field mapping */ +.mapping-header { + display: grid; + grid-template-columns: 1fr auto 1fr auto; + gap: 8px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.07em; + color: #64748b; + padding: 0 2px 4px; +} +.mapping-row { + display: grid; + grid-template-columns: 1fr auto 1fr auto; + gap: 8px; + align-items: center; + margin-bottom: 6px; +} +.mapping-row input { + border-radius: 5px; + font-size: 13px; + padding: 6px 10px; +} +.mapping-arrow { color: var(--accent); font-size: 16px; text-align: center; } + +/* Toggle checkbox */ +.toggle-label { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; +} +.toggle-label input[type="checkbox"] { position: absolute; opacity: 0; width: 0; height: 0; } +.toggle-track { + width: 36px; + height: 20px; + background: #2d2d3a; + border-radius: 999px; + position: relative; + transition: background 0.2s; + flex-shrink: 0; +} +.toggle-track::after { + content: ''; + position: absolute; + top: 3px; left: 3px; + width: 14px; height: 14px; + background: #94a3b8; + border-radius: 50%; + transition: transform 0.2s, background 0.2s; +} +.toggle-label input:checked ~ .toggle-track { background: var(--accent); } +.toggle-label input:checked ~ .toggle-track::after { transform: translateX(16px); background: #fff; } + +/* Action bar */ +.editor-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 12px 0 4px; + border-top: 1px solid var(--border); + margin-top: 4px; +} + +/* JSON preview */ +.editor-json-panel { + background: rgba(0,0,0,0.4); + padding: 16px; + margin-top: 16px; +} +.editor-json-pre { + background: #0a0c10; + border: 1px solid var(--border-dk); + border-radius: 6px; + padding: 14px; + font-size: 12px; + line-height: 1.6; + color: #cbd5e1; + overflow-x: auto; + max-height: 50vh; + white-space: pre; +} + +/* Toast notification */ +.editor-toast { + position: fixed; + bottom: 28px; + right: 28px; + background: #1e293b; + border: 1px solid #334155; + border-radius: 8px; + padding: 12px 20px; + font-size: 13px; + font-weight: 500; + color: #e2e8f0; + box-shadow: 0 8px 24px rgba(0,0,0,0.5); + z-index: 200; + opacity: 0; + transform: translateY(8px); + transition: opacity 0.2s ease, transform 0.2s ease; + pointer-events: none; +} +.editor-toast-visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} +.editor-toast-ok { border-left: 3px solid #4ade80; } +.editor-toast-warn { border-left: 3px solid #facc15; } +.editor-toast-error { border-left: 3px solid #f87171; } diff --git a/data/templates/base.html b/data/templates/base.html index 192019b..0b7ab22 100644 --- a/data/templates/base.html +++ b/data/templates/base.html @@ -19,6 +19,7 @@ diff --git a/data/templates/config_editor.html b/data/templates/config_editor.html new file mode 100644 index 0000000..2d41941 --- /dev/null +++ b/data/templates/config_editor.html @@ -0,0 +1,721 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+
+ Dashboard › + Configs › + {{ title }} +
+

{{ title }}

+
+ + {# ── Toast notification ───────────────────────────────── #} +
+ +
+ + {# ── Top meta strip ───────────────────────────────────── #} +
+ +
+ + + Unique identifier — lowercase, hyphen-separated. +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + Draft results go to staging tables; Published go to live dashboards. +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
{# /meta-strip #} + + {# ── Two-column editor body ────────────────────────────── #} +
+ + {# ── Sources column ────────────────────────────────── #} +
+
+

Sources

+ +
+
+
+ + {# ── Destination column ────────────────────────────── #} +
+
+

Destination

+
+
+
+ +
{# /editor-body #} + + {# ── Field mapping ─────────────────────────────────────── #} +
+
+

Field Mapping

+ +
+

Map source column names to destination column names when they differ. Leave empty if all column names already match.

+
+
+ Source Column + + Destination Column + +
+
+
+ + {# ── Action bar ─────────────────────────────────────────── #} +
+ Cancel + + +
+ +
+ + {# ── JSON preview panel (hidden by default) ───────────── #} + + +
+ +{# ── System Config Card Template ──────────────────────────────────────────── #} + + + +{% endblock %} diff --git a/data/templates/configs_list.html b/data/templates/configs_list.html new file mode 100644 index 0000000..2786cc6 --- /dev/null +++ b/data/templates/configs_list.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+
+ Dashboard › Configs +
+

+ Recon Configs + + New Config +

+
+ +
+ + + + + + + + + + + + + {% for c in configs %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
ReferenceNameBusiness ProcessFrequencyStatus
{{ c.reference }}{{ c.name }}{{ c.business_process }}{{ c.frequency }} + {{ c.status }} + + Edit +
+ No configs yet. Create one. +
+
+ +
+{% endblock %}