+
+
+
+
+ {% for phase, label, sections in phases %}
+
- Create product
-
- {% endif %}
-
+ {% endfor %}
+
+
+
@@ -446,6 +396,10 @@
Suggest indicators with AI
{% endif %}
+
+
+
+
@@ -553,6 +507,64 @@
ACH analyses
{% endif %}
+
+
+
+
+
+
+
+
Intelligence products
+ {{ reports | length }}
+
+ {% if reports %}
+
+ {% for r in reports %}
+
+
{{ r.title }}
+
+ {{ m.status_badge(r.status) }}
+ {{ m.level_badge(r.intel_level) }}
+ {{ m.tlp_badge(r.tlp) }}
+ {% if can_write %}Edit {% endif %}
+
+
+ {% endfor %}
+
+ {% else %}
+ No products authored from this notebook yet. Start one when the collection has enough context for an assessment.
+ {% endif %}
+
+ {% if can_write %}
+
+ {% endif %}
+
+
+
+
+
+
Requirements addressed
@@ -577,4 +589,7 @@ Requirements addressed
No open requirements to link. Stakeholder requirements will appear here when they are available for tasking.
{% endif %}
+
+
+
{% endblock %}
diff --git a/src/iceberg/templates/report_edit.html b/src/iceberg/templates/report_edit.html
index 7c26c49..568e13e 100644
--- a/src/iceberg/templates/report_edit.html
+++ b/src/iceberg/templates/report_edit.html
@@ -36,9 +36,55 @@
{% else %}
{{ report.title }}
{% endif %}
+ {# Markings live here — always visible, editable in place, single source of
+ truth. The real
is laid transparently over each chip so a click
+ opens the native dropdown (keyboard/AT-navigable, correct before Alpine
+ hydrates); `pickMarking` mirrors the chosen option into the chip. #}
+
+ {% if can_edit %}
+
+ {{ report.intel_level.value }}
+ ▾
+
+ {% for lvl in IntelLevel %}{{ lvl.value }} {% endfor %}
+
+
+
+
+ {{ tlp_label(report.tlp) }}
+ ▾
+
+ {% for t in TLP %}{{ tlp_label(t) }} {% endfor %}
+
+
+
+
+ {% if report.analytic_confidence %}{{ report.analytic_confidence.value | capitalize }} confidence{% else %}Confidence not stated{% endif %}
+ ▾
+
+ Confidence not stated
+ {% for c in AnalyticConfidence %}{{ c.value | capitalize }} confidence {% endfor %}
+
+
+ {% else %}
+ {{ report.intel_level.value }}
+
+ {{ tlp_label(report.tlp) }}
+
+ {% if report.analytic_confidence %}
+
+ {{ report.analytic_confidence.value | capitalize }} confidence
+
+ {% endif %}
+ {% endif %}
+
{{ m.status_badge(report.status) }}
- {{ m.confidence_badge(report.analytic_confidence) }}
{% if can_edit %}
@@ -598,38 +644,14 @@
Rendered products
-
+ {# Markings moved to the always-visible header chips (single source of
+ truth); the dock footer now only points at the estimative-language
+ guidance that used to sit beside the confidence select. #}
{% if can_edit %}
- {% else %}
-
{% endif %}
diff --git a/src/iceberg/web/__init__.py b/src/iceberg/web/__init__.py
index 260e653..c91567e 100644
--- a/src/iceberg/web/__init__.py
+++ b/src/iceberg/web/__init__.py
@@ -10,6 +10,7 @@
admin_audit,
admin_config,
admin_feeds,
+ admin_home,
admin_misp,
admin_oidc,
admin_proxy,
diff --git a/src/iceberg/web/admin_home.py b/src/iceberg/web/admin_home.py
new file mode 100644
index 0000000..00531bb
--- /dev/null
+++ b/src/iceberg/web/admin_home.py
@@ -0,0 +1,35 @@
+"""Admin-only Settings & integrations hub (read-only landing page).
+
+The map on top of the deep config pages: one tile per admin-configurable
+subsystem, its live status, and a way in. It owns no state and has no save path —
+every status pill is derived from the same settings singletons the config pages
+themselves edit (``services/effective_config.admin_hub_tiles``), so the hub can
+never disagree with the page it links to.
+"""
+
+from fastapi import Request
+
+from ..auth.dependencies import CurrentUser
+from ..services import effective_config
+from ..templating import templates
+from .common import SessionDep, _require_admin, router
+
+# Tile groups, in display order (a tile's ``group`` selects its section).
+HUB_GROUPS: tuple[str, ...] = ("Outbound integrations", "Governance")
+
+
+@router.get("/admin")
+def admin_home_view(request: Request, session: SessionDep, user: CurrentUser):
+ _require_admin(user)
+ tiles = effective_config.admin_hub_tiles(session)
+ return templates.TemplateResponse(
+ request,
+ "admin_home.html",
+ {
+ "user": user,
+ "groups": [
+ (group, [t for t in tiles if t["group"] == group])
+ for group in HUB_GROUPS
+ ],
+ },
+ )
diff --git a/src/iceberg/web/notebooks.py b/src/iceberg/web/notebooks.py
index 3941eb8..453544f 100644
--- a/src/iceberg/web/notebooks.py
+++ b/src/iceberg/web/notebooks.py
@@ -30,6 +30,7 @@
SourceCredibility,
SourceReliability,
TLP,
+ User,
ioc_type_label,
utcnow,
)
@@ -109,6 +110,42 @@ def _workspace_report_counts(session: Session) -> dict[str, int]:
}
+# Rows shown in the dashboard's "Needs you now" queue.
+_NEEDS_YOU_LIMIT = 5
+
+
+def _needs_you_now(session: Session, user: User) -> list[dict]:
+ """The writer's own action queue: drafts they own, plus — for anyone who can
+ review — the products waiting on a reviewer.
+
+ Both halves are already-modelled state (``Report.status`` / ``author_id``);
+ this only surfaces them above the fold so the dashboard says what to do next
+ rather than only what the numbers are.
+ """
+ can_review = user.role in (Role.REVIEWER, Role.ADMIN)
+ statuses = [ReportStatus.DRAFT] + ([ReportStatus.IN_REVIEW] if can_review else [])
+ rows = session.exec(
+ select(Report)
+ .where(col(Report.status).in_(statuses))
+ .order_by(col(Report.updated_at).desc())
+ ).all()
+ queue: list[dict] = []
+ for report in rows:
+ status_ = ReportStatus(report.status)
+ if status_ is ReportStatus.DRAFT and report.author_id != user.id:
+ continue # someone else's draft is not your work
+ queue.append(
+ {
+ "report": report,
+ "kind": "review" if status_ is ReportStatus.IN_REVIEW else "draft",
+ }
+ )
+ # Waiting on *you* (review) outranks work only you can resume (your drafts);
+ # within each half the most recently touched leads.
+ queue.sort(key=lambda item: (item["kind"] != "review", -item["report"].updated_at.timestamp()))
+ return queue[:_NEEDS_YOU_LIMIT]
+
+
@router.get("/")
def dashboard(request: Request, session: SessionDep, user: CurrentUser):
is_stakeholder = user.role == Role.STAKEHOLDER
@@ -151,7 +188,9 @@ def dashboard(request: Request, session: SessionDep, user: CurrentUser):
# KPI strip counts (writers only) — derived cheaply for the dashboard stats.
open_tasking = 0
published_30d = 0
+ needs_you = []
if not is_stakeholder:
+ needs_you = _needs_you_now(session, user)
open_tasking = len(
session.exec(
select(Requirement).where(
@@ -182,6 +221,7 @@ def dashboard(request: Request, session: SessionDep, user: CurrentUser):
"feed_unread": feed_unread,
"open_tasking": open_tasking,
"published_30d": published_30d,
+ "needs_you": needs_you,
},
)
diff --git a/tests/test_admin_home.py b/tests/test_admin_home.py
new file mode 100644
index 0000000..de88a6a
--- /dev/null
+++ b/tests/test_admin_home.py
@@ -0,0 +1,171 @@
+"""The /admin Settings & integrations hub.
+
+The hub owns no state: every pill is derived from the same settings singletons
+the deep config pages edit, so these tests pin the derivation (off → not
+configured → enabled) and the admin-only gate rather than the copy.
+"""
+
+import pytest
+from sqlmodel import Session
+
+from iceberg.config import get_settings
+from iceberg.models import Feed
+from iceberg.services import effective_config, misp_settings, webhook_settings
+
+# Every config page the hub must offer a way into.
+HUB_HREFS = (
+ "/admin/ai",
+ "/admin/misp",
+ "/admin/proxy",
+ "/admin/feeds",
+ "/admin/webhook",
+ "/admin/oidc",
+ "/admin/audit",
+ "/admin/config",
+)
+
+
+def _tile(session, title: str) -> dict:
+ tiles = effective_config.admin_hub_tiles(session)
+ return next(t for t in tiles if t["title"] == title)
+
+
+@pytest.mark.parametrize("role", ["ANALYST", "REVIEWER", "STAKEHOLDER"])
+def test_hub_is_admin_only(client, login, role):
+ login(role)
+ assert client.get("/admin").status_code == 403
+
+
+def test_hub_links_to_every_config_page(client, login):
+ login("ADMIN")
+ page = client.get("/admin")
+ assert page.status_code == 200
+ assert "Settings & integrations" in page.text
+ for href in HUB_HREFS:
+ assert f'href="{href}"' in page.text
+
+
+def test_disabled_integration_reads_off(engine):
+ with Session(engine) as session:
+ assert _tile(session, "MISP push")["status"] == "OFF"
+ assert _tile(session, "Publication webhook")["status"] == "OFF"
+
+
+def test_enabled_but_unconfigured_integration_warns(engine, monkeypatch):
+ """Enabled with no endpoint (or, for MISP, no env API key) is the state an
+ operator most needs flagged — it looks on but cannot deliver."""
+ monkeypatch.setattr(get_settings(), "misp_api_key", "")
+ with Session(engine) as session:
+ webhook_settings.update(session, enabled=True, url="")
+ misp_settings.update(session, enabled=True, url="https://misp.example.org")
+
+ webhook = _tile(session, "Publication webhook")
+ assert (webhook["status"], webhook["tone"]) == ("NOT CONFIGURED", "is-warn")
+ misp = _tile(session, "MISP push")
+ assert (misp["status"], misp["tone"]) == ("NOT CONFIGURED", "is-warn")
+ assert "no API key" in misp["meta"]
+
+
+def test_fully_configured_integration_reads_enabled(engine, monkeypatch):
+ monkeypatch.setattr(get_settings(), "misp_api_key", "k" * 16)
+ with Session(engine) as session:
+ webhook_settings.update(
+ session, enabled=True, url="https://hooks.example.org/x"
+ )
+ misp_settings.update(session, enabled=True, url="https://misp.example.org")
+
+ assert _tile(session, "Publication webhook")["status"] == "ENABLED"
+ assert _tile(session, "MISP push")["status"] == "ENABLED"
+
+
+def test_rss_tile_counts_only_enabled_feeds(engine):
+ with Session(engine) as session:
+ session.add(Feed(url="https://a.example/rss", title="A", enabled=True))
+ session.add(Feed(url="https://b.example/rss", title="B", enabled=True))
+ session.add(Feed(url="https://c.example/rss", title="C", enabled=False))
+ session.commit()
+ assert _tile(session, "RSS feeds")["status"] == "2 ACTIVE"
+
+
+def test_audit_tile_flags_a_local_only_trail(engine):
+ """stdout-only means nothing leaves the box — a governance gap worth a pill."""
+ with Session(engine) as session:
+ tile = _tile(session, "Audit log & SIEM")
+ assert (tile["status"], tile["tone"]) == ("LOCAL ONLY", "is-warn")
+
+
+def test_config_tile_surfaces_prod_guard_issues(engine, monkeypatch):
+ monkeypatch.setattr(get_settings(), "environment", "prod")
+ monkeypatch.setattr(get_settings(), "secret_key", "short")
+ with Session(engine) as session:
+ tile = _tile(session, "Effective config")
+ assert tile["tone"] == "is-warn"
+ assert "ISSUE" in tile["status"]
+
+
+def test_hub_never_leaks_a_secret(client, login, monkeypatch):
+ monkeypatch.setattr(get_settings(), "webhook_token", "TOP-SECRET-TOKEN")
+ monkeypatch.setattr(get_settings(), "misp_api_key", "TOP-SECRET-KEY")
+ login("ADMIN")
+ page = client.get("/admin").text
+ assert "TOP-SECRET-TOKEN" not in page
+ assert "TOP-SECRET-KEY" not in page
+
+
+def test_ai_hub_tile_reflects_the_resolved_backend(engine, monkeypatch):
+ """A provider selected but missing its env key resolves to "none" at runtime
+ (``ai_settings.resolve`` fail-closes), so the hub must not show it green —
+ the same defect the /admin/config tile was fixed for."""
+ from iceberg.services import ai_settings
+
+ monkeypatch.setattr(get_settings(), "ai_api_key", "")
+ with Session(engine) as session:
+ ai_settings.update(session, backend="openai", model="gpt-5")
+ assert ai_settings.resolve(session).ai_backend == "none"
+
+ tile = _tile(session, "AI provider")
+ assert (tile["status"], tile["tone"]) == ("NOT CONFIGURED", "is-warn")
+ assert "disabled at runtime" in tile["meta"]
+
+ monkeypatch.setattr(get_settings(), "ai_api_key", "k" * 20)
+ with Session(engine) as session:
+ tile = _tile(session, "AI provider")
+ assert (tile["status"], tile["tone"]) == ("OPENAI", "is-ok")
+
+
+def test_sso_tile_warns_when_a_provider_has_no_client_secret(engine, monkeypatch):
+ """A provider with a client id but no env secret cannot complete the
+ authorization-code flow. It looks enabled and fails at login, so a green
+ pill would point an operator away from the actual cause."""
+ from iceberg.services import oidc_settings
+
+ monkeypatch.setattr(get_settings(), "oidc_client_secret", "")
+ with Session(engine) as session:
+ oidc_settings.update(session, entra_enabled=True, entra_client_id="abc123")
+ assert [c.name for c in oidc_settings.enabled_providers(session)] == ["entra"]
+
+ tile = _tile(session, "Single sign-on")
+ assert (tile["status"], tile["tone"]) == ("NOT CONFIGURED", "is-warn")
+ assert "entra" in tile["meta"]
+
+ monkeypatch.setattr(get_settings(), "oidc_client_secret", "s" * 24)
+ with Session(engine) as session:
+ tile = _tile(session, "Single sign-on")
+ assert (tile["status"], tile["tone"]) == ("ENTRA", "is-ok")
+
+
+def test_audit_tile_warns_when_the_http_sink_has_no_endpoint(engine):
+ """Selecting the HTTP sink without an endpoint claims off-box forwarding
+ that cannot happen — worse than 'local only', because it looks solved."""
+ from iceberg.services import audit_settings
+
+ with Session(engine) as session:
+ audit_settings.update(session, enabled=True, methods=["stdout", "http"])
+ tile = _tile(session, "Audit log & SIEM")
+ assert (tile["status"], tile["tone"]) == ("NOT CONFIGURED", "is-warn")
+ assert "no endpoint" in tile["meta"]
+
+ audit_settings.update(session, http_endpoint="https://siem.example.org/hec")
+ tile = _tile(session, "Audit log & SIEM")
+ assert tile["tone"] == "is-ok"
+ assert "HTTP" in tile["status"]
diff --git a/tests/test_dashboard_counts.py b/tests/test_dashboard_counts.py
index 062b581..eb8bfb4 100644
--- a/tests/test_dashboard_counts.py
+++ b/tests/test_dashboard_counts.py
@@ -3,9 +3,9 @@
import re
from datetime import timedelta
-from sqlmodel import Session
+from sqlmodel import Session, select
-from iceberg.models import Notebook, Report, ReportStatus, utcnow
+from iceberg.models import Notebook, Report, ReportStatus, User, utcnow
def test_dashboard_counts_all_in_flight_reports_not_only_the_recent_eight(
@@ -57,6 +57,109 @@ def test_dashboard_counts_all_in_flight_reports_not_only_the_recent_eight(
response.text,
)
assert "0 in review · 1 approved" in response.text
- # The display list is still presentation-capped, independently of the KPI.
- assert response.text.count('class="row-title"') == 8
- assert "Older approved report" not in response.text
+ # The display list is still presentation-capped, independently of the KPI
+ # (scoped to the recent-reports column — the "Needs you now" queue above it
+ # renders rows of its own).
+ recent = response.text.split("Recent reports", 1)[1]
+ assert recent.count('class="row-title"') == 8
+ assert "Older approved report" not in recent
+
+
+def _other_author(session) -> int:
+ """A second real author, so "someone else's draft" is a genuine FK row."""
+ user = User(email="other@example.com", display_name="Other")
+ session.add(user)
+ session.commit()
+ session.refresh(user)
+ return user.id
+
+
+def _seed(session, *, owner_id, title, status, author_id=None, age_minutes=0):
+ notebook = session.exec(select(Notebook)).first()
+ if notebook is None:
+ notebook = Notebook(title="Queue", owner_id=owner_id)
+ session.add(notebook)
+ session.commit()
+ session.refresh(notebook)
+ session.add(
+ Report(
+ notebook_id=notebook.id,
+ author_id=author_id if author_id is not None else owner_id,
+ title=title,
+ status=status,
+ updated_at=utcnow() - timedelta(minutes=age_minutes),
+ )
+ )
+ session.commit()
+
+
+def test_needs_you_now_lists_your_drafts_only(client, login, engine):
+ """An analyst's queue is their own unfinished work — another author's draft
+ is not theirs to resume, and they cannot review."""
+ login("ANALYST", email="mine@example.com")
+ me = client.get("/api/me").json()["id"]
+ with Session(engine) as session:
+ _seed(session, owner_id=me, title="My draft", status=ReportStatus.DRAFT)
+ _seed(
+ session,
+ owner_id=me,
+ author_id=_other_author(session),
+ title="Their draft",
+ status=ReportStatus.DRAFT,
+ )
+ _seed(session, owner_id=me, title="Awaiting review", status=ReportStatus.IN_REVIEW)
+
+ page = client.get("/").text
+ queue = page.split("Recent reports", 1)[0]
+ assert "Needs you now" in queue
+ assert "My draft" in queue
+ assert "Their draft" not in queue
+ assert "Awaiting review" not in queue # an analyst is not a reviewer
+
+
+def test_needs_you_now_puts_the_review_queue_first(client, login, engine):
+ """For a reviewer, work waiting on *them* outranks their own drafts."""
+ login("REVIEWER", email="rev@example.com")
+ me = client.get("/api/me").json()["id"]
+ with Session(engine) as session:
+ _seed(
+ session,
+ owner_id=me,
+ title="My newer draft",
+ status=ReportStatus.DRAFT,
+ age_minutes=0,
+ )
+ _seed(
+ session,
+ owner_id=me,
+ author_id=_other_author(session),
+ title="Older submission",
+ status=ReportStatus.IN_REVIEW,
+ age_minutes=60,
+ )
+
+ queue = client.get("/").text.split("Recent reports", 1)[0]
+ assert queue.index("Older submission") < queue.index("My newer draft")
+ assert "Review →" in queue and "Resume →" in queue
+
+
+def test_needs_you_now_is_capped(client, login, engine):
+ login("ANALYST", email="many@example.com")
+ me = client.get("/api/me").json()["id"]
+ with Session(engine) as session:
+ for i in range(9):
+ _seed(
+ session,
+ owner_id=me,
+ title=f"Draft {i}",
+ status=ReportStatus.DRAFT,
+ age_minutes=i,
+ )
+
+ queue = client.get("/").text.split("Recent reports", 1)[0]
+ assert queue.count('class="queue-dot') == 5
+
+
+def test_stakeholder_dashboard_has_no_writer_queue(client, login):
+ login("STAKEHOLDER", email="reader@example.com")
+ assert "Needs you now" not in client.get("/").text
diff --git a/tests/test_portal.py b/tests/test_portal.py
index d7d3a56..0432426 100644
--- a/tests/test_portal.py
+++ b/tests/test_portal.py
@@ -446,3 +446,130 @@ def test_valid_token_for_deleted_user_is_anonymous(client, login, engine):
)
assert resp.status_code == 303
assert resp.headers["location"] == "/auth/login"
+
+
+def test_editor_markings_live_in_the_header(client, login):
+ """TLP / intel level / confidence are edited from the always-visible header
+ chips, and only there — a second copy in the dock footer would be a second
+ source of truth for the same three form fields."""
+ login("ANALYST", email="marks@example.com")
+ nb = client.post("/api/notebooks", json={"title": "Markings nb"}).json()
+ rid = client.post(
+ "/api/reports", json={"notebook_id": nb["id"], "title": "Marked product"}
+ ).json()["id"]
+
+ page = client.get(f"/reports/{rid}/edit").text
+ head = page.split('class="editor-head-actions"', 1)[0]
+ for field in ("intel_level", "tlp", "analytic_confidence"):
+ assert page.count(f'name="{field}"') == 1
+ assert f'name="{field}"' in head
+ # Each chip's
still posts with the product form, so autosave and a
+ # plain submit both carry the markings.
+ assert head.count('class="marking-chip-select"') == 3
+ assert head.count('form="reportform"') == 4 # title input + three markings
+
+
+def test_editor_markings_are_read_only_for_a_non_author(client, login):
+ """A reviewer opening someone else's product sees the markings as static
+ chips — visible, but with no editable control smuggled into the page."""
+ login("ANALYST", email="author2@example.com")
+ nb = client.post("/api/notebooks", json={"title": "Read-only nb"}).json()
+ rid = client.post(
+ "/api/reports", json={"notebook_id": nb["id"], "title": "Someone else's"}
+ ).json()["id"]
+
+ login("REVIEWER", email="reviewer2@example.com")
+ page = client.get(f"/reports/{rid}/edit").text
+ assert "marking-chip" in page
+ assert "marking-chip-select" not in page
+ assert 'name="tlp"' not in page
+
+
+def test_notebook_phases_keep_every_section_reachable(client, login):
+ """The nine sections are re-cut into four phases, not removed: each section
+ id still renders, and the rule cancels x-cloak so a browser
+ without Alpine still sees all of them."""
+ login("ANALYST", email="phases@example.com")
+ nb = client.post("/api/notebooks", json={"title": "Phased nb"}).json()
+
+ page = client.get(f"/notebooks/{nb['id']}").text
+ for section in (
+ "sources",
+ "notes",
+ "attachments",
+ "figures",
+ "indicators",
+ "diamonds",
+ "ach",
+ "products",
+ "requirements",
+ ):
+ assert f'id="{section}"' in page
+ assert f'href="#{section}"' in page # its phase tab
+ for phase in ("collect", "analyze", "produce", "trace"):
+ assert f"phase === '{phase}'" in page
+ # Collect is the server-rendered default: it alone is not cloaked.
+ assert "x-show=\"phase === 'collect'\">" in page
+ assert "[x-cloak] { display: revert; }" in page
+
+
+def test_notebook_phase_map_covers_every_section_and_redirect_target(client, login):
+ """Every post-action redirect lands on # (creating a Diamond model
+ returns to /notebooks/{id}#diamonds). The component resolves that hash to the
+ owning phase via this map, so a section missing from it would leave the user
+ on a cloaked page that looks like their work vanished."""
+ import json
+ import re
+
+ login("ANALYST", email="deeplink@example.com")
+ nb = client.post("/api/notebooks", json={"title": "Deep link nb"}).json()
+ page = client.get(f"/notebooks/{nb['id']}").text
+
+ raw = re.search(
+ r'id="notebook-phase-data">(.*?)', page, re.S
+ ).group(1)
+ mapping = json.loads(raw.replace(""", '"'))
+ assert mapping == {
+ "sources": "collect",
+ "notes": "collect",
+ "attachments": "collect",
+ "figures": "collect",
+ "indicators": "collect",
+ "diamonds": "analyze",
+ "ach": "analyze",
+ "products": "produce",
+ "requirements": "trace",
+ }
+ # Every id the map claims really exists as a section on the page.
+ for section in mapping:
+ assert f'id="{section}"' in page
+
+
+def test_analyze_phase_redirects_land_on_a_resolvable_anchor(client, login):
+ """The regression this guards: #diamonds and #ach sit in the Analyze phase,
+ which is cloaked on load. Any route that redirects there must use an anchor
+ the phase map knows, or the user is returned to a page that looks empty."""
+ import json
+ import re
+
+ login("ANALYST", email="diamondlink@example.com")
+ nb = client.post("/api/notebooks", json={"title": "Diamond nb"}).json()
+ created = client.post(
+ f"/notebooks/{nb['id']}/diamonds",
+ data={"title": "Volt Typhoon", "confidence": "MODERATE"},
+ follow_redirects=False,
+ )
+ assert created.status_code == 303
+ # Creating lands on the model's own editor; deleting is what returns to the
+ # notebook's #diamonds anchor.
+ diamond_id = created.headers["location"].rstrip("/edit").rsplit("/", 1)[1]
+
+ resp = client.post(
+ f"/notebooks/{nb['id']}/diamonds/{diamond_id}/delete", follow_redirects=False
+ )
+ assert resp.status_code == 303
+ anchor = resp.headers["location"].split("#")[1]
+
+ page = client.get(f"/notebooks/{nb['id']}").text
+ raw = re.search(r'id="notebook-phase-data">(.*?)', page, re.S).group(1)
+ assert json.loads(raw.replace(""", '"'))[anchor] == "analyze"
diff --git a/vulture_whitelist.py b/vulture_whitelist.py
index 46bd19a..18c18fc 100644
--- a/vulture_whitelist.py
+++ b/vulture_whitelist.py
@@ -119,3 +119,7 @@
queue_dissemination # unused function (src/iceberg/services/dissemination.py)
fetch_all_enabled_once # unused function (src/iceberg/services/feeds.py)
_.connect_unix_socket # unused attribute (httpcore backend protocol method)
+
+# /admin Settings-hub tile fields — read only in templates/admin_home.html.
+tone # unused variable (src/iceberg/services/effective_config.py)
+meta # unused variable (src/iceberg/services/effective_config.py)