diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b37cce8..ff9735c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -120,6 +120,8 @@ Security-relevant events are **persisted locally** (the `AuditEvent` table — t **Effective configuration (`services/effective_config.py`, `web/admin_config.py`, read-only `/admin/config`).** An admin-only viewer that answers "what config is this process using, where did each value come from, and which optional features are available?" without shell access. `snapshot(session)` introspects the pydantic `Settings` **and** the six admin-editable DB settings rows (Audit/Proxy/MISP/Webhook/AI/OIDC), emitting one row per operationally-meaningful field with a **provenance** — `database` (a settings row is authoritative), `environment` (`settings.model_fields_set`), or `built-in default`. **Secrets never cross the boundary**: a field in the explicit secret set is coerced to a plain `set`/`not set` string server-side (no value or prefix serialised). It also re-runs the prod boot-guards (`config._guard_production`'s checks) **non-fatally** to list every issue, surfaces startup advisories (no-login-path, console-email-in-prod, in-memory-rate-limit-in-prod), and renders feature-capability tiles (environment, AI backend, enabled SSO providers, dev-login, rate limiting, email, RSS poll, MISP/webhook, Typst-available). The page is server-rendered with a CSP-safe Alpine text filter (`configFilter` in `tags.js`, rows carry a `data-search` attribute — no JSON island needed). Read-only: no model, no save path. +**Settings & integrations hub (`web/admin_home.py`, `templates/admin_home.html`, `/admin`).** The map on top of the deep config pages: one tile per admin-configurable subsystem (AI, MISP, proxy, RSS, webhook, SSO under *Outbound integrations*; audit/SIEM and effective config under *Governance*), each with a status pill and a link in, plus the two curation screens (taxonomy, audience groups). Status comes from `effective_config.admin_hub_tiles(session)`, which reads the *same* settings singletons the config pages themselves edit, so the hub can never disagree with the page it links to — it owns no state and has no save path. The pill distinguishes **OFF** (opt-in integration disabled) from **NOT CONFIGURED** (enabled but missing an endpoint or its env-only secret), which is the state an operator most needs flagged: it looks on but cannot deliver. Secrets are only ever consulted as set/not-set, never rendered. + **Admin-configurable (`web/admin_audit.py`, `templates/admin_audit.html`).** Runtime routing config lives on the single `AuditSettings` DB row (enable, chosen methods, min severity, file path, syslog host/port/protocol/facility, http endpoint, verify-TLS), edited live at the admin-only **`/admin/audit`** console (settings form + filterable event trail + "Send test event"). **Secrets stay in env** (`ICEBERG_AUDIT_HTTP_TOKEN`) and are never written to the DB; `services/audit_settings.py` seeds the row from `ICEBERG_AUDIT_*` env defaults on first read. Rail link (`ic-audit`) is admin-only. ### Frontend / design system @@ -133,7 +135,11 @@ The portal is a **"command-center" design** — a light, print-like, authoritati **⌘K command palette.** An additive Alpine overlay in `base.html` (jump-to over the role's nav items, built as a Jinja `jump_items` list); ⌘K/Ctrl-K or the topbar trigger opens it, arrow keys + Enter navigate, Esc closes. It follows the combobox/listbox dialog pattern: focus stays in the palette while open, the active result is exposed with `aria-activedescendant`, and focus returns to the invoking control on close. No backend dependency (live `/api/search` results are a noted future hook). -**Report editor (`report_edit.html`).** A full-height 3-pane workspace (`.editor-shell`): a command header (inline title + lifecycle flow + save-state + Submit/Approve/Publish), then a `1fr 1fr 340px` grid of **markdown ∣ live preview ∣ docked tabbed panel** (Citations · Tags · AI review for editable drafts · Reqs · Audience for admins · Render), with intel-level/TLP/confidence pinned to the dock footer. The dock uses standard tab/tablist/tabpanel semantics with arrow/Home/End roving selection; the taxonomy picker is a combobox/listbox with keyboard add, removal and dismissal. The AI review tab requests advisory judgement, controlled-tag and analytic-challenge suggestions only: analysts edit them locally, explicitly apply them through the ordinary report/tag save flows, and accepted report fields receive AI provenance after a successful save. Disabled/TLP/provider messages remain inline and fail-soft. The markdown pane's **Insert ▾** toolbar drops `[[diamond|figure|ach:ID]]` tokens at the cursor. The single product `
` is wired across header/pane/footer via the HTML `form=` attribute (no nested forms); it keeps the existing per-section POSTs and live-preview `POST /api/preview/product` binding verbatim, plus **debounced autosave** (1.2s) to the same `/reports/{id}` endpoint behind the header save-state indicator. +**Report editor (`report_edit.html`).** A full-height 3-pane workspace (`.editor-shell`): a command header (inline title + **marking chips** + lifecycle flow + save-state + Submit/Approve/Publish), then a `1fr 1fr 340px` grid of **markdown ∣ live preview ∣ docked tabbed panel** (Citations · Tags · AI review for editable drafts · Reqs · Audience for admins · Render). Intel level, TLP and analytic confidence are the header chips and *only* the header chips — one source of truth, always visible while writing. Each chip lays its real ` is laid transparently over the chip, so a click opens the + native dropdown: no popover code, keyboard + screen-reader navigable, and + correct before Alpine hydrates. The current value drives the swatch/dot via + data-attributes (attribute bindings replace cleanly; :class would not). */ +.marking-chip { + position: relative; flex: none; display: inline-flex; align-items: center; gap: 6px; + padding: 4px 8px; border: 1px solid var(--line-strong); border-radius: var(--radius-sm); + font-family: var(--fs-mono); font-size: 11px; font-weight: 600; letter-spacing: 0.04em; + color: var(--ink); background: var(--surface); white-space: nowrap; +} +label.marking-chip { cursor: pointer; } +label.marking-chip:hover { border-color: var(--accent-line); } +.marking-chip:focus-within { border-color: var(--accent); box-shadow: var(--ring); } +.marking-chip .caret { color: var(--faint); font-size: 9px; } +.marking-chip .swatch { + width: 9px; height: 9px; flex: none; border-radius: 2px; background: var(--faint); + box-shadow: inset 0 0 0 1px oklch(0 0 0 / 0.08); +} +.marking-chip .dot { width: 6px; height: 6px; flex: none; border-radius: 50%; background: var(--faint); } +.marking-chip--lvl { color: var(--accent-deep); background: var(--accent-soft); border-color: var(--accent-line); } +.marking-chip[data-tlp="RED"] .swatch { background: var(--tlp-red); } +.marking-chip[data-tlp="AMBER"] .swatch, +.marking-chip[data-tlp="AMBER_STRICT"] .swatch { background: var(--tlp-amber); } +.marking-chip[data-tlp="GREEN"] .swatch { background: var(--tlp-green); } +.marking-chip[data-tlp="CLEAR"] .swatch { background: var(--tlp-clear); } +.marking-chip[data-tlp="AMBER_STRICT"] { border-style: dashed; } +.marking-chip[data-conf="LOW"] .dot { background: var(--muted); } +.marking-chip[data-conf="MODERATE"] .dot { background: var(--accent); } +.marking-chip[data-conf="HIGH"] .dot { background: var(--c-ok); } +.marking-chip-select { + position: absolute; inset: 0; width: 100%; height: 100%; + padding: 0; border: 0; background: transparent; opacity: 0; cursor: pointer; + appearance: none; +} + .editor-subhead { flex: none; display: flex; align-items: center; gap: 14px; padding: 8px 20px; border-bottom: 1px solid var(--line); background: var(--surface-2); diff --git a/src/iceberg/static/js/tags.js b/src/iceberg/static/js/tags.js index ede9229..11bdc0d 100644 --- a/src/iceberg/static/js/tags.js +++ b/src/iceberg/static/js/tags.js @@ -118,10 +118,36 @@ document.addEventListener('alpine:init', () => { generation: 0, savedGeneration: 0, savePromise: null, saveQueued: false, aiLoading: '', aiApplying: false, aiStatus: '', aiStatusKind: '', aiJudgements: null, aiTagIds: [], aiChallenge: '', + intelLevel: '', tlp: '', tlpKey: '', confidence: '', confKey: '', init() { this.tabOrder = [...this.$el.querySelectorAll('[data-editor-tab]')] .map(el => el.dataset.editorTab); + this.initMarkings(); + }, + + /* ---- header marking chips (intel level / TLP / analytic confidence) ---- + Each chip shows the selected option's own text, so the server-rendered + label and the hydrated one are identical and there is no label map to + keep in sync with the enums. */ + initMarkings() { + this.$el.querySelectorAll('.marking-chip-select') + .forEach((sel) => { this.readMarking(sel); }); + }, + readMarking(sel) { + const opt = sel.options[sel.selectedIndex]; + const label = opt ? opt.textContent.trim() : ''; + if (sel.name === 'intel_level') { + this.intelLevel = label; + } else if (sel.name === 'tlp') { + this.tlp = label; this.tlpKey = sel.value; + } else if (sel.name === 'analytic_confidence') { + this.confidence = label; this.confKey = sel.value; + } + }, + pickMarking(event) { + this.readMarking(event.target); + this.markDirty(); }, selectTab(id, focus = false) { if (this.tabOrder.length && !this.tabOrder.includes(id)) return; @@ -378,6 +404,38 @@ document.addEventListener('alpine:init', () => { })); /* ---- Citation autosave form (report_edit.html) ------------------------- */ + /* ---- Notebook phase tabs (notebook_detail.html) ------------------------ + The notebook is worked in phases (collect → analyze → produce → trace) + rather than scrolled end to end, so one phase's sections render at a time. + The tabs are real anchors: without Alpine the