From 98b73f5425ffa32c6b3a9e2868290a6dc7fdc5ec Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sat, 18 Jul 2026 12:39:54 -0500 Subject: [PATCH 01/23] feat(wbpp): open frame previews from the export quality table File names in the WBPP quality table are now preview links, matching the session frame table. Clicking one opens the shared FilePreviewModal, and the arrow keys walk every row in the table's current sort order from the clicked frame, so sorting by verdict lets you flip through all excluded frames in sequence. The modal gains an optional per-file metadata strip, passed as a generic label/value array so it stays ignorant of WBPP concepts. The WBPP panel fills it with a mirror of the table row: the verdict (with failure detail in the tooltip), filter, the five quality metrics with the same baseline band colors and failure marks, and the session date. With the master filter toggle off, every strip reads Copy. Other callers of the modal pass nothing and are unaffected. --- frontend/src/components/FilePreviewModal.tsx | 39 ++++++++++- .../components/wbpp/WbppQualityPanel.test.tsx | 68 +++++++++++++++++- .../src/components/wbpp/WbppQualityPanel.tsx | 70 ++++++++++++++++++- 3 files changed, 172 insertions(+), 5 deletions(-) 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} + + + + + + )} + +
+
+
({ + useSettingsContext: () => ({ + settings: () => ({ general: { preview_resolution: 2400 } }), + }), +})); + import WbppQualityPanel from "./WbppQualityPanel"; import { emptyConstraintFor, @@ -21,6 +30,9 @@ function frame(name: string, over: Partial = {}): FrameRecord { return { file_name: name, filter_used: "L", + image_id: `id-${name}`, + file_path: `/data/lights/${name}`, + thumbnail_url: null, source_relative: `lights/${name}`, timestamp: "2026-07-01T00:00:00", median_hfr: null, @@ -524,6 +536,60 @@ describe("WbppQualityPanel verdict column", () => { }); }); +describe("WbppQualityPanel file preview", () => { + const dialog = () => document.querySelector('[role="dialog"]') as HTMLElement | null; + + function openRow(container: HTMLElement, name: string) { + const link = Array.from(container.querySelectorAll("tbody td button")).find( + (b) => b.textContent === name, + ) as HTMLButtonElement; + expect(link).toBeTruthy(); + fireEvent.click(link); + return dialog()!; + } + + it("renders the file name as a preview link", () => { + const { container } = setup(); + const cells = container.querySelectorAll("tbody tr td:nth-last-child(2)"); + for (const td of Array.from(cells)) { + expect(td.querySelector("button")).toBeTruthy(); + } + expect(fileColumn(container)).toEqual(["a.fits", "b.fits", "c.fits"]); + }); + + it("opens the preview modal with the row's verdict and metric strip", () => { + const { container } = setup(); + const d = openRow(container, "b.fits"); + expect(d.textContent).toContain("/data/lights/b.fits"); + // Verdict pill carries the failure detail in its tooltip. + expect(d.textContent).toContain("Exclude"); + const verdictPill = Array.from(d.querySelectorAll("span[title]")).find((s) => + (s as HTMLElement).title.startsWith("Exclude"), + ) as HTMLElement; + expect(verdictPill.title).toContain("HFR 2.80 > 2.00"); + // Metric, filter and session pills mirror the table row. + expect(d.textContent).toContain("Filter"); + expect(d.textContent).toContain("HFR"); + expect(d.textContent).toContain(DATE); + fireEvent.click(d.querySelector('button[class*="absolute"]')!); + expect(dialog()).toBe(null); + }); + + it("navigates all rows in the current sort order from the clicked index", () => { + const { container } = setup(); + const d = openRow(container, "b.fits"); + // Chronological default order: a, b, c -> b.fits is 2 of 3. + expect(d.textContent).toContain("2 / 3"); + }); + + it("shows Copy on every row's preview while the master toggle is off", () => { + const { container } = setup({ enabled: false }); + const d = openRow(container, "b.fits"); + expect(d.textContent).toContain("Copy"); + expect(d.textContent).not.toContain("Exclude"); + }); +}); + describe("WbppQualityPanel empty and loading states", () => { it("shows the loading line in place of the table while loading", () => { const { container } = setup({ loading: true, verdicts: [] }); diff --git a/frontend/src/components/wbpp/WbppQualityPanel.tsx b/frontend/src/components/wbpp/WbppQualityPanel.tsx index 8b9397ce..4935b9ee 100644 --- a/frontend/src/components/wbpp/WbppQualityPanel.tsx +++ b/frontend/src/components/wbpp/WbppQualityPanel.tsx @@ -25,6 +25,8 @@ import { } from "../../lib/wbppQualityFilter"; import { bandForZ, bandToCellClass } from "../../utils/frameQuality"; import HelpPopover from "../HelpPopover"; +import { ClickableFilePath } from "../ClickableFilePath"; +import type { PreviewFile, PreviewMetaEntry } from "../FilePreviewModal"; import type { FrameRecord, SessionDetail } from "../../api/types"; export interface WbppQualityPanelProps { @@ -191,6 +193,60 @@ export default function WbppQualityPanel(props: WbppQualityPanelProps): JSX.Elem const arrow = (key: SortKey) => (sortKey() === key ? (sortAsc() ? "▲" : "▼") : null); + // The preview modal's metadata strip mirrors this row's cells: verdict, + // filter, the five metrics with the same band coloring and failure marks, + // then the session date. The modal's own neutral text is used where the + // table would fall back to its default color. + const stripClass = (cls: string): string | undefined => + cls === "text-theme-text-primary" ? undefined : cls; + + const metaFor = (v: FrameVerdict): PreviewMetaEntry[] => { + const kept = effectiveKeep(v); + const verdictEntry: PreviewMetaEntry = kept + ? { label: "Verdict", value: "Copy", class: "text-theme-success", title: "Copy" } + : v.reason === "unmeasured" + ? { + label: "Verdict", + value: "Unmeasured", + class: "text-theme-warning", + title: "Unmeasured: none of the constrained metrics recorded", + } + : { + label: "Verdict", + value: "Exclude", + class: "text-theme-error", + title: `Exclude: ${v.failures.map((f) => f.text).join(", ")}`, + }; + const metricEntries = METRIC_ORDER.map((metric): PreviewMetaEntry => { + const raw = metricValue(v.frame, metric); + const failure = props.enabled ? v.failures.find((f) => f.metric === metric) : undefined; + return { + label: SHORT_LABEL[metric], + value: raw != null ? formatMetric(metric, raw) : "—", + class: failure ? "text-theme-error font-medium" : stripClass(cellClass(v, metric)), + marked: !!failure, + title: failure?.text, + }; + }); + return [ + verdictEntry, + { label: "Filter", value: v.frame.filter_used ?? "—" }, + ...metricEntries, + { label: "Session", value: v.sessionDate }, + ]; + }; + + // Same order as the rendered rows, so a row's index addresses its own entry + // and ←/→ in the modal walks the table in its current sort. + const previewFiles = createMemo(() => + sortedVerdicts().map((v) => ({ + imageId: v.frame.image_id, + filePath: v.frame.file_path, + thumbnailUrl: v.frame.thumbnail_url, + meta: metaFor(v), + })), + ); + return (
{/* Toolbar: enable checkbox, metric chips, kept/skipped tally, baseline. @@ -425,7 +481,7 @@ export default function WbppQualityPanel(props: WbppQualityPanelProps): JSX.Elem - {(v) => ( + {(v, i) => ( {/* Icon-only verdict: the glyph says THAT a frame is dropped; the marked metric cells say which gates @@ -482,8 +538,16 @@ export default function WbppQualityPanel(props: WbppQualityPanelProps): JSX.Elem {/* The file column absorbs whatever width the fitted columns leave over, so the table still fills the panel without stretching the data columns. */} - - {v.frame.file_name} + + {v.sessionDate} From a0093930c0c5aeee6012d0a292b0f8c3f0d4b67c Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sat, 18 Jul 2026 13:26:53 -0500 Subject: [PATCH 02/23] feat(activity): add users.activity_seen_at column for server-side seen state --- .../versions/0020_user_activity_seen.py | 33 +++++++++++++++++++ backend/app/models/user.py | 4 +++ backend/tests/test_api_activity_seen.py | 33 +++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 backend/alembic/versions/0020_user_activity_seen.py create mode 100644 backend/tests/test_api_activity_seen.py 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/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/tests/test_api_activity_seen.py b/backend/tests/test_api_activity_seen.py new file mode 100644 index 00000000..d083095f --- /dev/null +++ b/backend/tests/test_api_activity_seen.py @@ -0,0 +1,33 @@ +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 From 9d74f0f3c510b1843d5378a98931e503e1026428 Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sat, 18 Jul 2026 13:27:04 -0500 Subject: [PATCH 03/23] feat(wbpp): module-level copy job store with beforeunload guard and ActiveJob projection --- frontend/src/api/types.ts | 2 +- frontend/src/store/wbppCopyJob.test.ts | 122 +++++++++++++++++++++++++ frontend/src/store/wbppCopyJob.ts | 116 +++++++++++++++++++++++ 3 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 frontend/src/store/wbppCopyJob.test.ts create mode 100644 frontend/src/store/wbppCopyJob.ts 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/store/wbppCopyJob.test.ts b/frontend/src/store/wbppCopyJob.test.ts new file mode 100644 index 00000000..2084586c --- /dev/null +++ b/frontend/src/store/wbppCopyJob.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../lib/wbppBrowserCopy", () => { + class CopyCancelledError extends Error { + constructor() { + super("cancelled"); + this.name = "CopyCancelledError"; + } + } + return { runBrowserCopy: vi.fn(), CopyCancelledError }; +}); +vi.mock("../components/Toast", () => ({ showToast: vi.fn() })); + +import { runBrowserCopy, CopyCancelledError } from "../lib/wbppBrowserCopy"; +import { showToast } from "../components/Toast"; +import { + startWbppCopy, + stopWbppCopy, + wbppCopyRunning, + wbppCopyDone, + wbppCopyTotal, + wbppCopyError, + wbppCopyFinished, + wbppCopyActiveJob, +} from "./wbppCopyJob"; + +const args = { + rootHandle: {}, + destHandle: {}, + operations: [], + exclusions: [], + excludedSourceRelatives: [], + targetName: "M31", +}; + +describe("wbppCopyJob lifecycle", () => { + beforeEach(() => { + vi.mocked(runBrowserCopy).mockReset(); + vi.mocked(showToast).mockReset(); + }); + + it("tracks progress while running and exposes an ActiveJob, then clears on completion", async () => { + let resolveCopy!: (r: { copied: number; destinationName: string }) => void; + vi.mocked(runBrowserCopy).mockImplementation((_root, _dest, opts) => { + opts.onProgress(5, 10, "2025-01-01: frame.fits"); + return new Promise((res) => { resolveCopy = res; }); + }); + + const p = startWbppCopy(args); + expect(wbppCopyRunning()).toBe(true); + expect(wbppCopyDone()).toBe(5); + expect(wbppCopyTotal()).toBe(10); + + const job = wbppCopyActiveJob(); + expect(job?.id).toBe("wbpp-copy"); + expect(job?.category).toBe("wbpp_copy"); + expect(job?.label).toBe("WBPP copy: M31"); + expect(job?.progress).toBeCloseTo(0.5, 5); + expect(job?.cancelable).toBe(true); + + resolveCopy({ copied: 10, destinationName: "staging" }); + await p; + expect(wbppCopyRunning()).toBe(false); + expect(wbppCopyFinished()).toBe(10); + expect(wbppCopyActiveJob()).toBeNull(); + expect(vi.mocked(showToast).mock.calls[0][0]).toContain("Copied 10 files"); + }); + + it("registers a beforeunload guard only while the copy runs", async () => { + const addSpy = vi.spyOn(window, "addEventListener"); + const removeSpy = vi.spyOn(window, "removeEventListener"); + vi.mocked(runBrowserCopy).mockResolvedValue({ copied: 1, destinationName: "d" }); + + await startWbppCopy(args); + + expect(addSpy.mock.calls.some(([type]) => type === "beforeunload")).toBe(true); + expect(removeSpy.mock.calls.some(([type]) => type === "beforeunload")).toBe(true); + addSpy.mockRestore(); + removeSpy.mockRestore(); + }); + + it("treats cancellation as a clean stop, not an error", async () => { + vi.mocked(runBrowserCopy).mockRejectedValue(new CopyCancelledError()); + await startWbppCopy(args); + expect(wbppCopyRunning()).toBe(false); + expect(wbppCopyError()).toBeNull(); + }); + + it("records failures and surfaces a toast", async () => { + vi.mocked(runBrowserCopy).mockRejectedValue(new Error("disk full")); + await startWbppCopy(args); + expect(wbppCopyError()).toContain("disk full"); + expect(vi.mocked(showToast)).toHaveBeenCalled(); + }); + + it("ignores a second start while a copy is running", async () => { + let resolveCopy!: (r: { copied: number; destinationName: string }) => void; + vi.mocked(runBrowserCopy).mockImplementation( + () => new Promise((res) => { resolveCopy = res; }), + ); + const p = startWbppCopy(args); + await startWbppCopy(args); + expect(vi.mocked(runBrowserCopy)).toHaveBeenCalledTimes(1); + resolveCopy({ copied: 0, destinationName: "d" }); + await p; + }); + + it("stopWbppCopy aborts the in-flight signal", async () => { + let capturedSignal: AbortSignal | undefined; + vi.mocked(runBrowserCopy).mockImplementation((_r, _d, opts) => { + capturedSignal = opts.signal; + return new Promise((_res, reject) => { + opts.signal?.addEventListener("abort", () => reject(new CopyCancelledError())); + }); + }); + const p = startWbppCopy(args); + stopWbppCopy(); + await p; + expect(capturedSignal?.aborted).toBe(true); + expect(wbppCopyRunning()).toBe(false); + }); +}); diff --git a/frontend/src/store/wbppCopyJob.ts b/frontend/src/store/wbppCopyJob.ts new file mode 100644 index 00000000..8afea931 --- /dev/null +++ b/frontend/src/store/wbppCopyJob.ts @@ -0,0 +1,116 @@ +// Module-level state for the WBPP in-browser copy, so the copy survives the +// export modal closing: the modal drives the copy through startWbppCopy and +// renders from these accessors, and the job monitor shows the same state on +// every page. The copy still dies with the tab (File System Access API is +// tab-bound), which is why a beforeunload guard is registered while running. +import { createSignal } from "solid-js"; +import { + runBrowserCopy, + CopyCancelledError, + type PermState, +} from "../lib/wbppBrowserCopy"; +import { showToast } from "../components/Toast"; +import { getErrorMessage } from "../utils/errors"; +import type { ActiveJob, WbppCopyOperation } from "../api/types"; + +const [running, setRunning] = createSignal(false); +const [done, setDone] = createSignal(0); +const [total, setTotal] = createSignal(0); +const [label, setLabel] = createSignal(""); +const [error, setError] = createSignal(null); +const [finished, setFinished] = createSignal(null); +const [targetName, setTargetName] = createSignal(""); +let startedAt = 0; +let abortController: AbortController | null = null; + +// preventDefault + returnValue is the portable way to request the browser's +// generic "leave site?" confirmation while a copy is in flight. +function onBeforeUnload(e: BeforeUnloadEvent): void { + e.preventDefault(); + e.returnValue = ""; +} + +export interface StartWbppCopyArgs { + rootHandle: any; + destHandle: any; + operations: WbppCopyOperation[]; + exclusions: string[]; + excludedSourceRelatives: string[]; + targetName: string; + onPermission?: (which: "source" | "dest", state: PermState) => void; +} + +export async function startWbppCopy(args: StartWbppCopyArgs): Promise { + if (running()) return; + setError(null); + setFinished(null); + setDone(0); + setTotal(0); + setLabel(""); + setTargetName(args.targetName); + startedAt = Date.now(); + abortController = new AbortController(); + setRunning(true); + window.addEventListener("beforeunload", onBeforeUnload); + try { + const result = await runBrowserCopy(args.rootHandle, args.destHandle, { + operations: args.operations, + exclusions: args.exclusions, + excludedSourceRelatives: args.excludedSourceRelatives, + onProgress: (d, t, l) => { + setDone(d); + setTotal(t); + setLabel(l); + }, + onPermission: args.onPermission, + signal: abortController.signal, + }); + setFinished(result.copied); + showToast( + `Copied ${result.copied} file${result.copied !== 1 ? "s" : ""} to ${result.destinationName}`, + ); + } catch (e: unknown) { + if (!(e instanceof CopyCancelledError)) { + const msg = getErrorMessage(e, "Browser copy failed."); + setError(msg); + // Toast too: the modal that started the copy may be long closed. + showToast(msg, "error", 0); + } + } finally { + setRunning(false); + abortController = null; + window.removeEventListener("beforeunload", onBeforeUnload); + } +} + +export function stopWbppCopy(): void { + abortController?.abort(); +} + +export const wbppCopyRunning = running; +export const wbppCopyDone = done; +export const wbppCopyTotal = total; +export const wbppCopyLabel = label; +export const wbppCopyError = error; +export const wbppCopyFinished = finished; + +export function clearWbppCopyError(): void { + setError(null); +} + +export const wbppCopyActiveJob = (): ActiveJob | null => + running() + ? { + id: "wbpp-copy", + category: "wbpp_copy", + label: `WBPP copy: ${targetName()}`, + subLabel: + total() > 0 + ? `${done().toLocaleString()} / ${total().toLocaleString()} files` + : "Counting files", + progress: total() > 0 ? Math.min(1, done() / total()) : undefined, + startedAt, + cancelable: true, + onCancel: async () => stopWbppCopy(), + } + : null; From 3aa081c2eaf348b4d55a4dc93691281574bf0eb9 Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sat, 18 Jul 2026 13:27:43 -0500 Subject: [PATCH 04/23] feat(activity): POST /api/activity/seen and activity_seen_at in the auth me payload --- backend/app/api/activity.py | 26 ++++++++++- backend/app/api/auth.py | 7 ++- backend/app/schemas/activity.py | 4 ++ backend/app/schemas/auth.py | 1 + backend/tests/conftest.py | 2 + backend/tests/test_api_activity_seen.py | 59 +++++++++++++++++++++++++ 6 files changed, 96 insertions(+), 3 deletions(-) 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/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/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/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 index d083095f..08f2cec5 100644 --- a/backend/tests/test_api_activity_seen.py +++ b/backend/tests/test_api_activity_seen.py @@ -31,3 +31,62 @@ 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 From 623d9096ed0bd917a3f1ddf4cb721d73f0a4733f Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sat, 18 Jul 2026 13:28:25 -0500 Subject: [PATCH 05/23] refactor(monitor): module-level scan controls and a shared rebuild status store so jobs are visible off the Settings page Extracts rebuild polling out of ScanManager into store/rebuild.ts, and promotes scan status/stop/refresh to module-level exports in store/scan.ts. This lets an always-mounted job monitor read scan and rebuild state and stop a scan from anywhere in the app, not only while Settings is open. ScanManager keeps its existing wiring into the dashboard ActivityFeed unchanged; behavior for scan/rebuild polling is byte-for-byte the same, just relocated. --- frontend/src/components/ScanManager.tsx | 49 ++---------------- frontend/src/store/rebuild.ts | 68 +++++++++++++++++++++++++ frontend/src/store/scan.ts | 35 +++++++++---- 3 files changed, 97 insertions(+), 55 deletions(-) create mode 100644 frontend/src/store/rebuild.ts diff --git a/frontend/src/components/ScanManager.tsx b/frontend/src/components/ScanManager.tsx index 47497c43..5f828d39 100644 --- a/frontend/src/components/ScanManager.tsx +++ b/frontend/src/components/ScanManager.tsx @@ -7,18 +7,6 @@ import { apiClient } from "../api/generated/client"; import { unwrap } from "../api/unwrap"; import { scanFilters as scanFiltersApi } from "../api/scanFilters"; import type { DbSummary } from "../api/types"; -// Deliberately kept on the OLD hand-written `RebuildStatus` (narrow `state` -// literal union, `details: Record`) rather than the -// generated-schema alias in `../api/types` (`RebuildStatusResponse`, which -// types `state` as a plain `string` and `details` as `Record`). -// This component's `rebuildState` signal is threaded into -// `store/activeJobs.ts`'s `wireActiveJobSources` (out of scope for this -// slice) via the old type's literal-union contract; repointing here would -// ripple a type break into that file. The field names are otherwise -// identical to the generated `RebuildStatusResponse`, so narrowing the -// fetch result via a runtime-safe cast preserves both the migration and the -// existing contract. See store/scan.ts for the matching ScanStatus deferral. -import type { RebuildStatus } from "../api/types"; import type { ScanFiltersResponse } from "../api/scanFilters"; import DatabaseOverview from "./DatabaseOverview"; import CaptureActivity from "./CaptureActivity"; @@ -31,6 +19,7 @@ import ScanFiltersOnboarding from "./ScanFiltersOnboarding"; import { showToast } from "./Toast"; import HelpPopover from "./HelpPopover"; import { wireActiveJobSources } from "../store/activeJobs"; +import { rebuildStatus, fetchRebuildStatus, startRebuildPolling } from "../store/rebuild"; type FrameFilter = "all" | "light_only"; @@ -53,9 +42,7 @@ const ScanManager: Component = () => { const [forceOrphanCleanup, setForceOrphanCleanup] = createSignal(false); const [forceCleanupConfirmOpen, setForceCleanupConfirmOpen] = createSignal(false); const [dbSummary, setDbSummary] = createSignal(null); - const [rebuildState, setRebuildState] = createSignal({ - state: "idle", mode: "", message: "", started_at: null, completed_at: null, details: {}, - }); + const rebuildState = rebuildStatus; const [autoScanEnabled, setAutoScanEnabled] = createSignal(true); const [autoScanInterval, setAutoScanInterval] = createSignal(240); const [observerName, setObserverName] = createSignal(null); @@ -64,7 +51,6 @@ const ScanManager: Component = () => { const [latError, setLatError] = createSignal(null); const [lngError, setLngError] = createSignal(null); const [scanFiltersData, setScanFiltersData] = createSignal(null); - let rebuildPollTimer: ReturnType | null = null; wireActiveJobSources(scanStatus, rebuildState, stopScan); @@ -166,40 +152,11 @@ const ScanManager: Component = () => { runScan(); }; - // --- Rebuild polling --- - const fetchRebuildStatus = async () => { - try { - const status = await apiClient.GET("/api/scan/rebuild-status").then(unwrap) as RebuildStatus; - const prev = rebuildState().state; - setRebuildState(status); - if (status.state !== "running") { - if (rebuildPollTimer) { clearInterval(rebuildPollTimer); rebuildPollTimer = null; } - } - } catch { /* ignore */ } - }; - + // Pick up an in-flight rebuild when Settings opens. fetchRebuildStatus(); - const startRebuildPolling = () => { - // Clear any previous timer so we can restart - if (rebuildPollTimer) { clearInterval(rebuildPollTimer); rebuildPollTimer = null; } - // Set optimistic running state so UI shows feedback immediately - // (the Celery task may not have updated Redis yet) - setRebuildState((prev) => ({ - ...prev, - state: "running", - message: "Starting...", - started_at: Date.now() / 1000, - completed_at: null, - })); - // Give the Celery task a moment to pick up before first poll - setTimeout(fetchRebuildStatus, 1000); - rebuildPollTimer = setInterval(fetchRebuildStatus, 2000); - }; - onCleanup(() => { stopPolling(); - if (rebuildPollTimer) clearInterval(rebuildPollTimer); }); return ( diff --git a/frontend/src/store/rebuild.ts b/frontend/src/store/rebuild.ts new file mode 100644 index 00000000..c5e56358 --- /dev/null +++ b/frontend/src/store/rebuild.ts @@ -0,0 +1,68 @@ +// Module-level rebuild status, extracted from ScanManager so the job monitor +// can show a stats rebuild from any page, not only while Settings is open. +// Same polling shape as store/scan.ts: a 2s poll that stops itself when the +// rebuild leaves "running". +import { createSignal } from "solid-js"; +import { apiClient } from "../api/generated/client"; +import { unwrap } from "../api/unwrap"; +// Hand-written RebuildStatus (narrow state literal union) rather than the +// generated RebuildStatusResponse, matching the existing contract threaded +// into store/activeJobs.ts wireActiveJobSources. See ScanManager.tsx's +// original import note; the field names are identical, so the cast below +// preserves the migration and the contract. +import type { RebuildStatus } from "../api/types"; + +const defaultStatus: RebuildStatus = { + state: "idle", + mode: "", + message: "", + started_at: null, + completed_at: null, + details: {}, +}; + +const [rebuildStatus, setRebuildStatus] = createSignal({ ...defaultStatus }); + +export { rebuildStatus }; + +let pollTimer: ReturnType | null = null; + +function stopRebuildPolling(): void { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } +} + +export async function fetchRebuildStatus(): Promise { + try { + const status = await apiClient.GET("/api/scan/rebuild-status").then(unwrap) as RebuildStatus; + setRebuildStatus(status); + if (status.state !== "running") stopRebuildPolling(); + } catch { /* ignore - transient network errors must not kill the poll loop */ } +} + +export function startRebuildPolling(): void { + // Clear any previous timer so we can restart. + stopRebuildPolling(); + // Optimistic running state so the UI shows feedback immediately + // (the Celery task may not have updated Redis yet). + setRebuildStatus((prev) => ({ + ...prev, + state: "running", + message: "Starting...", + started_at: Date.now() / 1000, + completed_at: null, + })); + // Give the Celery task a moment to pick up before the first poll. + setTimeout(fetchRebuildStatus, 1000); + pollTimer = setInterval(fetchRebuildStatus, 2000); +} + +// One cheap status check; resumes the 2s poll if a rebuild started elsewhere. +export async function refreshRebuildStatus(): Promise { + await fetchRebuildStatus(); + if (rebuildStatus().state === "running" && !pollTimer) { + pollTimer = setInterval(fetchRebuildStatus, 2000); + } +} diff --git a/frontend/src/store/scan.ts b/frontend/src/store/scan.ts index b1e60360..4dc47e77 100644 --- a/frontend/src/store/scan.ts +++ b/frontend/src/store/scan.ts @@ -62,6 +62,31 @@ function stopPolling() { } } +// Module-scope exports for the always-mounted job monitor: the monitor needs +// the status accessor and stop action without owning a useScan() lifecycle, +// and a way to bootstrap the 2s poller when its slow idle check discovers a +// scan started elsewhere (auto-scan, another tab). +export { scanStatus }; + +export async function stopScan(): Promise { + setStopping(true); + try { + await apiClient.POST("/api/scan/stop", {}).then(unwrap); + } catch { + setStopping(false); + setScanError("Failed to stop scan"); + } +} + +// One cheap status check; hands off to the existing 2s poller when active. +export async function refreshScanStatus(): Promise { + await fetchStatus(); + const s = scanStatus(); + if (s.state === "scanning" || s.state === "ingesting") { + startPolling(true); + } +} + export function useScan() { // On every mount, check server state - resume polling if scan is active onMount(async () => { @@ -144,15 +169,7 @@ export function useScan() { } }, - stopScan: async () => { - setStopping(true); - try { - await apiClient.POST("/api/scan/stop", {}).then(unwrap); - } catch { - setStopping(false); - setScanError("Failed to stop scan"); - } - }, + stopScan, stopPolling, }; From d4e973fecaaa39ee62c9c7dce5ad0f04ac9cd2bd Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sat, 18 Jul 2026 13:28:56 -0500 Subject: [PATCH 06/23] chore(api): regenerate OpenAPI schema for activity seen endpoint --- frontend/openapi.json | 232 +++++++++++++++++++++++++ frontend/src/api/generated/schema.d.ts | 102 +++++++++++ 2 files changed, 334 insertions(+) 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/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: { From d1b35a50cad596d3d4affdcc7cea5e4bffa11054 Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sat, 18 Jul 2026 14:29:57 -0500 Subject: [PATCH 07/23] feat(wbpp): browser copy survives closing the export modal by running through the copy job store --- frontend/src/components/WbppExportModal.tsx | 99 ++++++++++----------- 1 file changed, 46 insertions(+), 53 deletions(-) diff --git a/frontend/src/components/WbppExportModal.tsx b/frontend/src/components/WbppExportModal.tsx index 881e763a..99e4e06f 100644 --- a/frontend/src/components/WbppExportModal.tsx +++ b/frontend/src/components/WbppExportModal.tsx @@ -23,7 +23,6 @@ import WbppFooter, { type CopyBlocker } from "./wbpp/WbppFooter"; import { detectOs, lastSegment, parentContext } from "./wbpp/paths"; import { isFsAccessSupported, - runBrowserCopy, CopyCancelledError, pickDirectory, queryHandlePermission, @@ -32,6 +31,17 @@ import { HANDLE_KEYS, type PermState, } from "../lib/wbppBrowserCopy"; +import { + startWbppCopy, + stopWbppCopy, + wbppCopyRunning, + wbppCopyDone, + wbppCopyTotal, + wbppCopyLabel, + wbppCopyError, + wbppCopyFinished, + clearWbppCopyError, +} from "../store/wbppCopyJob"; import { computeVerdicts, excludedSourceRelatives, @@ -146,12 +156,6 @@ const WbppExportModal: Component = (props) => { const [destHandle, setDestHandle] = createSignal(null); const [srcPerm, setSrcPerm] = createSignal("none"); const [destPerm, setDestPerm] = createSignal("none"); - const [copying, setCopying] = createSignal(false); - const [copyDone, setCopyDone] = createSignal(0); - const [copyTotal, setCopyTotal] = createSignal(0); - const [copyLabel, setCopyLabel] = createSignal(""); - const [copyFinished, setCopyFinished] = createSignal(null); - let abortController: AbortController | null = null; let scriptRef: HTMLDivElement | undefined; // --- Quality filter state, persisted per rig --- @@ -698,11 +702,17 @@ const WbppExportModal: Component = (props) => { }; /** - * Grant-then-copy in one click: runBrowserCopy requests both permissions itself - * as its first act, still inside this click's user gesture, then copies. There - * is no separate "Grant access" step to forget. + * Grant-then-copy in one click: runBrowserCopy (inside the store) requests + * both permissions itself as its first act, still inside this click's user + * gesture, then copies. The copy runs in the module-level wbppCopyJob store, + * so closing this modal no longer loses it; the nav-bar job monitor shows + * progress and Stop, and reopening the modal reattaches to the live state. */ - const startBrowserCopy = async () => { + const startBrowserCopy = () => { + if (wbppCopyRunning()) { + setError("A WBPP copy is already running. Stop it from the Activity monitor first."); + return; + } if (!srcHandle() || !destHandle()) { setError("Choose a library folder and a destination folder first."); return; @@ -712,42 +722,23 @@ const WbppExportModal: Component = (props) => { return; } setError(null); - setCopyFinished(null); - setCopyDone(0); - setCopyTotal(0); - setCopyLabel(""); - abortController = new AbortController(); - setCopying(true); - try { - const result = await runBrowserCopy(srcHandle(), destHandle(), { - operations: plan(), - exclusions: parsedExclusions(), - excludedSourceRelatives: excludedSet(), - onProgress: (done, total, label) => { - setCopyDone(done); - setCopyTotal(total); - setCopyLabel(label); - }, - // Take the permission state from what the request actually returned, on - // the failing path as much as the succeeding one. Hardcoding "granted" - // after a successful copy left a denial unable to reach "denied": the row - // kept saying "needs permission" and the primary kept offering to grant - // access that had just been refused, click after click. - onPermission: (which, state) => - which === "source" ? setSrcPerm(state) : setDestPerm(state), - signal: abortController.signal, - }); - setCopyFinished(result.copied); - showToast(`Copied ${result.copied} file${result.copied !== 1 ? "s" : ""} to ${result.destinationName}`); - } catch (e: unknown) { - if (!(e instanceof CopyCancelledError)) setError(getErrorMessage(e, "Browser copy failed.")); - } finally { - setCopying(false); - abortController = null; - } + clearWbppCopyError(); + void startWbppCopy({ + rootHandle: srcHandle(), + destHandle: destHandle(), + operations: plan(), + exclusions: parsedExclusions(), + excludedSourceRelatives: excludedSet(), + targetName: props.targetName, + // Take the permission state from what the request actually returned, on + // the failing path as much as the succeeding one (see the store; these + // setters are safe to call even after this modal unmounts). + onPermission: (which, state) => + which === "source" ? setSrcPerm(state) : setDestPerm(state), + }); }; - const stopCopy = () => abortController?.abort(); + const stopCopy = () => stopWbppCopy(); return ( @@ -776,9 +767,9 @@ const WbppExportModal: Component = (props) => { {/* Body: the ONLY scroll container. */}
- +
- {error()} + {error() ?? wbppCopyError()}
@@ -864,19 +855,19 @@ const WbppExportModal: Component = (props) => {

- +
0 ? Math.round((copyDone() / copyTotal()) * 100) : (copyFinished() !== null ? 100 : 0)}%`, + width: `${wbppCopyTotal() > 0 ? Math.round((wbppCopyDone() / wbppCopyTotal()) * 100) : (wbppCopyFinished() !== null ? 100 : 0)}%`, }} />
- +

- Done. Copied {copyFinished()} file{copyFinished() !== 1 ? "s" : ""}. Open WBPP and use Add Directory on the destination. + Done. Copied {wbppCopyFinished()} file{wbppCopyFinished() !== 1 ? "s" : ""}. Open WBPP and use Add Directory on the destination.

@@ -1183,9 +1174,11 @@ const WbppExportModal: Component = (props) => { permissionGranted={permissionGranted()} blockedBy={copyBlockedBy()} canBrowserCopy={canBrowserCopy} - copying={copying()} + copying={wbppCopyRunning()} copyProgress={ - copying() ? { done: copyDone(), total: copyTotal(), label: copyLabel() } : null + wbppCopyRunning() + ? { done: wbppCopyDone(), total: wbppCopyTotal(), label: wbppCopyLabel() } + : null } onCopy={startBrowserCopy} onStop={stopCopy} From 8b2771ce4a47705428c3ceb97c1798f14645b853 Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sat, 18 Jul 2026 14:33:09 -0500 Subject: [PATCH 08/23] feat(monitor): job aggregation store with global source wiring and strip progress Adds the aggregation layer the nav-bar job monitor renders from: a single monitorJobs() list combining the existing scan/rebuild/Celery activeJobs registry with the WBPP browser copy job, and aggregateProgress() to reduce that list to one strip-level fraction (mean of determinate jobs, null when every running job is indeterminate so the strip falls back to a shimmer). wireGlobalJobSources() replaces the per-page ScanManager wiring so jobs stay visible across the whole app rather than only the page that started them. --- frontend/src/store/errorToastPoller.ts | 80 -------------------------- frontend/src/store/jobMonitor.test.ts | 54 +++++++++++++++++ frontend/src/store/jobMonitor.ts | 39 +++++++++++++ 3 files changed, 93 insertions(+), 80 deletions(-) delete mode 100644 frontend/src/store/errorToastPoller.ts create mode 100644 frontend/src/store/jobMonitor.test.ts create mode 100644 frontend/src/store/jobMonitor.ts diff --git a/frontend/src/store/errorToastPoller.ts b/frontend/src/store/errorToastPoller.ts deleted file mode 100644 index bdab4094..00000000 --- a/frontend/src/store/errorToastPoller.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { showToast } from "../components/Toast"; -import { apiClient } from "../api/generated/client"; -import { unwrap } from "../api/unwrap"; - -const LS_KEY = "galactilog_last_error_ts"; - -// Tracks activity item IDs already surfaced as toasts in this browser session so -// the same error never pops again after the user has seen it (or dismissed it). -const seenIds = new Set(); - -function getLastSeenTs(): string { - try { - return localStorage.getItem(LS_KEY) ?? new Date(0).toISOString(); - } catch { - return new Date(0).toISOString(); - } -} - -function setLastSeenTs(ts: string): void { - try { - localStorage.setItem(LS_KEY, ts); - } catch { /* ignore */ } -} - -let pollTimer: ReturnType | null = null; - -// Only poll while the tab is visible; a backgrounded tab does not need to -// surface background-error toasts, and re-checks immediately on return. -function pollIfVisible(): void { - if (document.visibilityState === "visible") void checkErrors(); -} - -function onVisibilityChange(): void { - if (document.visibilityState === "visible") void checkErrors(); -} - -async function checkErrors(): Promise { - const since = getLastSeenTs(); - try { - const res = await apiClient - .GET("/api/activity", { params: { query: { severity: ["error"], since } } }) - .then(unwrap); - if (res.items.length === 0) return; - - setLastSeenTs(res.items[0].timestamp); - - // Filter to only items not yet shown in this session. - const unseen = res.items.filter((item) => !seenIds.has(item.id)); - if (unseen.length === 0) return; - - const toShow = unseen.slice(0, 3); - for (const item of toShow) { - seenIds.add(item.id); - // Prefix with "Background:" so the user knows these come from the - // activity poller, not from an action they just triggered. - const msg = `Background: [${item.category}] ${item.message} (ref #${item.id})`; - showToast(msg, "error", 0); - } - if (unseen.length > 3) { - const remaining = unseen.slice(3); - for (const item of remaining) seenIds.add(item.id); - showToast(`${unseen.length - 3} more background errors, check the Activity log`, "error", 0); - } - } catch { /* non-blocking */ } -} - -export function startErrorToastPoller(): void { - if (pollTimer) return; - pollIfVisible(); - pollTimer = setInterval(pollIfVisible, 10_000); - document.addEventListener("visibilitychange", onVisibilityChange); -} - -export function stopErrorToastPoller(): void { - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } - document.removeEventListener("visibilitychange", onVisibilityChange); -} diff --git a/frontend/src/store/jobMonitor.test.ts b/frontend/src/store/jobMonitor.test.ts new file mode 100644 index 00000000..71853016 --- /dev/null +++ b/frontend/src/store/jobMonitor.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("./wbppCopyJob", () => ({ wbppCopyActiveJob: vi.fn(() => null) })); + +import { wbppCopyActiveJob } from "./wbppCopyJob"; +import { aggregateProgress, monitorJobs, stripProgress } from "./jobMonitor"; +import type { ActiveJob } from "../api/types"; + +const job = (progress?: number): ActiveJob => ({ + id: "x", + category: "scan", + label: "Job", + progress, + startedAt: 0, + cancelable: false, +}); + +describe("aggregateProgress", () => { + it("returns null for an empty job list", () => { + expect(aggregateProgress([])).toBeNull(); + }); + + it("returns null when every job is indeterminate", () => { + expect(aggregateProgress([job(), job()])).toBeNull(); + }); + + it("averages determinate jobs and ignores indeterminate ones", () => { + expect(aggregateProgress([job(0.2), job(0.6), job()])).toBeCloseTo(0.4, 5); + }); + + it("clamps to the [0, 1] range", () => { + expect(aggregateProgress([job(1.4)])).toBe(1); + }); +}); + +describe("monitorJobs", () => { + beforeEach(() => { + vi.mocked(wbppCopyActiveJob).mockReturnValue(null); + }); + + it("appends the WBPP copy job when one is running", () => { + vi.mocked(wbppCopyActiveJob).mockReturnValue({ + ...job(0.5), + id: "wbpp-copy", + category: "wbpp_copy", + }); + expect(monitorJobs().some((j) => j.id === "wbpp-copy")).toBe(true); + expect(stripProgress()).toBeCloseTo(0.5, 5); + }); + + it("omits the WBPP copy job when idle", () => { + expect(monitorJobs().some((j) => j.id === "wbpp-copy")).toBe(false); + }); +}); diff --git a/frontend/src/store/jobMonitor.ts b/frontend/src/store/jobMonitor.ts new file mode 100644 index 00000000..b6bc3e65 --- /dev/null +++ b/frontend/src/store/jobMonitor.ts @@ -0,0 +1,39 @@ +// Aggregation layer for the nav-bar job monitor: the existing activeJobs +// registry (scan + rebuild + Celery one-offs) plus the WBPP browser copy, +// reduced to the signals the strip/chip/panel render from. +import { activeJobs, wireActiveJobSources } from "./activeJobs"; +import { wbppCopyActiveJob } from "./wbppCopyJob"; +import { scanStatus, stopScan } from "./scan"; +import { rebuildStatus } from "./rebuild"; +import type { ActiveJob } from "../api/types"; + +let wired = false; + +// Global replacement for the wiring ScanManager used to do on mount: the +// monitor mounts on every page, so jobs are visible everywhere. Idempotent. +export function wireGlobalJobSources(): void { + if (wired) return; + wired = true; + wireActiveJobSources(scanStatus, rebuildStatus, stopScan); +} + +export const monitorJobs = (): ActiveJob[] => { + const jobs = [...activeJobs()]; + const copy = wbppCopyActiveJob(); + if (copy) jobs.push(copy); + return jobs; +}; + +export const hasMonitorJobs = (): boolean => monitorJobs().length > 0; + +// Aggregate strip progress: mean of the determinate jobs' fractions, or null +// when every running job is indeterminate (the strip renders a full-width +// shimmer instead of a bar in that case). +export function aggregateProgress(jobs: ActiveJob[]): number | null { + const determinate = jobs.filter((j) => j.progress !== undefined); + if (determinate.length === 0) return null; + const sum = determinate.reduce((a, j) => a + (j.progress as number), 0); + return Math.max(0, Math.min(1, sum / determinate.length)); +} + +export const stripProgress = (): number | null => aggregateProgress(monitorJobs()); From b8ffb4ada87474a1fe8700270ec158c2be7ed8d9 Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sat, 18 Jul 2026 14:33:31 -0500 Subject: [PATCH 09/23] fix(monitor): restore errorToastPoller.ts caught up in the jobMonitor commit The prior commit's staging accidentally swept in another in-progress change's already-staged deletion of this file. Restoring it here so that work stays untouched and can be committed on its own terms. --- frontend/src/App.tsx | 10 +- frontend/src/store/activityErrors.test.ts | 79 +++++++++++++++ frontend/src/store/activityErrors.ts | 116 ++++++++++++++++++++++ frontend/src/store/errorToastPoller.ts | 80 +++++++++++++++ 4 files changed, 280 insertions(+), 5 deletions(-) create mode 100644 frontend/src/store/activityErrors.test.ts create mode 100644 frontend/src/store/activityErrors.ts create mode 100644 frontend/src/store/errorToastPoller.ts 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/store/activityErrors.test.ts b/frontend/src/store/activityErrors.test.ts new file mode 100644 index 00000000..b3bd8354 --- /dev/null +++ b/frontend/src/store/activityErrors.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../api/generated/client", () => ({ + apiClient: { GET: vi.fn(), POST: vi.fn() }, +})); +vi.mock("../components/Toast", () => ({ showToast: vi.fn() })); + +import { apiClient } from "../api/generated/client"; +import { showToast } from "../components/Toast"; +import { + checkErrors, + errorEvents, + unseenErrorCount, + setActivitySeenAt, + markAllErrorsSeen, +} from "./activityErrors"; + +function okResult(data: T) { + return { data, error: undefined, response: { ok: true, status: 200 } as Response }; +} + +// Newest first, matching GET /api/activity ordering. +const items = [ + { id: 2, timestamp: "2026-07-18T12:10:00+00:00", severity: "error", category: "scan", event_type: "ingest_failed", message: "3 files failed to ingest" }, + { id: 1, timestamp: "2026-07-18T12:00:00+00:00", severity: "error", category: "enrichment", event_type: "simbad_timeout", message: "SIMBAD lookup timeout" }, +]; + +describe("activityErrors", () => { + beforeEach(() => { + vi.mocked(apiClient.GET).mockReset(); + vi.mocked(apiClient.POST).mockReset(); + vi.mocked(showToast).mockReset(); + localStorage.clear(); + }); + + it("stores fetched errors and counts all as unseen when activity_seen_at is null", async () => { + vi.mocked(apiClient.GET).mockResolvedValue(okResult({ items, next_cursor: null, total: 2 })); + setActivitySeenAt(null); + await checkErrors(); + expect(errorEvents().length).toBe(2); + expect(unseenErrorCount()).toBe(2); + expect(apiClient.GET).toHaveBeenCalledWith("/api/activity", { + params: { query: { severity: ["error"], limit: 20 } }, + }); + }); + + it("counts only errors newer than activity_seen_at", async () => { + vi.mocked(apiClient.GET).mockResolvedValue(okResult({ items, next_cursor: null, total: 2 })); + setActivitySeenAt("2026-07-18T12:05:00+00:00"); + await checkErrors(); + expect(unseenErrorCount()).toBe(1); + }); + + it("markAllErrorsSeen posts and advances the seen timestamp to clear the badge", async () => { + vi.mocked(apiClient.GET).mockResolvedValue(okResult({ items, next_cursor: null, total: 2 })); + vi.mocked(apiClient.POST).mockResolvedValue(okResult({ activity_seen_at: "2026-07-18T12:30:00+00:00" })); + setActivitySeenAt(null); + await checkErrors(); + await markAllErrorsSeen(); + expect(apiClient.POST).toHaveBeenCalledWith("/api/activity/seen", {}); + expect(unseenErrorCount()).toBe(0); + }); + + it("toasts new errors once and never re-toasts on the next poll", async () => { + // Fresh ids: the module-level session dedupe set persists across the + // tests in this file, so ids 1 and 2 were already consumed above. + const toastItems = [ + { id: 102, timestamp: "2026-07-18T13:10:00+00:00", severity: "error", category: "scan", event_type: "ingest_failed", message: "1 file failed to ingest" }, + { id: 101, timestamp: "2026-07-18T13:00:00+00:00", severity: "error", category: "mosaic", event_type: "composite_failed", message: "Composite failed" }, + ]; + vi.mocked(apiClient.GET).mockResolvedValue(okResult({ items: toastItems, next_cursor: null, total: 2 })); + await checkErrors(); + const toastsAfterFirst = vi.mocked(showToast).mock.calls.length; + expect(toastsAfterFirst).toBe(2); + expect(vi.mocked(showToast).mock.calls[0][0]).toContain("Background: [scan]"); + await checkErrors(); + expect(vi.mocked(showToast).mock.calls.length).toBe(toastsAfterFirst); + }); +}); diff --git a/frontend/src/store/activityErrors.ts b/frontend/src/store/activityErrors.ts new file mode 100644 index 00000000..33ad32af --- /dev/null +++ b/frontend/src/store/activityErrors.ts @@ -0,0 +1,116 @@ +// Shared activity-errors store: ONE 10s visibility-gated poll of +// GET /api/activity?severity=error serves both the background-error toasts +// (formerly store/errorToastPoller.ts, behavior unchanged) and the job +// monitor's recent-errors panel + unseen badge. +// +// Two independent "seen" notions live here on purpose: +// - Toast dedupe: localStorage last-toast timestamp + a session id set, +// exactly as the old poller did. Controls only whether a toast pops. +// - activitySeenAt: the SERVER-side per-user users.activity_seen_at, +// seeded from the auth "me" payload and advanced by markAllErrorsSeen(). +// Controls only the unseen badge count. +import { createSignal } from "solid-js"; +import { showToast } from "../components/Toast"; +import { apiClient } from "../api/generated/client"; +import { unwrap } from "../api/unwrap"; +import type { ActivityEvent } from "../api/types"; + +const LS_KEY = "galactilog_last_error_ts"; +const FETCH_LIMIT = 20; + +// Activity item IDs already surfaced as toasts in this browser session so the +// same error never pops again after the user has seen it (or dismissed it). +const seenIds = new Set(); + +const [errorEvents, setErrorEvents] = createSignal([]); +const [activitySeenAt, setActivitySeenAt] = createSignal(null); + +export { errorEvents, activitySeenAt, setActivitySeenAt }; + +export const unseenErrorCount = (): number => { + const seenAt = activitySeenAt(); + const seenMs = seenAt ? new Date(seenAt).getTime() : 0; + return errorEvents().filter((e) => new Date(e.timestamp).getTime() > seenMs).length; +}; + +export async function markAllErrorsSeen(): Promise { + try { + const res = await apiClient.POST("/api/activity/seen", {}).then(unwrap); + setActivitySeenAt(res.activity_seen_at); + } catch { + // Non-blocking: the badge simply stays until the next successful call. + } +} + +function getLastToastTs(): string { + try { + return localStorage.getItem(LS_KEY) ?? new Date(0).toISOString(); + } catch { + return new Date(0).toISOString(); + } +} + +function setLastToastTs(ts: string): void { + try { + localStorage.setItem(LS_KEY, ts); + } catch { /* ignore */ } +} + +let pollTimer: ReturnType | null = null; + +// Only poll while the tab is visible; a backgrounded tab does not need to +// surface background-error toasts, and re-checks immediately on return. +function pollIfVisible(): void { + if (document.visibilityState === "visible") void checkErrors(); +} + +function onVisibilityChange(): void { + if (document.visibilityState === "visible") void checkErrors(); +} + +export async function checkErrors(): Promise { + try { + const res = await apiClient + .GET("/api/activity", { params: { query: { severity: ["error"], limit: FETCH_LIMIT } } }) + .then(unwrap); + setErrorEvents(res.items); + if (res.items.length === 0) return; + + // Toast behavior unchanged from errorToastPoller: only items newer than + // the last toasted timestamp, minus those already toasted this session. + const lastMs = new Date(getLastToastTs()).getTime(); + const unseen = res.items.filter( + (item) => new Date(item.timestamp).getTime() > lastMs && !seenIds.has(item.id), + ); + if (unseen.length === 0) return; + setLastToastTs(res.items[0].timestamp); + + const toShow = unseen.slice(0, 3); + for (const item of toShow) { + seenIds.add(item.id); + // Prefix with "Background:" so the user knows these come from the + // activity poller, not from an action they just triggered. + const msg = `Background: [${item.category}] ${item.message} (ref #${item.id})`; + showToast(msg, "error", 0); + } + if (unseen.length > 3) { + for (const item of unseen.slice(3)) seenIds.add(item.id); + showToast(`${unseen.length - 3} more background errors, check the Activity log`, "error", 0); + } + } catch { /* non-blocking */ } +} + +export function startActivityErrorsPoller(): void { + if (pollTimer) return; + pollIfVisible(); + pollTimer = setInterval(pollIfVisible, 10_000); + document.addEventListener("visibilitychange", onVisibilityChange); +} + +export function stopActivityErrorsPoller(): void { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + document.removeEventListener("visibilitychange", onVisibilityChange); +} diff --git a/frontend/src/store/errorToastPoller.ts b/frontend/src/store/errorToastPoller.ts new file mode 100644 index 00000000..bdab4094 --- /dev/null +++ b/frontend/src/store/errorToastPoller.ts @@ -0,0 +1,80 @@ +import { showToast } from "../components/Toast"; +import { apiClient } from "../api/generated/client"; +import { unwrap } from "../api/unwrap"; + +const LS_KEY = "galactilog_last_error_ts"; + +// Tracks activity item IDs already surfaced as toasts in this browser session so +// the same error never pops again after the user has seen it (or dismissed it). +const seenIds = new Set(); + +function getLastSeenTs(): string { + try { + return localStorage.getItem(LS_KEY) ?? new Date(0).toISOString(); + } catch { + return new Date(0).toISOString(); + } +} + +function setLastSeenTs(ts: string): void { + try { + localStorage.setItem(LS_KEY, ts); + } catch { /* ignore */ } +} + +let pollTimer: ReturnType | null = null; + +// Only poll while the tab is visible; a backgrounded tab does not need to +// surface background-error toasts, and re-checks immediately on return. +function pollIfVisible(): void { + if (document.visibilityState === "visible") void checkErrors(); +} + +function onVisibilityChange(): void { + if (document.visibilityState === "visible") void checkErrors(); +} + +async function checkErrors(): Promise { + const since = getLastSeenTs(); + try { + const res = await apiClient + .GET("/api/activity", { params: { query: { severity: ["error"], since } } }) + .then(unwrap); + if (res.items.length === 0) return; + + setLastSeenTs(res.items[0].timestamp); + + // Filter to only items not yet shown in this session. + const unseen = res.items.filter((item) => !seenIds.has(item.id)); + if (unseen.length === 0) return; + + const toShow = unseen.slice(0, 3); + for (const item of toShow) { + seenIds.add(item.id); + // Prefix with "Background:" so the user knows these come from the + // activity poller, not from an action they just triggered. + const msg = `Background: [${item.category}] ${item.message} (ref #${item.id})`; + showToast(msg, "error", 0); + } + if (unseen.length > 3) { + const remaining = unseen.slice(3); + for (const item of remaining) seenIds.add(item.id); + showToast(`${unseen.length - 3} more background errors, check the Activity log`, "error", 0); + } + } catch { /* non-blocking */ } +} + +export function startErrorToastPoller(): void { + if (pollTimer) return; + pollIfVisible(); + pollTimer = setInterval(pollIfVisible, 10_000); + document.addEventListener("visibilitychange", onVisibilityChange); +} + +export function stopErrorToastPoller(): void { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + document.removeEventListener("visibilitychange", onVisibilityChange); +} From 4d70d75f7da61599b9ef856ca393409796dcefa2 Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sat, 18 Jul 2026 14:34:42 -0500 Subject: [PATCH 10/23] chore(monitor): remove errorToastPoller.ts, superseded by activityErrors store Task 4 (shared activity-errors store) landed the replacement in b8ffb4a but a concurrent commit from another in-progress task restored this file while untangling an unrelated staging collision. Removing it now that the replacement is confirmed in place and no code references it. --- frontend/src/store/errorToastPoller.ts | 80 -------------------------- 1 file changed, 80 deletions(-) delete mode 100644 frontend/src/store/errorToastPoller.ts diff --git a/frontend/src/store/errorToastPoller.ts b/frontend/src/store/errorToastPoller.ts deleted file mode 100644 index bdab4094..00000000 --- a/frontend/src/store/errorToastPoller.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { showToast } from "../components/Toast"; -import { apiClient } from "../api/generated/client"; -import { unwrap } from "../api/unwrap"; - -const LS_KEY = "galactilog_last_error_ts"; - -// Tracks activity item IDs already surfaced as toasts in this browser session so -// the same error never pops again after the user has seen it (or dismissed it). -const seenIds = new Set(); - -function getLastSeenTs(): string { - try { - return localStorage.getItem(LS_KEY) ?? new Date(0).toISOString(); - } catch { - return new Date(0).toISOString(); - } -} - -function setLastSeenTs(ts: string): void { - try { - localStorage.setItem(LS_KEY, ts); - } catch { /* ignore */ } -} - -let pollTimer: ReturnType | null = null; - -// Only poll while the tab is visible; a backgrounded tab does not need to -// surface background-error toasts, and re-checks immediately on return. -function pollIfVisible(): void { - if (document.visibilityState === "visible") void checkErrors(); -} - -function onVisibilityChange(): void { - if (document.visibilityState === "visible") void checkErrors(); -} - -async function checkErrors(): Promise { - const since = getLastSeenTs(); - try { - const res = await apiClient - .GET("/api/activity", { params: { query: { severity: ["error"], since } } }) - .then(unwrap); - if (res.items.length === 0) return; - - setLastSeenTs(res.items[0].timestamp); - - // Filter to only items not yet shown in this session. - const unseen = res.items.filter((item) => !seenIds.has(item.id)); - if (unseen.length === 0) return; - - const toShow = unseen.slice(0, 3); - for (const item of toShow) { - seenIds.add(item.id); - // Prefix with "Background:" so the user knows these come from the - // activity poller, not from an action they just triggered. - const msg = `Background: [${item.category}] ${item.message} (ref #${item.id})`; - showToast(msg, "error", 0); - } - if (unseen.length > 3) { - const remaining = unseen.slice(3); - for (const item of remaining) seenIds.add(item.id); - showToast(`${unseen.length - 3} more background errors, check the Activity log`, "error", 0); - } - } catch { /* non-blocking */ } -} - -export function startErrorToastPoller(): void { - if (pollTimer) return; - pollIfVisible(); - pollTimer = setInterval(pollIfVisible, 10_000); - document.addEventListener("visibilitychange", onVisibilityChange); -} - -export function stopErrorToastPoller(): void { - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } - document.removeEventListener("visibilitychange", onVisibilityChange); -} From aa29d68eec68030046dffb78d4720ef6650d97c5 Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sat, 18 Jul 2026 14:36:08 -0500 Subject: [PATCH 11/23] fix(wbpp): toast a rejected second copy start, cover reattach behavior startWbppCopy silently no-oped when a copy was already running, which would have been a dead end for future callers like the job monitor that have no pre-check of their own. It now toasts the same way a failure does, and state from the in-flight copy is proven to survive the rejected call. Also adds executable coverage for why the copy lives in a module-level store: a fresh mount reflects an already-running copy's live progress immediately, and the store keeps running independent of any component. --- .../src/components/WbppExportModal.test.tsx | 45 ++++++++++++++++++ frontend/src/store/wbppCopyJob.test.ts | 46 ++++++++++++++++++- frontend/src/store/wbppCopyJob.ts | 7 ++- 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/WbppExportModal.test.tsx b/frontend/src/components/WbppExportModal.test.tsx index 850d438f..850c2654 100644 --- a/frontend/src/components/WbppExportModal.test.tsx +++ b/frontend/src/components/WbppExportModal.test.tsx @@ -855,6 +855,51 @@ describe("WbppExportModal settings", () => { }); }); +describe("WbppExportModal reattach to a live copy", () => { + // The copy lives in the module-level wbppCopyJob store precisely so it + // survives the modal closing; a later remount (e.g. reopening the export + // dialog) must render the store's live state rather than starting fresh. + it("renders progress from the store immediately on mount, without the modal itself starting the copy", async () => { + let resolveCopy!: (r: { copied: number; destinationName: string }) => void; + postMock.mockClear(); + const wbppBrowserCopy = await import("../lib/wbppBrowserCopy"); + vi.spyOn(wbppBrowserCopy, "runBrowserCopy").mockImplementation( + ((_root: any, _dest: any, opts: any) => { + opts.onProgress(4, 8, "reattach.fits"); + return new Promise((res) => { + resolveCopy = res; + }); + }) as any, + ); + const { startWbppCopy, stopWbppCopy } = await import("../store/wbppCopyJob"); + + // Drive the store to "running" the same way a previous, now-unmounted + // instance of this modal would have, via startWbppCopy directly rather + // than through any component. + const copyPromise = startWbppCopy({ + rootHandle: {}, + destHandle: {}, + operations: [], + exclusions: [], + excludedSourceRelatives: [], + targetName: "M31", + }); + + // A fresh mount of the modal -- simulating "reopen" -- must reflect the + // in-flight copy immediately, with no click of its own required. + renderModal(); + expect(bodyText()).toMatch(/4|8/); + const bar = document.body.querySelector(".bg-theme-accent.transition-all") as HTMLElement; + expect(bar).not.toBe(null); + expect(bar.style.width).toBe("50%"); + + // Cleanup: stop the copy so it does not leak into later tests. + stopWbppCopy(); + resolveCopy({ copied: 4, destinationName: "d" }); + await copyPromise; + }); +}); + describe("WbppExportModal folder rows", () => { it("offers a manual preview only while no library root allows the auto-preview", () => { // With a root set the modal previews on mount; without one it must not fire diff --git a/frontend/src/store/wbppCopyJob.test.ts b/frontend/src/store/wbppCopyJob.test.ts index 2084586c..c206d8fa 100644 --- a/frontend/src/store/wbppCopyJob.test.ts +++ b/frontend/src/store/wbppCopyJob.test.ts @@ -93,18 +93,60 @@ describe("wbppCopyJob lifecycle", () => { expect(vi.mocked(showToast)).toHaveBeenCalled(); }); - it("ignores a second start while a copy is running", async () => { + it("ignores a second start while a copy is running, but toasts instead of failing silently", async () => { let resolveCopy!: (r: { copied: number; destinationName: string }) => void; vi.mocked(runBrowserCopy).mockImplementation( () => new Promise((res) => { resolveCopy = res; }), ); const p = startWbppCopy(args); - await startWbppCopy(args); + const doneBefore = wbppCopyDone(); + const totalBefore = wbppCopyTotal(); + + await startWbppCopy({ ...args, targetName: "Different Target" }); + expect(vi.mocked(runBrowserCopy)).toHaveBeenCalledTimes(1); + expect(vi.mocked(showToast)).toHaveBeenCalledWith( + expect.stringContaining("already running"), + "error", + 0, + ); + // State from the in-flight copy must survive the rejected second call, not + // be reset by it. + expect(wbppCopyRunning()).toBe(true); + expect(wbppCopyDone()).toBe(doneBefore); + expect(wbppCopyTotal()).toBe(totalBefore); + expect(wbppCopyActiveJob()?.label).toBe("WBPP copy: M31"); + resolveCopy({ copied: 0, destinationName: "d" }); await p; }); + it("keeps state alive independent of any component: reattach reads the same live store", async () => { + // Simulates the modal unmounting mid-copy and a later remount (or the job + // monitor, which never mounts the modal at all) reading the same + // module-level store -- there is nothing component-local to lose. + let resolveCopy!: (r: { copied: number; destinationName: string }) => void; + vi.mocked(runBrowserCopy).mockImplementation((_root, _dest, opts) => { + opts.onProgress(3, 9, "in progress"); + return new Promise((res) => { resolveCopy = res; }); + }); + + const p = startWbppCopy(args); + expect(wbppCopyRunning()).toBe(true); + + // "Unmount": nothing in the store references a component, so there is + // nothing to tear down. A later "reattach" is just reading the same + // accessors again. + expect(wbppCopyRunning()).toBe(true); + expect(wbppCopyDone()).toBe(3); + expect(wbppCopyTotal()).toBe(9); + expect(wbppCopyActiveJob()).not.toBeNull(); + + resolveCopy({ copied: 9, destinationName: "d" }); + await p; + expect(wbppCopyRunning()).toBe(false); + }); + it("stopWbppCopy aborts the in-flight signal", async () => { let capturedSignal: AbortSignal | undefined; vi.mocked(runBrowserCopy).mockImplementation((_r, _d, opts) => { diff --git a/frontend/src/store/wbppCopyJob.ts b/frontend/src/store/wbppCopyJob.ts index 8afea931..578f8a06 100644 --- a/frontend/src/store/wbppCopyJob.ts +++ b/frontend/src/store/wbppCopyJob.ts @@ -41,7 +41,12 @@ export interface StartWbppCopyArgs { } export async function startWbppCopy(args: StartWbppCopyArgs): Promise { - if (running()) return; + if (running()) { + // The modal has its own pre-check, but future callers (e.g. the job + // monitor) have no such guard, so a second start must not fail silently. + showToast("A WBPP copy is already running. Stop it first.", "error", 0); + return; + } setError(null); setFinished(null); setDone(0); From aa862741f0afedc526c61fefb051857450413b47 Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sat, 18 Jul 2026 14:41:10 -0500 Subject: [PATCH 12/23] feat(monitor): ambient nav-bar job monitor with progress strip, activity chip, and job-center panel --- frontend/src/components/JobMonitor.tsx | 255 ++++++++++++++++++++++++ frontend/src/components/NavBar.tsx | 2 + frontend/src/components/ScanManager.tsx | 3 - 3 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/JobMonitor.tsx diff --git a/frontend/src/components/JobMonitor.tsx b/frontend/src/components/JobMonitor.tsx new file mode 100644 index 00000000..8c0e073e --- /dev/null +++ b/frontend/src/components/JobMonitor.tsx @@ -0,0 +1,255 @@ +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(() => { + 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 */} + +