diff --git a/backend/alembic/versions/0020_user_activity_seen.py b/backend/alembic/versions/0020_user_activity_seen.py new file mode 100644 index 00000000..1865d23d --- /dev/null +++ b/backend/alembic/versions/0020_user_activity_seen.py @@ -0,0 +1,33 @@ +"""Add users.activity_seen_at for the job monitor's unseen-error badge. + +Nullable timezone-aware timestamp: when this user last marked the activity +error feed as seen (POST /api/activity/seen). NULL means never marked, which +the frontend treats as "every fetched error is unseen". + +Plain add_column with no existence guard: guards are reserved for revisions +with a specific stated reason (see 0018's autocommit backfill), and installs +bootstrapped by create_all-then-stamp are no longer supported +(MIN_UPGRADE_FROM_DATA_VERSION = 13). + +No index: the column is only ever read for the requesting user's own row, +which the primary key already serves. +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "0020" +down_revision = "0019" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("activity_seen_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("users", "activity_seen_at") diff --git a/backend/alembic/versions/0021_arcsec_per_pixel.py b/backend/alembic/versions/0021_arcsec_per_pixel.py new file mode 100644 index 00000000..93e06f64 --- /dev/null +++ b/backend/alembic/versions/0021_arcsec_per_pixel.py @@ -0,0 +1,41 @@ +"""Add images.arcsec_per_pixel, the materialized plate scale. + +Nullable float: 206.265 * XPIXSZ(um) / FOCALLEN(mm), computed at ingest by the +scanner via units.arcsec_per_pixel. NULL when the headers carry no usable pixel +size or focal length. Materialized because deriving the plate scale per-row +from raw_headers JSONB (regex-guarded text casts, units.sql_arcsec_per_pixel) +made cold /api/stats aggregations take tens of seconds. + +Plain add_column with no existence guard: guards are reserved for revisions +with a specific stated reason (see 0018's autocommit backfill), and installs +bootstrapped by create_all-then-stamp are no longer supported +(MIN_UPGRADE_FROM_DATA_VERSION = 13). + +Schema only. Backfilling existing rows is inference from raw_headers, not a +structural DDL step, so it runs through app.services.data_migrations as data +migration v15, where raw-headers re-derivation already lives and where the +version gate makes it run exactly once (same split as 0019 / v14). + +No index: the column is aggregated over full scans (multiplied into HFR/FWHM +conversions), never used as a filter, so a btree would cost every ingest write +for no read. Add one when a query needs it. +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "0021" +down_revision = "0020" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "images", + sa.Column("arcsec_per_pixel", sa.Float(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("images", "arcsec_per_pixel") diff --git a/backend/app/api/activity.py b/backend/app/api/activity.py index 274535ae..72f4e0d8 100644 --- a/backend/app/api/activity.py +++ b/backend/app/api/activity.py @@ -3,7 +3,7 @@ import base64 import json import logging -from datetime import datetime +from datetime import datetime, timezone from fastapi import APIRouter, Depends, Query from sqlalchemy import delete, func, select @@ -13,7 +13,11 @@ from app.database import get_session from app.models.activity_event import ActivityEvent from app.models.user import User -from app.schemas.activity import ActivityItem, PaginatedActivityResponse +from app.schemas.activity import ( + ActivityItem, + ActivitySeenResponse, + PaginatedActivityResponse, +) from app.schemas.common import StatusResponse logger = logging.getLogger(__name__) @@ -138,3 +142,21 @@ async def clear_activity( await session.execute(delete(ActivityEvent)) await session.commit() return {"status": "cleared"} + + +@router.post("/seen", response_model=ActivitySeenResponse) +async def mark_activity_seen( + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + """Record that the current user has seen the activity error feed. + + Any authenticated role may call this: it mutates only the caller's own + row. get_current_user and this endpoint share the request's get_session + dependency (FastAPI caches per-request), so mutating the loaded user and + committing persists it, the same pattern change_password uses in auth.py. + """ + now = datetime.now(timezone.utc) + user.activity_seen_at = now + await session.commit() + return ActivitySeenResponse(activity_seen_at=now) diff --git a/backend/app/api/analysis.py b/backend/app/api/analysis.py index 6ae59a2c..800d8b28 100644 --- a/backend/app/api/analysis.py +++ b/backend/app/api/analysis.py @@ -30,7 +30,6 @@ TrendLine, ) from app.services.normalization import load_alias_maps, expand_canonical, normalize_equipment, normalize_filter -from app.services.units import sql_arcsec_per_pixel from app.api.auth import get_current_user router = APIRouter(prefix="/analysis", tags=["analysis"]) @@ -764,11 +763,12 @@ async def get_compare( # Pixel-domain metrics cannot be compared across optical trains directly: # the same star measures a different pixel HFR on a different focal length # or pixel size. For these, each frame is also converted to arcsec via its - # plate scale (XPIXSZ/FOCALLEN raw headers) and the verdict runs on the - # arcsec medians. Frames without a derivable plate scale contribute to the - # px box plots (backward compatible) but not to the arcsec comparison. + # materialized plate scale (arcsec_per_pixel, NULL when the headers lacked + # a derivable XPIXSZ/FOCALLEN) and the verdict runs on the arcsec medians. + # Frames without a plate scale contribute to the px box plots (backward + # compatible) but not to the arcsec comparison. is_pixel_metric = metric in _PIXEL_METRICS - scale_expr = sql_arcsec_per_pixel(Image.raw_headers) + scale_expr = Image.arcsec_per_pixel async def _fetch_values(group: str) -> tuple[list[float], list[float]]: q = ( diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 42fa9f30..2f40485d 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -235,7 +235,12 @@ async def logout( @router.get("/me", response_model=MeResponse) async def me(user: User = Depends(get_current_user)): - return MeResponse(id=user.id, username=user.username, role=user.role.value) + return MeResponse( + id=user.id, + username=user.username, + role=user.role.value, + activity_seen_at=user.activity_seen_at, + ) @router.put("/password", response_model=StatusResponse) diff --git a/backend/app/api/stats.py b/backend/app/api/stats.py index 80bb0a2a..119c5537 100644 --- a/backend/app/api/stats.py +++ b/backend/app/api/stats.py @@ -15,7 +15,6 @@ from app.models.user import User from app.models import Image, Target from app.services.normalization import load_alias_maps, normalize_filter, normalize_equipment -from app.services.units import sql_arcsec_per_pixel from app.schemas.stats import ( StatsResponse, OverviewStats, EquipmentStats, EquipmentItem, @@ -278,12 +277,12 @@ async def _query_overview(): async with async_session() as s: return (await s.execute(q)).one() - # Plate scale (arcsec/px) derived per row from the XPIXSZ/FOCALLEN raw - # headers; NULL when underivable, so multiplying a pixel metric by it - # yields NULL and percentile_cont skips the frame. This keeps the pooled - # arcsec medians unit-consistent across optical trains, unlike the raw - # pixel medians beside them. - _scale = sql_arcsec_per_pixel(Image.raw_headers) + # Plate scale (arcsec/px) materialized at scan time from the + # XPIXSZ/FOCALLEN raw headers; NULL when underivable, so multiplying a + # pixel metric by it yields NULL and percentile_cont skips the frame. + # This keeps the pooled arcsec medians unit-consistent across optical + # trains, unlike the raw pixel medians beside them. + _scale = Image.arcsec_per_pixel async def _query_cameras(): q = select( diff --git a/backend/app/models/image.py b/backend/app/models/image.py index 7b61f2d6..925ecef4 100644 --- a/backend/app/models/image.py +++ b/backend/app/models/image.py @@ -70,6 +70,14 @@ class Image(Base): # derived from RA/Dec + time + site. altitude_deg: Mapped[float | None] = mapped_column(Float, nullable=True) + # Plate scale in arcseconds per pixel, materialized at ingest from the + # XPIXSZ/FOCALLEN headers via units.arcsec_per_pixel (206.265 * um / mm). + # Stored as a column because deriving it per-row from raw_headers JSONB + # (regex-guarded text casts, see units.sql_arcsec_per_pixel) is what made + # cold /api/stats aggregations take tens of seconds. NULL when the headers + # carry no usable pixel size or focal length. + arcsec_per_pixel: Mapped[float | None] = mapped_column(Float, nullable=True) + # --- CSV metrics (N.I.N.A. ImageMetaData) --- # Quality hfr_stdev: Mapped[float | None] = mapped_column(Float, nullable=True) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 6802c204..8ee107a0 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -22,5 +22,9 @@ class User(Base): password_hash: Mapped[str] = mapped_column(String(255), nullable=False) role: Mapped[UserRole] = mapped_column(Enum(UserRole, name="user_role"), nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + # When this user last marked the activity/error feed as seen (job-monitor + # unseen badge). NULL means never marked; the frontend then treats every + # fetched error event as unseen. + activity_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) diff --git a/backend/app/schemas/activity.py b/backend/app/schemas/activity.py index d1a380d6..84c7b315 100644 --- a/backend/app/schemas/activity.py +++ b/backend/app/schemas/activity.py @@ -41,3 +41,7 @@ class ActivityFilterParams(BaseModel): @classmethod def cap_limit(cls, v: int) -> int: return min(int(v), 200) + + +class ActivitySeenResponse(BaseModel): + activity_seen_at: datetime diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 0dfc2b30..d2a47d96 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -19,6 +19,7 @@ class MeResponse(BaseModel): id: uuid.UUID username: str role: str + activity_seen_at: datetime | None = None class PasswordChangeRequest(BaseModel): diff --git a/backend/app/services/data_migrations.py b/backend/app/services/data_migrations.py index fec60071..1875353d 100644 --- a/backend/app/services/data_migrations.py +++ b/backend/app/services/data_migrations.py @@ -23,7 +23,7 @@ # Current data version - bump this and add a migration function when # code changes affect how stored target data is derived. -DATA_VERSION = 14 +DATA_VERSION = 15 # Migrations whose per-target loops make external network calls (VizieR, Gaia, # SAC, HyperLEDA) with pacing sleeps commit their work every this many queried @@ -812,6 +812,51 @@ def _migrate_v14_eccentricity_provenance(session: Session) -> str: return "; ".join(parts) if parts else "No changes needed" +def _migrate_v15_arcsec_per_pixel(session: Session) -> str: + """Backfill the materialized plate scale (arcsec_per_pixel) from raw_headers. + + Re-derives an image-level value from raw_headers after a schema change, + exactly as v12/v14 did: alembic 0021 added images.arcsec_per_pixel so + /api/stats aggregations no longer have to derive the plate scale per-row + from JSONB (regex-guarded text casts, units.sql_arcsec_per_pixel), which + is what made the cold path take tens of seconds. The scanner now sets the + column at ingest; existing rows carry XPIXSZ/FOCALLEN in raw_headers, so + the value is backfilled here rather than left to wait on a full re-ingest. + + Uses units.plate_scale_from_headers, the Python counterpart of the SQL + expression, so the backfilled values match what the scanner writes and + what the SQL derivation produced (same keyword semantics, None for + missing/non-numeric/non-positive inputs). + + Idempotent: only rows still NULL in arcsec_per_pixel are selected, and the + derivation is a pure function of raw_headers, so replaying is a no-op. + """ + from app.models import Image + from app.services.units import plate_scale_from_headers + + rows = session.execute( + select(Image.id, Image.raw_headers).where( + Image.arcsec_per_pixel.is_(None), + Image.raw_headers.has_key("XPIXSZ"), # noqa: W601 (JSONB ?) + Image.raw_headers.has_key("FOCALLEN"), # noqa: W601 + ) + ).all() + + updates: list[dict] = [] + for img_id, headers in rows: + scale = plate_scale_from_headers(headers) + if scale is not None: + updates.append({"id": img_id, "arcsec_per_pixel": scale}) + + if updates: + session.bulk_update_mappings(Image, updates) + session.flush() + + if updates: + return f"{len(updates)} plate scales backfilled from raw_headers" + return "No changes needed" + + def reference_catalogs_are_empty(session: Session) -> bool: """Return True if the static OpenNGC catalog table has no rows. @@ -879,6 +924,7 @@ def load_reference_catalogs(session: Session) -> str: 12: ("Backfill exposure_time from EXPOSURE keyword and split FWHM out of median_hfr", _migrate_v12_exposure_and_metric_split), 13: ("Backfill per-target enrichment (constellation, memberships, Gaia, HyperLEDA) for targets created since v11", _migrate_v13_enrich_created_targets), 14: ("Recover eccentricity provenance and backfill frame altitude from raw_headers", _migrate_v14_eccentricity_provenance), + 15: ("Backfill materialized plate scale (arcsec_per_pixel) from raw_headers", _migrate_v15_arcsec_per_pixel), } diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py index b41631b5..c21d82cb 100644 --- a/backend/app/services/scanner.py +++ b/backend/app/services/scanner.py @@ -10,6 +10,7 @@ from app.services.csv_metadata import get_csv_metrics, merge_csv_metrics from app.services.scan_filters import ScanFilterConfig +from app.services.units import arcsec_per_pixel logger = logging.getLogger(__name__) @@ -236,6 +237,13 @@ def extract_metadata(fits_path: Path, header=None) -> dict[str, Any]: # OBJCTALT (MaxIm-style) first, CENTALT second; both are written by # common capture software and agree when both are present. "altitude_deg": _first_float(header, "OBJCTALT", "CENTALT"), + # Plate scale materialized at ingest (206.265 * XPIXSZ / FOCALLEN) so + # aggregations never have to re-derive it per-row from raw_headers + # JSONB. Same helper semantics as units.sql_arcsec_per_pixel: None + # when either keyword is missing, non-numeric, or non-positive. + "arcsec_per_pixel": arcsec_per_pixel( + header.get("XPIXSZ"), header.get("FOCALLEN") + ), "capture_date": capture_date, "raw_headers": raw_headers, } diff --git a/backend/app/services/xisf_parser.py b/backend/app/services/xisf_parser.py index a9564191..f45f366e 100644 --- a/backend/app/services/xisf_parser.py +++ b/backend/app/services/xisf_parser.py @@ -21,6 +21,7 @@ from app.services.csv_metadata import get_csv_metrics, merge_csv_metrics from app.services.stretch import normalize_to_unit, resize_array, stretch_channel +from app.services.units import arcsec_per_pixel logger = logging.getLogger(__name__) @@ -213,6 +214,11 @@ def extract_xisf_metadata(xisf_path: Path) -> dict[str, Any]: # Frame-centre altitude, for the same atmospheric-dispersion reason as # the FITS path (see scanner.extract_metadata). "altitude_deg": _first_float(merged.get("OBJCTALT"), merged.get("CENTALT")), + # Plate scale materialized at ingest, same as the FITS path (see + # scanner.extract_metadata). + "arcsec_per_pixel": arcsec_per_pixel( + merged.get("XPIXSZ"), merged.get("FOCALLEN") + ), "capture_date": _parse_capture_date(merged.get("DATE-OBS"), xisf_path), "raw_headers": raw_headers, } diff --git a/backend/app/worker/tasks_scan.py b/backend/app/worker/tasks_scan.py index 59c43a1c..f5bf2d0b 100644 --- a/backend/app/worker/tasks_scan.py +++ b/backend/app/worker/tasks_scan.py @@ -476,6 +476,7 @@ def _do_ingest(fits_path: str, include_calibration: bool = True) -> dict: eccentricity=meta.get("eccentricity"), eccentricity_source=meta.get("eccentricity_source"), altitude_deg=meta.get("altitude_deg"), + arcsec_per_pixel=meta.get("arcsec_per_pixel"), raw_headers=meta.get("raw_headers", {}), # CSV metrics (N.I.N.A. Session Metadata) hfr_stdev=meta.get("hfr_stdev"), diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 71bfe641..4fa61d1d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -50,6 +50,7 @@ def admin_user(): user.role = UserRole.admin user.is_active = True user.password_hash = "hashed" + user.activity_seen_at = None return user @@ -62,4 +63,5 @@ def viewer_user(): user.role = UserRole.viewer user.is_active = True user.password_hash = "hashed" + user.activity_seen_at = None return user diff --git a/backend/tests/test_api_activity_seen.py b/backend/tests/test_api_activity_seen.py new file mode 100644 index 00000000..08f2cec5 --- /dev/null +++ b/backend/tests/test_api_activity_seen.py @@ -0,0 +1,92 @@ +import uuid +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest +from httpx import AsyncClient, ASGITransport + +from app.main import app +from app.database import get_session +from app.api.deps import get_current_user +from app.models.user import User, UserRole + + +def _user(role=UserRole.viewer): + u = MagicMock(spec=User) + u.id = uuid.uuid4() + u.username = "viewer" + u.role = role + u.is_active = True + u.activity_seen_at = None + return u + + +def _session_gen(mock_session): + async def _gen(): + yield mock_session + return _gen + + +def test_user_model_has_nullable_activity_seen_at(): + assert "activity_seen_at" in User.__table__.columns + col = User.__table__.columns["activity_seen_at"] + assert col.nullable is True + + +@pytest.mark.asyncio +async def test_mark_activity_seen_sets_timestamp_and_commits(): + user = _user() + mock_session = AsyncMock() + app.dependency_overrides[get_session] = _session_gen(mock_session) + app.dependency_overrides[get_current_user] = lambda: user + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + resp = await c.post("/api/activity/seen") + app.dependency_overrides.clear() + assert resp.status_code == 200 + returned = datetime.fromisoformat(resp.json()["activity_seen_at"]) + assert returned.tzinfo is not None + assert user.activity_seen_at == returned + mock_session.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_mark_activity_seen_allowed_for_viewer_role(): + user = _user(UserRole.viewer) + mock_session = AsyncMock() + app.dependency_overrides[get_session] = _session_gen(mock_session) + app.dependency_overrides[get_current_user] = lambda: user + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + resp = await c.post("/api/activity/seen") + app.dependency_overrides.clear() + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_mark_activity_seen_requires_auth(): + app.dependency_overrides.clear() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + resp = await c.post("/api/activity/seen") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_me_includes_activity_seen_at(): + user = _user() + user.activity_seen_at = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc) + app.dependency_overrides[get_current_user] = lambda: user + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + resp = await c.get("/api/auth/me") + app.dependency_overrides.clear() + assert resp.status_code == 200 + assert datetime.fromisoformat(resp.json()["activity_seen_at"]) == user.activity_seen_at + + +@pytest.mark.asyncio +async def test_me_activity_seen_at_null_when_never_marked(): + user = _user() + app.dependency_overrides[get_current_user] = lambda: user + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + resp = await c.get("/api/auth/me") + app.dependency_overrides.clear() + assert resp.status_code == 200 + assert resp.json()["activity_seen_at"] is None diff --git a/backend/tests/test_api_analysis.py b/backend/tests/test_api_analysis.py index 4d029144..07955017 100644 --- a/backend/tests/test_api_analysis.py +++ b/backend/tests/test_api_analysis.py @@ -603,7 +603,7 @@ async def test_get_compare_pixel_metric_not_comparable_without_scale(): of returning a bogus % figure.""" user = _admin_user() rows_a = [_row(v, 1.5) for v in [2.0, 2.0, 2.2, 2.4, 2.6]] - rows_b = [_row(4.0, None) for _ in range(5)] # no XPIXSZ/FOCALLEN + rows_b = [_row(4.0, None) for _ in range(5)] # arcsec_per_pixel NULL session = _compare_session(rows_a, rows_b) app.dependency_overrides[get_current_user] = _override_user(user) diff --git a/backend/tests/test_data_migrations.py b/backend/tests/test_data_migrations.py index b591a736..3f7190c5 100644 --- a/backend/tests/test_data_migrations.py +++ b/backend/tests/test_data_migrations.py @@ -22,6 +22,7 @@ _migrate_v12_exposure_and_metric_split, _migrate_v13_enrich_created_targets, _migrate_v14_eccentricity_provenance, + _migrate_v15_arcsec_per_pixel, ) @@ -77,7 +78,13 @@ def test_v14_is_eccentricity_provenance(self): desc, func = MIGRATIONS[14] assert func is _migrate_v14_eccentricity_provenance assert "eccentricity" in desc.lower() - assert DATA_VERSION == 14 + + def test_v15_is_arcsec_per_pixel_backfill(self): + assert 15 in MIGRATIONS + desc, func = MIGRATIONS[15] + assert func is _migrate_v15_arcsec_per_pixel + assert "arcsec_per_pixel" in desc + assert DATA_VERSION == 15 def _sync_session_factory(): @@ -378,6 +385,98 @@ def test_backfills_source_and_altitude(self): engine.dispose() +class TestV15ArcsecPerPixel: + """End-to-end backfill of the materialized plate scale from raw_headers.""" + + def test_backfills_plate_scale(self): + from sqlalchemy import text + from app.models import Image + + Session, engine = _sync_session_factory() + session = Session() + + tag = uuid.uuid4().hex[:8] + p_full = f"/fits/{tag}/full_headers.fits" + p_strings = f"/fits/{tag}/string_headers.fits" + p_missing = f"/fits/{tag}/no_focallen.fits" + p_bad = f"/fits/{tag}/bad_values.fits" + p_set = f"/fits/{tag}/already_set.fits" + ids = {} + try: + # Row 1: numeric XPIXSZ + FOCALLEN -- backfilled. + r1 = Image( + file_path=p_full, file_name="full_headers.fits", + arcsec_per_pixel=None, + raw_headers={"XPIXSZ": 3.76, "FOCALLEN": 530.0, "OBJECT": "M31"}, + ) + # Row 2: string values (XISF FITSKeywords land as text) -- backfilled. + r2 = Image( + file_path=p_strings, file_name="string_headers.fits", + arcsec_per_pixel=None, + raw_headers={"XPIXSZ": "3.76", "FOCALLEN": "530", "OBJECT": "M31"}, + ) + # Row 3: FOCALLEN missing -- stays NULL (JSONB key filter skips it). + r3 = Image( + file_path=p_missing, file_name="no_focallen.fits", + arcsec_per_pixel=None, + raw_headers={"XPIXSZ": 3.76, "OBJECT": "M31"}, + ) + # Row 4: non-positive focal length -- selected but underivable, NULL. + r4 = Image( + file_path=p_bad, file_name="bad_values.fits", + arcsec_per_pixel=None, + raw_headers={"XPIXSZ": 3.76, "FOCALLEN": 0, "OBJECT": "M31"}, + ) + # Row 5: already materialized -- left untouched (idempotency filter). + r5 = Image( + file_path=p_set, file_name="already_set.fits", + arcsec_per_pixel=2.5, + raw_headers={"XPIXSZ": 3.76, "FOCALLEN": 530.0, "OBJECT": "M31"}, + ) + session.add_all([r1, r2, r3, r4, r5]) + session.commit() + for r in (r1, r2, r3, r4, r5): + ids[r.file_path] = r.id + + summary = _migrate_v15_arcsec_per_pixel(session) + session.commit() + + expected = 206.265 * 3.76 / 530.0 + + got1 = session.get(Image, ids[p_full]) + assert got1.arcsec_per_pixel == pytest.approx(expected) + + got2 = session.get(Image, ids[p_strings]) + assert got2.arcsec_per_pixel == pytest.approx(expected) + + got3 = session.get(Image, ids[p_missing]) + assert got3.arcsec_per_pixel is None + + got4 = session.get(Image, ids[p_bad]) + assert got4.arcsec_per_pixel is None + + got5 = session.get(Image, ids[p_set]) + assert got5.arcsec_per_pixel == pytest.approx(2.5) + + assert "plate scales backfilled" in summary + + # Replaying the migration must not change anything (idempotent). + session.expire_all() + _migrate_v15_arcsec_per_pixel(session) + session.commit() + assert session.get(Image, ids[p_full]).arcsec_per_pixel == pytest.approx(expected) + assert session.get(Image, ids[p_set]).arcsec_per_pixel == pytest.approx(2.5) + finally: + session.rollback() + for fp in (p_full, p_strings, p_missing, p_bad, p_set): + session.execute( + text("DELETE FROM images WHERE file_path = :fp"), {"fp": fp} + ) + session.commit() + session.close() + engine.dispose() + + class TestLoadReferenceCatalogs: """H1: static catalogs must load independently of the data-version gate. diff --git a/backend/tests/test_scanner.py b/backend/tests/test_scanner.py index a6ec27ec..6094416d 100644 --- a/backend/tests/test_scanner.py +++ b/backend/tests/test_scanner.py @@ -318,6 +318,49 @@ def test_eccentricity_keyword_preferred(mock_fitsio, mock_get_csv): assert result["eccentricity"] == 0.42 +# --------------------------------------------------------------------------- +# Materialized plate scale: arcsec_per_pixel from XPIXSZ + FOCALLEN +# --------------------------------------------------------------------------- + +@patch("app.services.scanner.get_csv_metrics") +@patch("app.services.scanner.fitsio") +def test_arcsec_per_pixel_from_headers(mock_fitsio, mock_get_csv): + """XPIXSZ + FOCALLEN materialize the plate scale (206.265 * um / mm).""" + header = _make_fake_header({"XPIXSZ": 3.76, "FOCALLEN": 530.0}) + mock_fitsio.read_header.return_value = header + mock_get_csv.return_value = {} + + result = extract_metadata(Path("/data/Light.fits")) + + assert result["arcsec_per_pixel"] == pytest.approx(206.265 * 3.76 / 530.0) + + +@patch("app.services.scanner.get_csv_metrics") +@patch("app.services.scanner.fitsio") +def test_arcsec_per_pixel_none_when_headers_missing(mock_fitsio, mock_get_csv): + """Missing pixel size or focal length yields a NULL plate scale.""" + header = _make_fake_header() # no XPIXSZ/FOCALLEN in defaults + mock_fitsio.read_header.return_value = header + mock_get_csv.return_value = {} + + result = extract_metadata(Path("/data/Light.fits")) + + assert result["arcsec_per_pixel"] is None + + +@patch("app.services.scanner.get_csv_metrics") +@patch("app.services.scanner.fitsio") +def test_arcsec_per_pixel_none_for_invalid_values(mock_fitsio, mock_get_csv): + """Non-positive or non-numeric header values yield a NULL plate scale.""" + header = _make_fake_header({"XPIXSZ": 3.76, "FOCALLEN": 0}) + mock_fitsio.read_header.return_value = header + mock_get_csv.return_value = {} + + result = extract_metadata(Path("/data/Light.fits")) + + assert result["arcsec_per_pixel"] is None + + # --------------------------------------------------------------------------- # AUD-020: unparseable DATE-OBS logs a warning instead of vanishing silently # --------------------------------------------------------------------------- diff --git a/frontend/openapi.json b/frontend/openapi.json index c87e936d..a7fbd0ef 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -154,6 +154,20 @@ "title": "ActivityItem", "type": "object" }, + "ActivitySeenResponse": { + "properties": { + "activity_seen_at": { + "format": "date-time", + "title": "Activity Seen At", + "type": "string" + } + }, + "required": [ + "activity_seen_at" + ], + "title": "ActivitySeenResponse", + "type": "object" + }, "ActivitySettingsResponse": { "properties": { "activity_retention_days": { @@ -921,12 +935,39 @@ }, "CompareResponse": { "properties": { + "comparable": { + "default": true, + "title": "Comparable", + "type": "boolean" + }, "group_a": { "$ref": "#/components/schemas/CompareGroupStats" }, "group_b": { "$ref": "#/components/schemas/CompareGroupStats" }, + "median_hfr_arcsec_a": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Median Hfr Arcsec A" + }, + "median_hfr_arcsec_b": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Median Hfr Arcsec B" + }, "metric": { "title": "Metric", "type": "string" @@ -1416,6 +1457,17 @@ ], "title": "Avg Hfr" }, + "avg_hfr_arcsec": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Avg Hfr Arcsec" + }, "best_hfr": { "anyOf": [ { @@ -1427,12 +1479,53 @@ ], "title": "Best Hfr" }, + "best_hfr_arcsec": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Best Hfr Arcsec" + }, + "ecc_excluded_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Ecc Excluded Count" + }, + "hfr_arcsec_hist": { + "default": [], + "items": { + "$ref": "#/components/schemas/HfrBucket" + }, + "title": "Hfr Arcsec Hist", + "type": "array" + }, "hfr_distribution": { "items": { "$ref": "#/components/schemas/HfrBucket" }, "title": "Hfr Distribution", "type": "array" + }, + "unscaled_frame_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Unscaled Frame Count" } }, "required": [ @@ -1980,6 +2073,17 @@ ], "title": "Median Fwhm" }, + "median_fwhm_arcsec": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Median Fwhm Arcsec" + }, "median_guiding_rms": { "anyOf": [ { @@ -3483,6 +3587,18 @@ }, "MeResponse": { "properties": { + "activity_seen_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Activity Seen At" + }, "id": { "format": "uuid", "title": "Id", @@ -6159,6 +6275,17 @@ "title": "Frames", "type": "array" }, + "fwhm_arcsec": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Fwhm Arcsec" + }, "gain": { "anyOf": [ { @@ -6170,6 +6297,17 @@ ], "title": "Gain" }, + "hfr_arcsec": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Hfr Arcsec" + }, "insights": { "default": [], "items": { @@ -6595,11 +6733,33 @@ "title": "Frame Count", "type": "integer" }, + "fwhm_arcsec": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Fwhm Arcsec" + }, "has_notes": { "default": false, "title": "Has Notes", "type": "boolean" }, + "hfr_arcsec": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Hfr Arcsec" + }, "integration_seconds": { "title": "Integration Seconds", "type": "number" @@ -7469,6 +7629,17 @@ ], "title": "Avg Hfr" }, + "avg_hfr_arcsec": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Avg Hfr Arcsec" + }, "catalog_memberships": { "default": [], "items": { @@ -7510,6 +7681,17 @@ ], "title": "Distance Pc" }, + "ecc_excluded_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Ecc Excluded Count" + }, "equipment": { "items": { "type": "string" @@ -8968,6 +9150,56 @@ ] } }, + "/api/activity/seen": { + "post": { + "description": "Record that the current user has seen the activity error feed.\n\nAny authenticated role may call this: it mutates only the caller's own\nrow. get_current_user and this endpoint share the request's get_session\ndependency (FastAPI caches per-request), so mutating the loaded user and\ncommitting persists it, the same pattern change_password uses in auth.py.", + "operationId": "mark_activity_seen_api_activity_seen_post", + "parameters": [ + { + "in": "cookie", + "name": "access_token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Access Token" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivitySeenResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Mark Activity Seen", + "tags": [ + "activity" + ] + } + }, "/api/analysis/boxplot": { "get": { "operationId": "get_boxplot_api_analysis_boxplot_get", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fca4929c..594d01f0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,18 +4,18 @@ import NavBar from "./components/NavBar"; import { Toast } from "./components/Toast"; import { useAuth } from "./components/AuthProvider"; import { - startErrorToastPoller, - stopErrorToastPoller, -} from "./store/errorToastPoller"; + startActivityErrorsPoller, + stopActivityErrorsPoller, +} from "./store/activityErrors"; const ErrorPollerMount: Component = () => { const { user } = useAuth(); createEffect(() => { if (user() !== null) { - startErrorToastPoller(); + startActivityErrorsPoller(); } else { - stopErrorToastPoller(); + stopActivityErrorsPoller(); } }); diff --git a/frontend/src/api/generated/schema.d.ts b/frontend/src/api/generated/schema.d.ts index 79a99dfb..b69b2687 100644 --- a/frontend/src/api/generated/schema.d.ts +++ b/frontend/src/api/generated/schema.d.ts @@ -31,6 +31,31 @@ export interface paths { patch?: never; trace?: never; }; + "/api/activity/seen": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Mark Activity Seen + * @description Record that the current user has seen the activity error feed. + * + * Any authenticated role may call this: it mutates only the caller's own + * row. get_current_user and this endpoint share the request's get_session + * dependency (FastAPI caches per-request), so mutating the loaded user and + * committing persists it, the same pattern change_password uses in auth.py. + */ + post: operations["mark_activity_seen_api_activity_seen_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/analysis/boxplot": { parameters: { query?: never; @@ -2262,6 +2287,14 @@ export interface components { */ timestamp: string; }; + /** ActivitySeenResponse */ + ActivitySeenResponse: { + /** + * Activity Seen At + * Format: date-time + */ + activity_seen_at: string; + }; /** ActivitySettingsResponse */ ActivitySettingsResponse: { /** Activity Retention Days */ @@ -2569,8 +2602,17 @@ export interface components { }; /** CompareResponse */ CompareResponse: { + /** + * Comparable + * @default true + */ + comparable: boolean; group_a: components["schemas"]["CompareGroupStats"]; group_b: components["schemas"]["CompareGroupStats"]; + /** Median Hfr Arcsec A */ + median_hfr_arcsec_a?: number | null; + /** Median Hfr Arcsec B */ + median_hfr_arcsec_b?: number | null; /** Metric */ metric: string; /** Mode */ @@ -2735,10 +2777,23 @@ export interface components { avg_eccentricity: number | null; /** Avg Hfr */ avg_hfr: number | null; + /** Avg Hfr Arcsec */ + avg_hfr_arcsec?: number | null; /** Best Hfr */ best_hfr: number | null; + /** Best Hfr Arcsec */ + best_hfr_arcsec?: number | null; + /** Ecc Excluded Count */ + ecc_excluded_count?: number | null; + /** + * Hfr Arcsec Hist + * @default [] + */ + hfr_arcsec_hist: components["schemas"]["HfrBucket"][]; /** Hfr Distribution */ hfr_distribution: components["schemas"]["HfrBucket"][]; + /** Unscaled Frame Count */ + unscaled_frame_count?: number | null; }; /** DbSummaryResponse */ DbSummaryResponse: { @@ -2926,6 +2981,8 @@ export interface components { integration_seconds: number; /** Median Fwhm */ median_fwhm?: number | null; + /** Median Fwhm Arcsec */ + median_fwhm_arcsec?: number | null; /** Median Guiding Rms */ median_guiding_rms?: number | null; /** Name */ @@ -3447,6 +3504,8 @@ export interface components { }; /** MeResponse */ MeResponse: { + /** Activity Seen At */ + activity_seen_at?: string | null; /** * Id * Format: uuid @@ -4368,8 +4427,12 @@ export interface components { * @default [] */ frames: components["schemas"]["FrameRecord"][]; + /** Fwhm Arcsec */ + fwhm_arcsec?: number | null; /** Gain */ gain?: number | null; + /** Hfr Arcsec */ + hfr_arcsec?: number | null; /** * Insights * @default [] @@ -4487,11 +4550,15 @@ export interface components { filters_used: string[]; /** Frame Count */ frame_count: number; + /** Fwhm Arcsec */ + fwhm_arcsec?: number | null; /** * Has Notes * @default false */ has_notes: boolean; + /** Hfr Arcsec */ + hfr_arcsec?: number | null; /** Integration Seconds */ integration_seconds: number; /** Median Detected Stars */ @@ -4769,6 +4836,8 @@ export interface components { avg_guiding_rms_arcsec?: number | null; /** Avg Hfr */ avg_hfr?: number | null; + /** Avg Hfr Arcsec */ + avg_hfr_arcsec?: number | null; /** * Catalog Memberships * @default [] @@ -4780,6 +4849,8 @@ export interface components { dec?: number | null; /** Distance Pc */ distance_pc?: number | null; + /** Ecc Excluded Count */ + ecc_excluded_count?: number | null; /** Equipment */ equipment: string[]; /** Filters Used */ @@ -5303,6 +5374,37 @@ export interface operations { }; }; }; + mark_activity_seen_api_activity_seen_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + access_token?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ActivitySeenResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_boxplot_api_analysis_boxplot_get: { parameters: { query: { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 823573c7..d13e053a 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -727,7 +727,7 @@ export type ActivityCategory = // widget); not a server response shape. export interface ActiveJob { id: string; - category: "scan" | "rebuild" | "thumbnail" | "enrichment" | "mosaic"; + category: "scan" | "rebuild" | "thumbnail" | "enrichment" | "mosaic" | "wbpp_copy"; label: string; subLabel?: string; progress?: number; diff --git a/frontend/src/components/FilePreviewModal.tsx b/frontend/src/components/FilePreviewModal.tsx index 33cfaab0..fd746dec 100644 --- a/frontend/src/components/FilePreviewModal.tsx +++ b/frontend/src/components/FilePreviewModal.tsx @@ -1,12 +1,28 @@ -import { createSignal, Show, onCleanup, onMount, createEffect, createMemo } from "solid-js"; +import { createSignal, Show, For, onCleanup, onMount, createEffect, createMemo } from "solid-js"; import { useSettingsContext } from "./SettingsProvider"; import HelpPopover from "./HelpPopover"; import Dialog from "./Dialog"; +// One pill in the optional per-file metadata strip. The modal renders whatever +// it is handed and knows nothing about where the values came from; callers own +// the semantics (and any color classes) entirely. +export type PreviewMetaEntry = { + label: string; + value: string; + /** Color class for the value; defaults to the modal's neutral text. */ + class?: string; + /** Renders a trailing ✕ after the value (e.g. a failed quality gate). */ + marked?: boolean; + /** Tooltip on the pill. */ + title?: string; +}; + export type PreviewFile = { imageId: string; filePath: string; thumbnailUrl?: string | null; + /** Optional metadata strip shown under the file path; updates on navigation. */ + meta?: PreviewMetaEntry[]; }; type Props = { @@ -254,6 +270,27 @@ export function FilePreviewModal(props: Props) { {current().filePath} + 0}> +
+ + {(m) => ( + + {m.label} + + {m.value} + + + + + + )} + +
+
+
({ showToast: vi.fn() })); + +import InlineEditCell from "./InlineEditCell"; + +function flush() { + return new Promise((r) => setTimeout(r, 0)); +} + +describe("InlineEditCell boolean", () => { + it("keeps the new checked state after save resolves even if props.value is stale", async () => { + // Parent does not refetch after save, so props.value stays at the old value. + const onSave = vi.fn().mockResolvedValue(undefined); + const { container } = render(() => ( + + )); + const box = container.querySelector("input[type=checkbox]") as HTMLInputElement; + expect(box.checked).toBe(false); + + fireEvent.click(box); + await flush(); + + expect(onSave).toHaveBeenCalledWith("true"); + // Bug: setSaving(false) re-ran the sync effect with the stale prop and + // reverted the checkbox to unchecked. + expect(box.checked).toBe(true); + }); + + it("still syncs when props.value actually changes from outside", async () => { + const [value, setValue] = createSignal("false"); + const { container } = render(() => ( + + )); + const box = container.querySelector("input[type=checkbox]") as HTMLInputElement; + expect(box.checked).toBe(false); + + setValue("true"); + await flush(); + expect(box.checked).toBe(true); + }); + + it("reverts to the previous value when save fails", async () => { + const onSave = vi.fn().mockRejectedValue(new Error("boom")); + const { container } = render(() => ( + + )); + const box = container.querySelector("input[type=checkbox]") as HTMLInputElement; + + fireEvent.click(box); + await flush(); + + expect(box.checked).toBe(false); + }); +}); diff --git a/frontend/src/components/InlineEditCell.tsx b/frontend/src/components/InlineEditCell.tsx index cb663081..cbe96fb1 100644 --- a/frontend/src/components/InlineEditCell.tsx +++ b/frontend/src/components/InlineEditCell.tsx @@ -1,4 +1,4 @@ -import { createSignal, Show, For, createEffect } from "solid-js"; +import { createSignal, Show, For, createEffect, on, untrack } from "solid-js"; import { showToast } from "./Toast"; import { getErrorMessage } from "../utils/errors"; @@ -16,11 +16,15 @@ export default function InlineEditCell(props: Props) { const [saving, setSaving] = createSignal(false); // Sync from props when external data changes (e.g. refetch), but never - // stomp on an in-flight save that has not yet resolved. - createEffect(() => { - const incoming = props.value; - if (!saving()) setLocalValue(incoming); - }); + // stomp on an in-flight save that has not yet resolved. Track only + // props.value: tracking saving() too would re-run this when a save + // finishes and clobber the just-committed value with a stale prop. + createEffect(on( + () => props.value, + (incoming) => { + if (!untrack(saving)) setLocalValue(incoming); + }, + )); // Confirm-then-commit: keep the old value visible (with a spinner) until the // save resolves. On success show the new value; on failure keep the old value @@ -56,12 +60,9 @@ export default function InlineEditCell(props: Props) { if (e.key === "Escape") setEditing(false); } - const Spinner = () => ( - - ); + // Saving feedback is the greyed-out disabled control itself; anything that + // mounts next to it (spinner) flashes on fast LAN saves and reads as a + // rendering artifact. // Boolean: simple checkbox if (props.columnType === "boolean") { @@ -71,10 +72,19 @@ export default function InlineEditCell(props: Props) { type="checkbox" checked={localValue() === "true"} disabled={saving()} - onChange={(e) => void save(e.currentTarget.checked ? "true" : "false")} - class="cursor-pointer disabled:opacity-50" + onChange={(e) => { + const el = e.currentTarget; + const want = el.checked ? "true" : "false"; + // Confirm-then-commit: snap the DOM back to the committed value; + // a successful save flips localValue and re-checks it. Without + // this a failed save leaves the DOM out of sync, since the + // unchanged signal never rewrites the checked attribute. + el.checked = localValue() === "true"; + void save(want); + }} + class="cursor-pointer disabled:opacity-40" + title={saving() ? "Saving..." : undefined} /> - ); } @@ -90,14 +100,14 @@ export default function InlineEditCell(props: Props) { const val = e.currentTarget.value; if (val) void save(val); }} - class="px-1 py-0.5 rounded border border-theme-border bg-theme-input text-theme-text-primary text-sm disabled:opacity-50" + class="px-1 py-0.5 rounded border border-theme-border bg-theme-input text-theme-text-primary text-sm disabled:opacity-40" + title={saving() ? "Saving..." : undefined} > {(opt) => } - ); } @@ -110,11 +120,10 @@ export default function InlineEditCell(props: Props) { {localValue() || "-"} - } > diff --git a/frontend/src/components/JobMonitor.tsx b/frontend/src/components/JobMonitor.tsx new file mode 100644 index 00000000..698d1f29 --- /dev/null +++ b/frontend/src/components/JobMonitor.tsx @@ -0,0 +1,256 @@ +import { Component, For, Show, createSignal, createEffect, onCleanup } from "solid-js"; +import { A } from "@solidjs/router"; +import { useAuth } from "./AuthProvider"; +import { useScan, scanStatus, refreshScanStatus } from "../store/scan"; +import { rebuildStatus, refreshRebuildStatus } from "../store/rebuild"; +import { + monitorJobs, + hasMonitorJobs, + stripProgress, + wireGlobalJobSources, +} from "../store/jobMonitor"; +import { + errorEvents, + unseenErrorCount, + markAllErrorsSeen, + setActivitySeenAt, +} from "../store/activityErrors"; +import type { ActiveJob } from "../api/types"; + +const IDLE_CHECK_MS = 30_000; +const PANEL_ERROR_LIMIT = 5; + +function timeAgo(iso: string): string { + const secs = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000)); + if (secs < 60) return `${secs}s ago`; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +const JobRow: Component<{ job: ActiveJob }> = (props) => ( +
+
+ {props.job.label} + + + +
+
+ + } + > +
+ +
+ +
+ {props.job.subLabel} + + · runs in this tab; closing it stops the copy + +
+
+
+); + +/** + * Ambient job monitor, mounted inside NavBar's right cluster on every page: + * a 2px aggregate progress strip on the header's bottom edge, an Activity + * chip (job count + unseen-error badge), and an anchored job-center panel. + * The strip and panel position absolutely against the header element (the + * nearest positioned ancestor: the header is position: sticky), the same + * technique NavBar's mobile dropdown uses. + */ +const JobMonitor: Component = () => { + wireGlobalJobSources(); + const { user } = useAuth(); + // Ref-counted 2s poller while a scan is active, plus one status fetch on + // mount. NavBar (and therefore this component) is mounted on every page + // except /login, so the subscriber count never drops to zero mid-session. + useScan(); + + // Seed the server-side seen marker from the auth "me" payload. + createEffect(() => { + const u = user(); + if (u) setActivitySeenAt(u.activity_seen_at ?? null); + }); + + // Idle watch (planner-chosen mechanism, see plan header): while nothing is + // known to run, one cheap status check every 30s detects scans or rebuilds + // started elsewhere (auto-scan schedule, another tab). While a job runs, + // the existing 2s pollers own freshness and this tick does nothing. + const idleTimer = setInterval(() => { + if (document.visibilityState !== "visible") return; + const s = scanStatus().state; + if (s !== "scanning" && s !== "ingesting") void refreshScanStatus(); + if (rebuildStatus().state !== "running") void refreshRebuildStatus(); + }, IDLE_CHECK_MS); + onCleanup(() => clearInterval(idleTimer)); + + const [open, setOpen] = createSignal(false); + let panelRef: HTMLDivElement | undefined; + let chipRef: HTMLButtonElement | undefined; + + const onDocMouseDown = (e: MouseEvent) => { + if (!open()) return; + const t = e.target as Node; + if (panelRef?.contains(t) || chipRef?.contains(t)) return; + setOpen(false); + }; + document.addEventListener("mousedown", onDocMouseDown); + onCleanup(() => document.removeEventListener("mousedown", onDocMouseDown)); + + const chipVisible = () => hasMonitorJobs() || unseenErrorCount() > 0; + const jobCount = () => monitorJobs().length; + const recentErrors = () => errorEvents().slice(0, PANEL_ERROR_LIMIT); + + return ( + <> + + + {/* Activity chip */} + + + + + {/* Ambient 2px progress strip along the NavBar bottom edge */} + +