diff --git a/DESIGN.md b/DESIGN.md index 6bf071c25..b20775d52 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -16,7 +16,7 @@ This file collects architecture and design notes for studio that aren't otherwis - [`src/features/instance/config/roles/DESIGN.md`](src/features/instance/config/roles/DESIGN.md) — why the role editor's JSON text is the only source of truth, what forces the JSON view, and what Save changes on purpose. - [`src/router/DESIGN.md`](src/router/DESIGN.md) — why a failed route load is reported and shown only by its route, and why a preload still toasts. -- [`docs/status-redesign.md`](docs/status-redesign.md) — port of analytics-viz visualizations + spec system into the instance Status tab. Covers: 7-tab IA, the data-path adapter (no SQL — `get_analytics` only), bucket-by-window clamps, chart-surface CSS tokens, feature-flag rollback, testing strategy. Read before touching `src/features/instance/status/**`. +- [`docs/status-redesign.md`](docs/status-redesign.md) — port of analytics-viz visualizations + spec system into the instance Status tab. Covers: 8-tab IA, the data-path adapter (no SQL — `get_analytics` only), the Jobs tab's own poll and permission gate, bucket-by-window clamps, chart-surface CSS tokens, feature-flag rollback, testing strategy. Read before touching `src/features/instance/status/**`. ## API explorer (the instance/cluster "APIs" tab) diff --git a/docs/status-redesign.md b/docs/status-redesign.md index d68181ec0..ac193b136 100644 --- a/docs/status-redesign.md +++ b/docs/status-redesign.md @@ -4,9 +4,9 @@ The instance Status tab is a spec-driven analytics dashboard built on top of Har ## Tabs -`Health → Traffic → Requests → Database → Replication → Storage → Overview` (Health is the default landing tab). +`Health → Traffic → Requests → Database → Replication → Storage → Overview → Jobs` (Health is the default landing tab). -Tab + time-range state is mirrored to `?tab=&range=&refresh=` so URLs are shareable. Below `md` the tab strip collapses to a `Select`. The time-range picker is a sticky sub-toolbar inside each tab (hidden on Overview). +Tab + time-range state is mirrored to `?tab=&range=&refresh=&jobsRefresh=` so URLs are shareable. Below `md` the tab strip collapses to a `Select`. The time-range picker is a sticky sub-toolbar inside each chart tab (hidden on Overview and Jobs, which has its own toolbar). ## Data Path @@ -18,6 +18,18 @@ All analytics data flows through one query: - **Capability probe:** `useAnalyticsCapability` runs one cheap probe per instance on mount. While loading, the Status tab shows an inline message; on error, it surfaces an "analytics unavailable" message (no legacy fallback). - **Cap:** server responses larger than 50 000 rows are tail-truncated client-side with a console warning. +## Jobs tab + +Jobs lists Harper's background jobs (CSV/S3 loads, exports, bulk deletes, backups) from `search_jobs_by_start_date` — `src/features/instance/status/jobs/` plus `src/integrations/api/instance/status/searchJobs.ts`. It reads no `get_analytics`, so like Overview it sits in `ANALYTICS_FREE_TABS` in `StatusTabs.tsx`: the capability probe neither gates its body nor redirects a `?tab=jobs` deep link. + +- **Its own poll, not the window tick.** The analytics tick bakes `[start, end]` into every key; Jobs keys on the window _width_ and computes the bounds in the query function, polling with `pollUnlessForbidden(jobsRefresh)` so a 403 stops the timer. It shares the `range` search param and the option lists, but not `TimeRangePicker`, whose resolution sub-label and freshness stamp describe analytics queries only. The StatusTabs tick keeps running on Jobs on purpose: stopping it would leave the charts on a stale window when the user switches back. +- **Auto-refresh off by default, in its own param.** Each poll makes Harper run an unlimited `select * from system.hdb_job` over the window (harper `server/jobs/jobs.ts`), so Jobs fetches when it opens and on Refresh now, and polls only on an interval picked for it — not on refocus or reconnect either while off. That interval is `jobsRefresh`, not the charts' `refresh`: one value cannot default to 60s on the charts and to off on Jobs without a tab switch either starting Jobs polling or resetting the charts' choice. Every navigation carries both unchanged. Push updates are HarperFast/harper#3179. +- **Unbounded by the server.** The operation takes no limit or cursor, so every poll fetches the whole window. Jobs therefore offers windows up to 7 days (a `range=30d` link reads as 7 days there), and renders at most `MAX_RENDERED_JOBS` of the newest rows, saying how many it held back. +- **Per node.** `hdb_job` is a non-replicating system table (harper `resources/databases.ts`), so a cluster route lists the jobs of whichever node its request reached, and says so. +- **Super user only, by the role catalog's rule.** The operation is `requires_su`; `canListJobs` honours an allowlist grant only where the role editor's `gateVerdict` says a listing grants it for the instance's version band (5.3.0-beta.3 on), so the tab and the role editor never disagree. The version comes from the `registration_info` the instance route already loaded; if a refetch of it fails, a listed grant is tried rather than refused, and a 403 shows the same explanation. + +The import modal's CSV progress toast uses the same job records: `waitForJob` reports each poll through `onUpdate`, and the toast is settled from `useImportDataMutation`'s own callbacks, which still run after the modal unmounts. Harper writes nothing to a job between starting and finishing it, so the toast shows state and elapsed time, never a percentage. + ## Pipeline + Primitives Ported from the analytics-viz project. Key directories under `src/features/instance/status/analytics/`: diff --git a/src/features/instance/databases/functions/importProgressToast.test.ts b/src/features/instance/databases/functions/importProgressToast.test.ts new file mode 100644 index 000000000..58a7d3365 --- /dev/null +++ b/src/features/instance/databases/functions/importProgressToast.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createImportProgressToast, describeImportJob, IMPORT_PROGRESS_TITLE } from './importProgressToast'; + +const toast = vi.hoisted(() => ({ + loading: vi.fn((_title: string, data?: { id?: string | number }) => data?.id ?? 'toast-1'), + success: vi.fn(), + dismiss: vi.fn(), +})); + +vi.mock('sonner', () => ({ toast })); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('describeImportJob', () => { + it('reads a job no worker has picked up yet as queued', () => { + expect(describeImportJob({ id: 'j1', status: 'CREATED' }, 2_000)).toBe('Queued · 2s · job j1'); + }); + + it('reads any other unfinished job as running', () => { + expect(describeImportJob({ id: 'j1', status: 'IN_PROGRESS' }, 75_000)).toBe('Running · 1m 15s · job j1'); + }); +}); + +describe('createImportProgressToast', () => { + it('opens one loading toast on the first job update and keeps updating that same toast', () => { + let now = 1_000; + const progress = createImportProgressToast(() => now); + + now = 1_500; + progress.onJobUpdate({ id: 'j1', status: 'CREATED' }); + now = 4_000; + progress.onJobUpdate({ id: 'j1', status: 'IN_PROGRESS' }); + + expect(toast.loading).toHaveBeenNthCalledWith(1, IMPORT_PROGRESS_TITLE, { + id: undefined, + description: 'Queued · 500 ms · job j1', + }); + expect(toast.loading).toHaveBeenNthCalledWith(2, IMPORT_PROGRESS_TITLE, { + id: 'toast-1', + description: 'Running · 3s · job j1', + }); + }); + + it('turns the progress toast into the result on success', () => { + const progress = createImportProgressToast(() => 0); + progress.onJobUpdate({ id: 'j1', status: 'IN_PROGRESS' }); + progress.onSuccess({ message: 'successfully loaded 10 of 10 records' }); + + expect(toast.success).toHaveBeenCalledWith('successfully loaded 10 of 10 records', { + id: 'toast-1', + description: undefined, + }); + }); + + it('reports the result in a toast of its own when the import ran no job', () => { + const progress = createImportProgressToast(() => 0); + progress.onSuccess({ message: 'Inserted 3 records' }); + + expect(toast.loading).not.toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith('Inserted 3 records', { id: undefined, description: undefined }); + }); + + it('dismisses the progress toast on failure, leaving the error to the global handler', () => { + const progress = createImportProgressToast(() => 0); + progress.onJobUpdate({ id: 'j1', status: 'IN_PROGRESS' }); + progress.onError(); + + expect(toast.dismiss).toHaveBeenCalledWith('toast-1'); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('dismisses nothing on a failure that came before any job', () => { + createImportProgressToast(() => 0).onError(); + expect(toast.dismiss).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/instance/databases/functions/importProgressToast.ts b/src/features/instance/databases/functions/importProgressToast.ts new file mode 100644 index 000000000..4e1a4c35f --- /dev/null +++ b/src/features/instance/databases/functions/importProgressToast.ts @@ -0,0 +1,37 @@ +import type { HarperJob } from '@/integrations/api/instance/database/getJob'; +import type { ImportDataResult, ImportProgressListener } from '@/integrations/api/instance/database/importData'; +import { formatDuration } from '@/lib/formatDuration'; +import { toast } from 'sonner'; + +export const IMPORT_PROGRESS_TITLE = 'Importing data…'; + +/** + * The progress line for a polled load job. Harper writes nothing to a job between starting and + * finishing it, so its state and the time since submission are all there is to report. + */ +export function describeImportJob(job: HarperJob, elapsedMs: number): string { + const state = job.status?.toUpperCase() === 'CREATED' ? 'Queued' : 'Running'; + return `${state} · ${formatDuration(elapsedMs)} · job ${job.id}`; +} + +/** On failure the progress toast is only dismissed: the global mutation handler already toasts the error. */ +export function createImportProgressToast(now: () => number = Date.now): ImportProgressListener { + const submittedAt = now(); + let toastId: string | number | undefined; + return { + onJobUpdate(job: HarperJob) { + toastId = toast.loading(IMPORT_PROGRESS_TITLE, { + id: toastId, + description: describeImportJob(job, now() - submittedAt), + }); + }, + onSuccess({ message }: ImportDataResult) { + toast.success(message, { id: toastId, description: undefined }); + }, + onError() { + if (toastId !== undefined) { + toast.dismiss(toastId); + } + }, + }; +} diff --git a/src/features/instance/databases/modals/ImportDataModal.progress.test.tsx b/src/features/instance/databases/modals/ImportDataModal.progress.test.tsx new file mode 100644 index 000000000..9cce8dc48 --- /dev/null +++ b/src/features/instance/databases/modals/ImportDataModal.progress.test.tsx @@ -0,0 +1,82 @@ +/** + * @vitest-environment jsdom + */ +import { ImportDataModal } from '@/features/instance/databases/modals/ImportDataModal'; +import type { ImportDataParams } from '@/integrations/api/instance/database/importData'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mutate = vi.hoisted(() => vi.fn()); +const toast = vi.hoisted(() => ({ + error: vi.fn(), + loading: vi.fn(() => 'toast-1'), + success: vi.fn(), + dismiss: vi.fn(), +})); + +vi.mock('@tanstack/react-router', () => ({ + useParams: () => ({ instanceId: 'instance-1' }), +})); + +vi.mock('@/hooks/useAuth', () => ({ + useInstanceAuth: () => ({ user: { role: { permission: { super_user: true } } } }), + isAdminMode: () => false, + useCloudAuth: () => ({ user: null }), +})); + +vi.mock('@/config/useInstanceClient', () => ({ + useInstanceClientIdParams: () => ({ instanceClient: {}, entityId: 'entity-1', entityType: 'instance' }), +})); + +vi.mock('@/integrations/api/instance/database/importData', () => ({ + useImportDataMutation: () => ({ mutate, isPending: false }), +})); + +vi.mock('sonner', () => ({ toast })); + +vi.mock('@/features/instance/config/roles/operations/useOperationsAllowlistSupported', () => ({ + useOperationsAllowlistSupported: () => true, +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const withTable = { data: { dog: { attributes: [{ attribute: 'id', is_primary_key: true }] } } }; + +describe('ImportDataModal progress', () => { + it('hands the import a progress toast and leaves the success toast to it', async () => { + const onImported = vi.fn(); + render( + undefined} + instanceDatabaseMap={withTable as never} + databaseName="data" + tableName="dog" + initialMethod="url" + onImported={onImported} + />, + ); + + fireEvent.change(screen.getByPlaceholderText('https://example.com/data.csv'), { + target: { value: 'https://example.com/dogs.csv' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Import Data' })); + + await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1)); + const [variables, callbacks] = mutate.mock.calls[0] as [ + ImportDataParams, + { onSuccess: (result: { message: string }) => void }, + ]; + expect(variables.source).toEqual({ kind: 'csv-url', url: 'https://example.com/dogs.csv' }); + + variables.progress!.onJobUpdate({ id: 'job-1', status: 'IN_PROGRESS' }); + expect(toast.loading).toHaveBeenCalledTimes(1); + + callbacks.onSuccess({ message: 'loaded 5 of 5' }); + expect(onImported).toHaveBeenCalledWith('data', 'dog'); + expect(toast.success).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/instance/databases/modals/ImportDataModal.tsx b/src/features/instance/databases/modals/ImportDataModal.tsx index 829f70bd8..5ea32e843 100644 --- a/src/features/instance/databases/modals/ImportDataModal.tsx +++ b/src/features/instance/databases/modals/ImportDataModal.tsx @@ -31,6 +31,7 @@ import { generateRandomRecords, randomizableAttributes, } from '@/features/instance/databases/functions/generateRandomRecords'; +import { createImportProgressToast } from '@/features/instance/databases/functions/importProgressToast'; import { parseJsonRecords } from '@/features/instance/databases/functions/parseJsonRecords'; import { SchemaRelationshipMap } from '@/features/instance/databases/functions/schemaRelationships'; import { sampleDatasets } from '@/features/instance/databases/sampleDatasets'; @@ -312,11 +313,11 @@ export function ImportDataModal({ tableExists, replicated: instanceParams.entityType === 'cluster', source, + progress: createImportProgressToast(), ...instanceParams, }, { - onSuccess: ({ message }) => { - toast.success(message); + onSuccess: () => { closeModal(); onImported(database, table); }, diff --git a/src/features/instance/status/analytics/StatusTabs.tsx b/src/features/instance/status/analytics/StatusTabs.tsx index 4a9c35800..afd36b6b7 100644 --- a/src/features/instance/status/analytics/StatusTabs.tsx +++ b/src/features/instance/status/analytics/StatusTabs.tsx @@ -6,6 +6,7 @@ import { ANALYTICS_QUERY_KEY_PREFIX } from '@/integrations/api/instance/status/g import { useQueryClient } from '@tanstack/react-query'; import { useNavigate, useSearch } from '@tanstack/react-router'; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { JobsTab } from '../jobs/JobsTab'; import { type StatusTabId, validateStatusSearch } from '../statusSearch'; import { AnalyticsOnboardingHint } from './components/AnalyticsOnboardingHint'; import { TimeRangePicker } from './components/TimeRangePicker'; @@ -35,10 +36,14 @@ const TAB_DEFS = [ { id: 'replication', label: 'Replication' }, { id: 'storage', label: 'Storage' }, { id: 'overview', label: 'Overview' }, + { id: 'jobs', label: 'Jobs' }, ] as const satisfies readonly { id: StatusTabId; label: string }[]; type TabId = StatusTabId; +/** Tabs that read no `get_analytics` data, so the capability probe neither gates nor redirects them. */ +const ANALYTICS_FREE_TABS: ReadonlySet = new Set(['overview', 'jobs']); + export function StatusTabs({ instanceParams, isLocalStudio }: Props) { // The capability probe only gates the analytics-backed chart tabs — // Overview reads get_status / get_system_information and must stay @@ -106,7 +111,10 @@ function StatusTabsInner({ instanceParams, isLocalStudio, capability }: InnerPro // only narrows the `strict: false` typing to the same single source of // truth (statusSearch.ts) instead of hand-rolled casts. const raw = useSearch({ strict: false }); - const { tab, range: presetId, refresh: refreshMs } = validateStatusSearch(raw as Record); + // `jobsRefresh` is only Jobs' own interval; every navigation here passes it through unchanged. + const { tab, range: presetId, refresh: refreshMs, jobsRefresh } = validateStatusSearch( + raw as Record, + ); // The analytics window is a fixed [start, end] snapshot baked into every // panel's query key, so "refresh" means sliding the window forward to a @@ -155,16 +163,16 @@ function StatusTabsInner({ instanceParams, isLocalStudio, capability }: InnerPro }, [refreshMs, refresh, queryClient, tick, instanceParams.entityId]); const updatePreset = useCallback((id: TimePresetId) => { - void navigate({ to: '.', search: { tab, range: id, refresh: refreshMs } }); - }, [navigate, tab, refreshMs]); + void navigate({ to: '.', search: { tab, range: id, refresh: refreshMs, jobsRefresh } }); + }, [navigate, tab, refreshMs, jobsRefresh]); const updateTab = useCallback((id: TabId) => { - void navigate({ to: '.', search: { tab: id, range: presetId, refresh: refreshMs } }); - }, [navigate, presetId, refreshMs]); + void navigate({ to: '.', search: { tab: id, range: presetId, refresh: refreshMs, jobsRefresh } }); + }, [navigate, presetId, refreshMs, jobsRefresh]); const updateRefreshMs = useCallback((ms: number) => { - void navigate({ to: '.', search: { tab, range: presetId, refresh: ms } }); - }, [navigate, tab, presetId]); + void navigate({ to: '.', search: { tab, range: presetId, refresh: ms, jobsRefresh } }); + }, [navigate, tab, presetId, jobsRefresh]); // When the probe fails while a chart tab is selected, land on Overview so // the page opens on live data instead of an error. One-shot per failure — @@ -179,10 +187,14 @@ function StatusTabsInner({ instanceParams, isLocalStudio, capability }: InnerPro } if (landedOnOverviewRef.current) { return; } landedOnOverviewRef.current = true; - if (tab !== 'overview') { - void navigate({ to: '.', search: { tab: 'overview', range: presetId, refresh: refreshMs }, replace: true }); + if (!ANALYTICS_FREE_TABS.has(tab)) { + void navigate({ + to: '.', + search: { tab: 'overview', range: presetId, refresh: refreshMs, jobsRefresh }, + replace: true, + }); } - }, [capability.error, tab, navigate, presetId, refreshMs]); + }, [capability.error, tab, navigate, presetId, refreshMs, jobsRefresh]); // The window snapshot lives in its own memo keyed ONLY on the preset and // the refresh tick. Nothing else may mint a new window: it is baked into @@ -209,7 +221,7 @@ function StatusTabsInner({ instanceParams, isLocalStudio, capability }: InnerPro syncId: `${instanceParams.entityId}:${tab}`, }), [timeRange, bucketMs, instanceParams, tab]); - const showTimePicker = tab !== 'overview'; + const showTimePicker = !ANALYTICS_FREE_TABS.has(tab); const picker = showTimePicker ? ( updateTab(v as TabId)} className="px-4 py-2"> { - /* Hint applies to chart interactions; hide it on Overview which - has none of those affordances. */ + /* Hint applies to chart interactions; hide it on the tabs that + have none of those affordances. */ } - {tab !== 'overview' && !capability.isLoading && !capability.error && } + {!ANALYTICS_FREE_TABS.has(tab) && !capability.isLoading && !capability.error && } { /* * Tab strip layout: @@ -293,6 +305,9 @@ function StatusTabsInner({ instanceParams, isLocalStudio, capability }: InnerPro + + + ); diff --git a/src/features/instance/status/analytics/__tests__/StatusTabs.capability-gating.test.tsx b/src/features/instance/status/analytics/__tests__/StatusTabs.capability-gating.test.tsx index fae997316..ea9f5ac91 100644 --- a/src/features/instance/status/analytics/__tests__/StatusTabs.capability-gating.test.tsx +++ b/src/features/instance/status/analytics/__tests__/StatusTabs.capability-gating.test.tsx @@ -43,6 +43,10 @@ vi.mock('../tabs/OverviewTab', () => ({ OverviewTab: () =>
overview content
, })); +vi.mock('../../jobs/JobsTab', () => ({ + JobsTab: () =>
jobs content
, +})); + let currentSearch: Record = {}; const navigateMock = vi.fn(async (opts: { search?: unknown }) => { if (typeof opts.search === 'object' && opts.search !== null) { @@ -120,8 +124,8 @@ describe('StatusTabs capability gating (#1442)', () => { mount(); expect(screen.getByTestId('overview-content')).toBeTruthy(); - // The tab strip stays usable — all 7 tabs are present, not an error wall. - expect(screen.getAllByRole('tab')).toHaveLength(7); + // The tab strip stays usable — all 8 tabs are present, not an error wall. + expect(screen.getAllByRole('tab')).toHaveLength(8); // Already on Overview: no redirect needed. expect(navigateMock).not.toHaveBeenCalled(); }); @@ -135,7 +139,7 @@ describe('StatusTabs capability gating (#1442)', () => { isFetching: false, retry: vi.fn(), }; - currentSearch = { tab: 'health' }; + currentSearch = { tab: 'health', refresh: 300_000, jobsRefresh: 30_000 }; await act(async () => { mount(); }); @@ -143,7 +147,8 @@ describe('StatusTabs capability gating (#1442)', () => { const lastCall = navigateMock.mock.calls.at(-1)?.[0] as | { search?: Record; replace?: boolean } | undefined; - expect(lastCall?.search).toMatchObject({ tab: 'overview' }); + // Both auto-refresh choices survive the redirect. + expect(lastCall?.search).toEqual({ tab: 'overview', range: '1h', refresh: 300_000, jobsRefresh: 30_000 }); expect(lastCall?.replace).toBe(true); }); @@ -227,6 +232,34 @@ describe('StatusTabs capability gating (#1442)', () => { expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy(); }); + it('keeps a Jobs deep link on Jobs when the probe fails, since Jobs reads no analytics', async () => { + mockCapability = { + supported: false, + error: new Error('analytics exploded'), + isAuthError: false, + isLoading: false, + isFetching: false, + retry: vi.fn(), + }; + currentSearch = { tab: 'jobs' }; + await act(async () => { + mount(); + }); + + expect(screen.getByTestId('jobs-content')).toBeTruthy(); + expect(screen.queryByText('Analytics unavailable on this instance.')).toBeNull(); + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it('renders Jobs immediately while the probe is still loading', () => { + mockCapability = { supported: false, isAuthError: false, isLoading: true, isFetching: true, retry: vi.fn() }; + currentSearch = { tab: 'jobs' }; + mount(); + + expect(screen.getByTestId('jobs-content')).toBeTruthy(); + expect(screen.queryByText('Checking analytics availability…')).toBeNull(); + }); + it('renders Overview immediately while the probe is still loading', () => { mockCapability = { supported: false, isAuthError: false, isLoading: true, isFetching: true, retry: vi.fn() }; currentSearch = { tab: 'overview' }; diff --git a/src/features/instance/status/analytics/__tests__/StatusTabs.url-sync.test.tsx b/src/features/instance/status/analytics/__tests__/StatusTabs.url-sync.test.tsx index edbfce7a5..be9b1a19b 100644 --- a/src/features/instance/status/analytics/__tests__/StatusTabs.url-sync.test.tsx +++ b/src/features/instance/status/analytics/__tests__/StatusTabs.url-sync.test.tsx @@ -30,6 +30,10 @@ vi.mock('../hooks/useAnalyticsCapability', () => ({ }), })); +vi.mock('../../jobs/JobsTab', () => ({ + JobsTab: () =>
jobs content
, +})); + // Stub the TanStack Router hooks so we can drive search state in tests // without spinning up a full RouterProvider. let currentSearch: Record = {}; @@ -146,3 +150,46 @@ describe('StatusTabs URL sync', () => { expect(navigateMock).not.toHaveBeenCalled(); }); }); + +describe('StatusTabs auto-refresh params', () => { + const lastSearch = () => navigateMock.mock.lastCall?.[0].search; + + async function switchTo(name: string) { + const trigger = screen.getByRole('tab', { name }); + await act(async () => { + fireEvent.mouseDown(trigger, { button: 0 }); + }); + } + + it("opens Jobs with its own auto-refresh off rather than the charts' 60s", async () => { + mount(); + await switchTo('Jobs'); + expect(lastSearch()).toEqual({ tab: 'jobs', range: '1h', refresh: 60_000, jobsRefresh: 0 }); + }); + + it('carries both intervals across tab switches unchanged', async () => { + currentSearch = { tab: 'jobs', refresh: 0, jobsRefresh: 30_000 }; + mount(); + await switchTo('Health'); + expect(lastSearch()).toEqual({ tab: 'health', range: '1h', refresh: 0, jobsRefresh: 30_000 }); + }); + + async function pick(trigger: HTMLElement, option: string | RegExp) { + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' }); + fireEvent.click(await screen.findByRole('option', { name: option })); + } + + it("leaves Jobs' interval alone when the charts' range changes", async () => { + currentSearch = { tab: 'health', jobsRefresh: 30_000 }; + mount(); + await pick(screen.getAllByRole('combobox').find((el) => el.textContent?.includes('Last 1 hour'))!, /Last 6 hours/); + expect(lastSearch()).toEqual({ tab: 'health', range: '6h', refresh: 60_000, jobsRefresh: 30_000 }); + }); + + it("leaves Jobs' interval alone when the charts' interval changes", async () => { + currentSearch = { tab: 'health', jobsRefresh: 30_000 }; + mount(); + await pick(screen.getByRole('combobox', { name: 'Auto-refresh interval' }), '5m'); + expect(lastSearch()).toEqual({ tab: 'health', range: '1h', refresh: 300_000, jobsRefresh: 30_000 }); + }); +}); diff --git a/src/features/instance/status/jobs/JobsTab.test.tsx b/src/features/instance/status/jobs/JobsTab.test.tsx new file mode 100644 index 000000000..4b4ae80f8 --- /dev/null +++ b/src/features/instance/status/jobs/JobsTab.test.tsx @@ -0,0 +1,322 @@ +// @vitest-environment jsdom +import type { LocalRolePermission } from '@/integrations/api/api.patch'; +import { focusManager, onlineManager, QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { AxiosError } from 'axios'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { JobsTab, MAX_RENDERED_JOBS } from './JobsTab'; + +const permission = vi.hoisted(() => ({ current: undefined as LocalRolePermission | undefined })); +const navigateMock = vi.hoisted(() => vi.fn()); +const search = vi.hoisted(() => ({ current: {} as Record })); + +vi.mock('@/hooks/useAuth', () => ({ + useInstanceAuth: () => ({ user: permission.current ? { role: { permission: permission.current } } : null }), +})); + +vi.mock('@tanstack/react-router', () => ({ + useSearch: () => search.current, + useNavigate: () => navigateMock, +})); + +const superUser = { super_user: true } as unknown as LocalRolePermission; + +function renderTab( + post: (url: string, body: Record) => Promise, + entityType: 'instance' | 'cluster' = 'instance', +) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const instanceParams = { instanceClient: { post } as never, entityId: 'ins-1' as never, entityType }; + render( + + + , + ); +} + +function forbidden(): AxiosError { + const err = new AxiosError('Request failed with status code 403'); + err.response = { status: 403 } as AxiosError['response']; + return err; +} + +beforeAll(() => { + // Radix Select relies on DOM APIs jsdom doesn't implement. + Element.prototype.hasPointerCapture ??= () => false; + Element.prototype.setPointerCapture ??= () => undefined; + Element.prototype.releasePointerCapture ??= () => undefined; + Element.prototype.scrollIntoView ??= () => undefined; +}); + +beforeEach(() => { + search.current = { tab: 'jobs', range: '24h' }; +}); + +afterEach(() => { + cleanup(); + permission.current = undefined; + navigateMock.mockClear(); +}); + +describe('JobsTab', () => { + it('lists the jobs Harper returns, newest first', async () => { + permission.current = superUser; + renderTab(async () => ({ + data: [ + { id: 'job-old', type: 'csv_url_load', status: 'COMPLETE', start_datetime: 1_000, end_datetime: 13_000 }, + { id: 'job-new', type: 'export_local', status: 'IN_PROGRESS', start_datetime: 50_000, user: 'admin' }, + ], + })); + + const rows = await screen.findAllByRole('row'); + expect(rows).toHaveLength(3); + expect(within(rows[1]).getByText('export_local')).toBeTruthy(); + expect(within(rows[1]).getByText('Running')).toBeTruthy(); + expect(within(rows[2]).getByText('csv_url_load')).toBeTruthy(); + expect(within(rows[2]).getByText('Complete')).toBeTruthy(); + expect(within(rows[2]).getByText('12s')).toBeTruthy(); + }); + + it('expands a row to show the full record, rendering a structured message as text', async () => { + permission.current = superUser; + const report = { error: 'not authorized', unauthorized_access: [{ table: 'dog' }] }; + renderTab(async () => ({ + data: [{ id: 'job-1', type: 'csv_data_load', status: 'ERROR', message: report, result: { inserted: 0 } }], + })); + + const toggle = await screen.findByRole('button', { name: 'Show details for job job-1' }); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + fireEvent.click(toggle); + + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + const details = document.getElementById(toggle.getAttribute('aria-controls')!)!; + expect(within(details).getByText(JSON.stringify(report))).toBeTruthy(); + expect(within(details).getByText(/"inserted": 0/)).toBeTruthy(); + expect(screen.queryByText('[object Object]')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Hide details for job job-1' })); + expect(document.getElementById(toggle.getAttribute('aria-controls')!)).toBeNull(); + }); + + it('leaves a row as it is when a click ends a text selection inside it', async () => { + permission.current = superUser; + renderTab(async () => ({ data: [{ id: 'job-1', type: 'csv_data_load', status: 'COMPLETE', user: 'admin' }] })); + + const toggle = await screen.findByRole('button', { name: 'Show details for job job-1' }); + const row = toggle.closest('tr')!; + const select = (node: Node) => { + const range = document.createRange(); + range.selectNodeContents(node); + window.getSelection()!.removeAllRanges(); + window.getSelection()!.addRange(range); + }; + + select(within(row).getByText('admin')); + fireEvent.click(row); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + + // A selection elsewhere on the page is not this row's drag, so the click still toggles. + select(screen.getByText('Jobs')); + fireEvent.click(row); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + window.getSelection()!.removeAllRanges(); + }); + + it('never asks Harper when the role certainly cannot list jobs', () => { + permission.current = { super_user: false } as unknown as LocalRolePermission; + const post = vi.fn(async () => ({ data: [] })); + renderTab(post); + + expect(screen.getByText("Your role can't list jobs on this instance.")).toBeTruthy(); + expect(screen.queryByRole('group', { name: 'Auto-refresh' })).toBeNull(); + expect(post).not.toHaveBeenCalled(); + }); + + describe('a role whose operations allowlist lists search_jobs_by_start_date', () => { + const listing = { operations: ['read_only', 'search_jobs_by_start_date'] } as unknown as LocalRolePermission; + + function answer(version: string | Error) { + return vi.fn(async (_url: string, body: Record) => { + if (body.operation === 'registration_info') { + if (version instanceof Error) { + throw version; + } + return { data: { version } }; + } + return { data: [{ id: 'job-1', type: 'csv_url_load', status: 'COMPLETE', start_datetime: 1_000 }] }; + }); + } + + const searched = (post: ReturnType) => + post.mock.calls.filter(([, body]) => body.operation === 'search_jobs_by_start_date'); + + it('lists jobs on Harper 5.3, which honours the grant', async () => { + permission.current = listing; + const post = answer('5.3.2'); + renderTab(post); + + expect(await screen.findByText('csv_url_load')).toBeTruthy(); + expect(searched(post)).toHaveLength(1); + }); + + it('explains, without asking for jobs, on a version that ignores the grant', async () => { + permission.current = listing; + const post = answer('5.2.9'); + renderTab(post); + + expect(await screen.findByText("Your role can't list jobs on this instance.")).toBeTruthy(); + expect(searched(post)).toHaveLength(0); + }); + + it('tries the grant when reading the version fails', async () => { + permission.current = listing; + const post = answer(forbidden()); + renderTab(post); + + expect(await screen.findByText('csv_url_load')).toBeTruthy(); + }); + }); + + it('waits for the role to load before asking Harper', () => { + const post = vi.fn(async () => ({ data: [] })); + renderTab(post); + + expect(screen.getByRole('status', { name: 'Loading' })).toBeTruthy(); + expect(post).not.toHaveBeenCalled(); + }); + + it('explains a 403 instead of offering a retry that cannot succeed', async () => { + permission.current = superUser; + renderTab(async () => { + throw forbidden(); + }); + + expect(await screen.findByText("Your role can't list jobs on this instance.")).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull(); + }); + + it('says so when no jobs started in the window', async () => { + permission.current = superUser; + renderTab(async () => ({ data: [] })); + + expect(await screen.findByText('No jobs started in the selected time range.')).toBeTruthy(); + }); + + it('renders at most the most recent jobs of a long window, and says how many there were', async () => { + permission.current = superUser; + const total = MAX_RENDERED_JOBS + 5; + renderTab(async () => ({ + data: Array.from({ length: total }, (_, i) => ({ id: `job-${i}`, status: 'COMPLETE', start_datetime: i })), + })); + + await waitFor(() => expect(screen.getAllByRole('row')).toHaveLength(MAX_RENDERED_JOBS + 1)); + expect(screen.getByText(`Showing the ${MAX_RENDERED_JOBS} most recent of ${total} jobs in this range.`)) + .toBeTruthy(); + expect(screen.getByRole('button', { name: `Show details for job job-${total - 1}` })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Show details for job job-0' })).toBeNull(); + }); + + it('tells a cluster view that job records are per node', async () => { + permission.current = superUser; + renderTab(async () => ({ data: [] }), 'cluster'); + + expect(await screen.findByText(/Job records stay on the node that ran them/)).toBeTruthy(); + }); + + it('reads a window wider than 7 days as 7 days, since every poll fetches the whole window', async () => { + permission.current = superUser; + search.current = { tab: 'jobs', range: '30d' }; + const post = vi.fn(async (_url: string, _body: Record) => ({ data: [] })); + renderTab(post); + await screen.findByText('No jobs started in the selected time range.'); + + const { from_date, to_date } = post.mock.calls[0][1] as { from_date: string; to_date: string }; + const spanMs = Date.parse(to_date) - Date.parse(from_date); + expect(spanMs).toBe(7 * 24 * 60 * 60 * 1000 + 24 * 60 * 60 * 1000); + expect(screen.getByRole('combobox', { name: 'Jobs started within' }).textContent).toBe('Last 7 days'); + }); + + it('refetches on demand', async () => { + permission.current = superUser; + const post = vi.fn(async () => ({ data: [] })); + renderTab(post); + await screen.findByText('No jobs started in the selected time range.'); + expect(post).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', { name: 'Refresh now' })); + await waitFor(() => expect(post).toHaveBeenCalledTimes(2)); + }); + + describe('auto-refresh', () => { + const interval = () => screen.getByRole('combobox', { name: 'Auto-refresh interval' }); + + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + focusManager.setFocused(undefined); + onlineManager.setOnline(true); + }); + + async function openWith(searchParams: Record) { + permission.current = superUser; + search.current = { tab: 'jobs', range: '24h', ...searchParams }; + const post = vi.fn(async () => ({ data: [] })); + renderTab(post); + await screen.findByText('No jobs started in the selected time range.'); + return post; + } + + async function wait(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); + } + + it('fetches once when Jobs opens and then only when asked', async () => { + const post = await openWith({}); + expect(interval().textContent).toBe('Off'); + + await wait(10 * 60_000); + // Long past staleTime, so a refocus or reconnect would refetch if either were allowed to. + act(() => { + focusManager.setFocused(false); + focusManager.setFocused(true); + onlineManager.setOnline(false); + onlineManager.setOnline(true); + }); + await wait(0); + expect(post).toHaveBeenCalledTimes(1); + }); + + it("does not poll on the charts' auto-refresh interval", async () => { + const post = await openWith({ refresh: 30_000 }); + expect(interval().textContent).toBe('Off'); + + await wait(10 * 60_000); + expect(post).toHaveBeenCalledTimes(1); + }); + + it('polls on an interval chosen for Jobs', async () => { + const post = await openWith({ refresh: 0, jobsRefresh: 30_000 }); + expect(interval().textContent).toBe('30s'); + + await wait(30_000); + await waitFor(() => expect(post).toHaveBeenCalledTimes(2)); + }); + + it("records a picked interval as Jobs' own, leaving the charts' interval as it was", async () => { + await openWith({ refresh: 300_000 }); + + fireEvent.pointerDown(interval(), { button: 0, ctrlKey: false, pointerType: 'mouse' }); + fireEvent.click(await screen.findByRole('option', { name: '30s' })); + + expect(navigateMock).toHaveBeenLastCalledWith({ + to: '.', + search: { tab: 'jobs', range: '24h', refresh: 300_000, jobsRefresh: 30_000 }, + }); + }); + }); +}); diff --git a/src/features/instance/status/jobs/JobsTab.tsx b/src/features/instance/status/jobs/JobsTab.tsx new file mode 100644 index 000000000..5d9feaa6e --- /dev/null +++ b/src/features/instance/status/jobs/JobsTab.tsx @@ -0,0 +1,320 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig'; +import { useInstanceAuth } from '@/hooks/useAuth'; +import { getRegistrationInfoQueryOptions } from '@/integrations/api/instance/status/getRegistrationInfo'; +import { getSearchJobsQueryOptions, type JobRecord } from '@/integrations/api/instance/status/searchJobs'; +import { cn } from '@/lib/cn'; +import { errorText } from '@/lib/errorText'; +import { formatDuration } from '@/lib/formatDuration'; +import { isForbiddenError } from '@/react-query/pollUnlessForbidden'; +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; +import { useNavigate, useSearch } from '@tanstack/react-router'; +import { ChevronRightIcon, RefreshCw } from 'lucide-react'; +import { Fragment, useState } from 'react'; +import { getPreset, REFRESH_OPTIONS, TIME_PRESETS, type TimePresetId } from '../analytics/context/timePresets'; +import { PanelErrorBoundary } from '../analytics/tabs/PanelErrorBoundary'; +import { PanelStateOrChart } from '../analytics/tabs/PanelShell'; +import { validateStatusSearch } from '../statusSearch'; +import { canListJobs, listsJobsGrant } from './canListJobs'; +import { describeJobStatus, formatJobTime, jobDurationMs } from './jobFormat'; + +interface Props { + instanceParams: InstanceClientIdConfig & InstanceTypeConfig; +} + +/** + * `search_jobs_by_start_date` takes no limit, so every poll fetches the whole window: Jobs stops at the + * 7-day preset rather than re-fetching a month of history, and renders only the newest rows of it. + */ +const JOB_WINDOW_PRESETS = TIME_PRESETS.filter((preset) => preset.durationMs <= getPreset('7d').durationMs); +export const MAX_RENDERED_JOBS = 200; + +function jobWindow(range: TimePresetId): TimePresetId { + return JOB_WINDOW_PRESETS.some((preset) => preset.id === range) ? range : JOB_WINDOW_PRESETS.at(-1)!.id; +} + +export function JobsTab({ instanceParams }: Props) { + const navigate = useNavigate(); + const search = validateStatusSearch(useSearch({ strict: false }) as Record); + const { user } = useInstanceAuth(instanceParams.entityId); + const permission = user?.role?.permission; + // Only a listed grant depends on the version, which the instance route has already loaded. + const registrationInfo = useQuery({ + ...getRegistrationInfoQueryOptions(instanceParams), + enabled: !!permission && permission.super_user !== true && listsJobsGrant(permission), + }); + const version = registrationInfo.data + ? registrationInfo.data.version || null + : registrationInfo.isError + ? null + : undefined; + const allowed = canListJobs(permission, version); + const range = jobWindow(search.range); + const windowMs = getPreset(range).durationMs; + + // `jobsRefresh`, not the charts' `refresh`: Jobs polls only on an interval chosen here (see statusSearch.ts). + const query = useQuery(getSearchJobsQueryOptions({ + ...instanceParams, + windowMs, + refreshMs: search.jobsRefresh, + enabled: allowed === true, + })); + + const setRange = (range: TimePresetId) => void navigate({ to: '.', search: { ...search, range } }); + const setRefresh = (jobsRefresh: number) => void navigate({ to: '.', search: { ...search, jobsRefresh } }); + + return ( + + +
+ Jobs + + Operations Harper runs in the background — CSV and S3 loads, exports, bulk deletes, backups — started in the + selected window, newest first. + {instanceParams.entityType === 'cluster' + && " Job records stay on the node that ran them, so on a cluster this lists the jobs of the node Studio's request reached; open an instance's Status page for a specific node."} + +
+ {allowed && ( + void query.refetch()} + isFetching={query.isFetching} + /> + )} +
+ + + +
+ ); +} + +function JobsBody({ allowed, query }: { allowed: boolean | undefined; query: UseQueryResult }) { + if (allowed === undefined) { + return
; + } + if (!allowed || isForbiddenError(query.error)) { + return ( +
+

Your role can't list jobs on this instance.

+

+ Listing jobs needs a super user role. Harper reserves search_jobs_by_start_date{' '} + for super users unless an operations allowlist grants it, which Harper honours from 5.3. +

+
+ ); + } + const jobs = query.data ?? []; + return ( + void query.refetch()} + > + + + + {jobs.length > MAX_RENDERED_JOBS && ( +

+ Showing the {MAX_RENDERED_JOBS} most recent of {jobs.length.toLocaleString()} jobs in this range. +

+ )} +
+ ); +} + +/** Drag-selecting a cell's text still ends in a click on the row, which must not toggle it. */ +function isSelectingTextIn(element: Element): boolean { + const selection = window.getSelection(); + return !!selection && !selection.isCollapsed + && (element.contains(selection.anchorNode) || element.contains(selection.focusNode)); +} + +function JobsTable({ jobs }: { jobs: JobRecord[] }) { + const [expanded, setExpanded] = useState>(new Set()); + const toggle = (id: string) => + setExpanded((current) => { + const next = new Set(current); + if (!next.delete(id)) { + next.add(id); + } + return next; + }); + + return ( + + + + + Details + + Status + Type + Started + Duration + User + Message + + + + {jobs.map((job) => { + const isOpen = expanded.has(job.id); + const status = describeJobStatus(job.status); + const duration = jobDurationMs(job); + const message = errorText(job.message); + const detailsId = `job-details-${job.id}`; + return ( + + { + if (!isSelectingTextIn(event.currentTarget)) { + toggle(job.id); + } + }} + > + + + + + {status.label} + + {job.type ?? '—'} + {formatJobTime(job.start_datetime)} + + {duration === undefined ? '—' : formatDuration(duration)} + + {job.user ?? '—'} + {message ?? '—'} + + {isOpen && ( + + + + + + )} + + ); + })} + +
+ ); +} + +function JobDetails({ job, message }: { job: JobRecord; message: string | undefined }) { + return ( +
+
+
Job ID
+
{job.id}
+
Created
+
{formatJobTime(job.created_datetime)}
+
Started
+
{formatJobTime(job.start_datetime)}
+
Ended
+
{formatJobTime(job.end_datetime)}
+
+ {message && ( +
+
Message
+
+						{message}
+					
+
+ )} + {job.result !== undefined && ( +
+
Result
+
+						{typeof job.result === 'string' ? job.result : JSON.stringify(job.result, null, 2)}
+					
+
+ )} +
+ ); +} + +interface ToolbarProps { + range: TimePresetId; + onRangeChange: (range: TimePresetId) => void; + refreshMs: number; + onRefreshChange: (refreshMs: number) => void; + onRefreshNow: () => void; + isFetching: boolean; +} + +function JobsToolbar({ range, onRangeChange, refreshMs, onRefreshChange, onRefreshNow, isFetching }: ToolbarProps) { + return ( +
+ +
+ +
+
+ ); +} diff --git a/src/features/instance/status/jobs/canListJobs.test.ts b/src/features/instance/status/jobs/canListJobs.test.ts new file mode 100644 index 000000000..0594fab1a --- /dev/null +++ b/src/features/instance/status/jobs/canListJobs.test.ts @@ -0,0 +1,51 @@ +import type { LocalRolePermission } from '@/integrations/api/api.patch'; +import { describe, expect, it } from 'vitest'; +import { canListJobs } from './canListJobs'; + +const perm = (value: Record) => value as unknown as LocalRolePermission; +const listing = perm({ operations: ['read_only', 'search_jobs_by_start_date'] }); + +describe('canListJobs', () => { + it('is undecided while the permission is still loading', () => { + expect(canListJobs(undefined, '5.3.2')).toBeUndefined(); + }); + + it('allows a super user, whatever the version', () => { + expect(canListJobs(perm({ super_user: true }), undefined)).toBe(true); + expect(canListJobs(perm({ super_user: true }), '5.2.9')).toBe(true); + }); + + it('denies a role with neither super_user nor an allowlist', () => { + expect(canListJobs(perm({ super_user: false, data: { tables: {} } }), '5.3.2')).toBe(false); + }); + + it('denies an allowlist that does not list it', () => { + expect(canListJobs(perm({ operations: ['read_only'] }), '5.3.2')).toBe(false); + }); + + it('denies an absent or malformed allowlist instead of reading it as unrestricted', () => { + expect(canListJobs(perm({}), '5.3.2')).toBe(false); + expect(canListJobs(perm({ operations: 'search_jobs_by_start_date' }), '5.3.2')).toBe(false); + expect(canListJobs(perm({ operations: { tables: {} } }), '5.3.2')).toBe(false); + }); + + describe('an allowlist that lists it', () => { + it('is allowed from 5.3.0-beta.3, where Harper first honours the grant', () => { + expect(canListJobs(listing, '5.3.0-beta.3')).toBe(true); + expect(canListJobs(listing, '5.3.2')).toBe(true); + }); + + it('is denied on a version whose gate cannot match it', () => { + expect(canListJobs(listing, '5.2.9')).toBe(false); + expect(canListJobs(listing, '5.3.0-beta.2')).toBe(false); + }); + + it('waits for the version', () => { + expect(canListJobs(listing, undefined)).toBeUndefined(); + }); + + it('is tried when the version cannot be read, rather than refused for good', () => { + expect(canListJobs(listing, null)).toBe(true); + }); + }); +}); diff --git a/src/features/instance/status/jobs/canListJobs.ts b/src/features/instance/status/jobs/canListJobs.ts new file mode 100644 index 000000000..4e2ed3f04 --- /dev/null +++ b/src/features/instance/status/jobs/canListJobs.ts @@ -0,0 +1,39 @@ +import { gateVerdict, resolveGrantBand } from '@/features/instance/config/roles/operations/operationsCatalog'; +import { checkAnyOperationAllowed } from '@/hooks/checkOperationPermission'; +import type { LocalRolePermission } from '@/integrations/api/api.patch'; +import { SEARCH_JOBS_OPERATION } from '@/integrations/api/instance/status/searchJobs'; +import { getOperationsAllowlist } from '@/integrations/api/localRolePermission'; + +/** Whether a role's operations allowlist lists the operation, which delegates it only on some Harper versions. */ +export function listsJobsGrant(permission: LocalRolePermission): boolean { + return getOperationsAllowlist(permission) !== undefined + && checkAnyOperationAllowed(permission, [SEARCH_JOBS_OPERATION]); +} + +/** + * Whether this role may list jobs, or `undefined` while its permission or the instance version is still loading. + * + * Harper registers `search_jobs_by_start_date` as super_user-only, and a listing delegates it only where the role + * editor's gate says it grants: 5.3.0-beta.3 on, where its authorization entry is first named (harper + * `utility/operation_authorization.ts`, unnamed at v5.2.9:373, named at v5.3.2:433). `version` is `null` when a + * read failed; the instance route has already loaded it once, so that is a failed refetch, not a refusal, and a + * listed grant is tried, since a refusal of the jobs request shows as one. + */ +export function canListJobs( + permission: LocalRolePermission | undefined, + version: string | null | undefined, +): boolean | undefined { + if (!permission) { + return undefined; + } + if (permission.super_user === true) { + return true; + } + if (!listsJobsGrant(permission)) { + return false; + } + if (version === undefined) { + return undefined; + } + return version === null || gateVerdict(SEARCH_JOBS_OPERATION, resolveGrantBand(version)) === 'grants'; +} diff --git a/src/features/instance/status/jobs/jobFormat.test.ts b/src/features/instance/status/jobs/jobFormat.test.ts new file mode 100644 index 000000000..3aede7909 --- /dev/null +++ b/src/features/instance/status/jobs/jobFormat.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { describeJobStatus, formatJobTime, jobDurationMs } from './jobFormat'; + +describe('describeJobStatus', () => { + it.each([ + ['CREATED', 'Queued', 'secondary'], + ['IN_PROGRESS', 'Running', 'default'], + ['COMPLETE', 'Complete', 'success'], + ['ERROR', 'Failed', 'destructive'], + ['complete', 'Complete', 'success'], + ])('labels %s as %s', (status, label, variant) => { + expect(describeJobStatus(status)).toEqual({ label, variant }); + }); + + it('shows an unrecognized status verbatim', () => { + expect(describeJobStatus('PAUSED')).toEqual({ label: 'PAUSED', variant: 'outline' }); + expect(describeJobStatus(undefined)).toEqual({ label: 'Unknown', variant: 'outline' }); + }); +}); + +describe('jobDurationMs', () => { + it('measures a finished job from start to end', () => { + expect(jobDurationMs({ status: 'COMPLETE', start_datetime: 1_000, end_datetime: 4_500 })).toBe(3_500); + expect(jobDurationMs({ status: 'ERROR', start_datetime: 1_000, end_datetime: 1_200 })).toBe(200); + }); + + it('has no duration while the job is still queued or running', () => { + expect(jobDurationMs({ status: 'IN_PROGRESS', start_datetime: 1_000 })).toBeUndefined(); + expect(jobDurationMs({ status: 'CREATED', start_datetime: 1_000, end_datetime: 2_000 })).toBeUndefined(); + }); + + it('has no duration when a timestamp is missing', () => { + expect(jobDurationMs({ status: 'COMPLETE', end_datetime: 2_000 })).toBeUndefined(); + }); +}); + +describe('formatJobTime', () => { + it('shows a dash for a missing timestamp', () => { + expect(formatJobTime(undefined)).toBe('—'); + }); + + it('formats a timestamp in the local time zone', () => { + expect(formatJobTime(0)).toBe(new Date(0).toLocaleString()); + }); +}); diff --git a/src/features/instance/status/jobs/jobFormat.ts b/src/features/instance/status/jobs/jobFormat.ts new file mode 100644 index 000000000..4a7e5dd18 --- /dev/null +++ b/src/features/instance/status/jobs/jobFormat.ts @@ -0,0 +1,34 @@ +import type { badgeVariants } from '@/components/ui/badgeVariants'; +import type { VariantProps } from 'class-variance-authority'; + +type BadgeVariant = NonNullable['variant']>; + +const STATUS_DISPLAY: Record = { + CREATED: { label: 'Queued', variant: 'secondary' }, + IN_PROGRESS: { label: 'Running', variant: 'default' }, + COMPLETE: { label: 'Complete', variant: 'success' }, + ERROR: { label: 'Failed', variant: 'destructive' }, +}; + +export function describeJobStatus(status: string | undefined): { label: string; variant: BadgeVariant } { + const known = status ? STATUS_DISPLAY[status.toUpperCase()] : undefined; + return known ?? { label: status || 'Unknown', variant: 'outline' }; +} + +function isJobFinished(status: string | undefined): boolean { + const normalized = status?.toUpperCase(); + return normalized === 'COMPLETE' || normalized === 'ERROR'; +} + +export function jobDurationMs( + job: { status?: string; start_datetime?: number; end_datetime?: number }, +): number | undefined { + if (!isJobFinished(job.status) || typeof job.start_datetime !== 'number' || typeof job.end_datetime !== 'number') { + return undefined; + } + return job.end_datetime - job.start_datetime; +} + +export function formatJobTime(ms: number | undefined): string { + return typeof ms === 'number' && Number.isFinite(ms) ? new Date(ms).toLocaleString() : '—'; +} diff --git a/src/features/instance/status/statusSearch.test.ts b/src/features/instance/status/statusSearch.test.ts index 22924838c..7ed6bb2f1 100644 --- a/src/features/instance/status/statusSearch.test.ts +++ b/src/features/instance/status/statusSearch.test.ts @@ -3,10 +3,11 @@ import { STATUS_SEARCH_DEFAULTS, validateStatusSearch } from './statusSearch'; describe('validateStatusSearch', () => { it('passes through valid values', () => { - expect(validateStatusSearch({ tab: 'traffic', range: '6h', refresh: 30_000 })).toEqual({ + expect(validateStatusSearch({ tab: 'traffic', range: '6h', refresh: 30_000, jobsRefresh: 300_000 })).toEqual({ tab: 'traffic', range: '6h', refresh: 30_000, + jobsRefresh: 300_000, }); }); @@ -15,17 +16,30 @@ describe('validateStatusSearch', () => { }); it('falls back to defaults for invalid values', () => { - expect(validateStatusSearch({ tab: 'garbage', range: '999y', refresh: 1234 })).toEqual( + expect(validateStatusSearch({ tab: 'garbage', range: '999y', refresh: 1234, jobsRefresh: 1234 })).toEqual( STATUS_SEARCH_DEFAULTS, ); }); + it("defaults the charts' auto-refresh to 60s and Jobs' to off", () => { + const { refresh, jobsRefresh } = validateStatusSearch({}); + expect(refresh).toBe(60_000); + expect(jobsRefresh).toBe(0); + }); + + it('reads each tab kind its own interval, so neither choice turns the other on', () => { + expect(validateStatusSearch({ refresh: 30_000 }).jobsRefresh).toBe(0); + expect(validateStatusSearch({ jobsRefresh: 30_000 }).refresh).toBe(60_000); + }); + it('coerces a string refresh (URL-sourced) to its numeric option', () => { expect(validateStatusSearch({ refresh: '30000' }).refresh).toBe(30_000); + expect(validateStatusSearch({ jobsRefresh: '30000' }).jobsRefresh).toBe(30_000); }); it('treats an empty-string refresh as missing, not as 0/Off', () => { expect(validateStatusSearch({ refresh: '' }).refresh).toBe(STATUS_SEARCH_DEFAULTS.refresh); + expect(validateStatusSearch({ jobsRefresh: '' }).jobsRefresh).toBe(STATUS_SEARCH_DEFAULTS.jobsRefresh); }); it('keeps refresh=0 (auto-refresh off) instead of defaulting it', () => { diff --git a/src/features/instance/status/statusSearch.ts b/src/features/instance/status/statusSearch.ts index 19ff11350..f58af3b12 100644 --- a/src/features/instance/status/statusSearch.ts +++ b/src/features/instance/status/statusSearch.ts @@ -6,7 +6,7 @@ import { type TimePresetId, } from './analytics/context/timePresets'; -/** Search-param schema for the instance Status route (`?tab&range&refresh`). +/** Search-param schema for the instance Status route (`?tab&range&refresh&jobsRefresh`). * Lives outside analytics/ so the route definition can validate search * without pulling the lazy-loaded analytics bundle into the main chunk. */ @@ -18,32 +18,49 @@ export const STATUS_TAB_IDS = [ 'replication', 'storage', 'overview', + 'jobs', ] as const; export type StatusTabId = (typeof STATUS_TAB_IDS)[number]; export interface StatusSearch { tab: StatusTabId; range: TimePresetId; + /** The chart tabs' auto-refresh. */ refresh: number; + /** The Jobs tab's auto-refresh. It is not `refresh`, because one URL value cannot default to 60s on the + * charts and to off on Jobs: switching tabs would either start Jobs polling or reset the charts' choice. */ + jobsRefresh: number; } +/** + * Off, so Jobs fetches when it opens and when asked. Each poll makes Harper run an unlimited + * `select * from system.hdb_job` over the window (harper `server/jobs/jobs.ts`); push updates are + * HarperFast/harper#3179. + */ +export const DEFAULT_JOBS_REFRESH_MS = 0; + export const STATUS_SEARCH_DEFAULTS: StatusSearch = { tab: 'health', range: DEFAULT_PRESET_ID, refresh: DEFAULT_REFRESH_MS, + jobsRefresh: DEFAULT_JOBS_REFRESH_MS, }; +function refreshOption(value: unknown, fallback: number): number { + // `Number('')` is 0, which would silently match the "Off" refresh option — + // treat an empty string like a missing param instead. + const ms = value === '' ? Number.NaN : Number(value); + return REFRESH_OPTIONS.find((o) => o.value === ms)?.value ?? fallback; +} + /** Normalizes raw search params to a valid `StatusSearch`; missing or invalid * values fall back to defaults rather than erroring, so hand-edited or stale * deep links degrade gracefully. */ export function validateStatusSearch(search: Record): StatusSearch { - // `Number('')` is 0, which would silently match the "Off" refresh option — - // treat an empty string like a missing param instead. - const refreshValue = search.refresh === '' ? Number.NaN : Number(search.refresh); return { tab: STATUS_TAB_IDS.find((t) => t === search.tab) ?? STATUS_SEARCH_DEFAULTS.tab, range: TIME_PRESETS.find((p) => p.id === search.range)?.id ?? STATUS_SEARCH_DEFAULTS.range, - refresh: REFRESH_OPTIONS.find((o) => o.value === refreshValue)?.value - ?? STATUS_SEARCH_DEFAULTS.refresh, + refresh: refreshOption(search.refresh, STATUS_SEARCH_DEFAULTS.refresh), + jobsRefresh: refreshOption(search.jobsRefresh, STATUS_SEARCH_DEFAULTS.jobsRefresh), }; } diff --git a/src/integrations/api/instance/database/getJob.test.ts b/src/integrations/api/instance/database/getJob.test.ts index f5ebdf970..b9f25ab2b 100644 --- a/src/integrations/api/instance/database/getJob.test.ts +++ b/src/integrations/api/instance/database/getJob.test.ts @@ -98,4 +98,49 @@ describe('waitForJob', () => { await vi.advanceTimersByTimeAsync(2000); await expect(pending).rejects.toThrow(/Timed out/); }); + + it('reports every job record a poll returns, the finished one included, and skips a failed poll', async () => { + const instanceClient = makeClient([ + { status: 'CREATED' }, + { reject: true }, + { status: 'IN_PROGRESS' }, + { status: 'COMPLETE', message: 'done' }, + ]); + const onUpdate = vi.fn(); + const pending = waitForJob({ jobId: 'job-1', instanceClient, pollIntervalMs: 100, onUpdate }); + await vi.advanceTimersByTimeAsync(1000); + await pending; + expect(onUpdate.mock.calls.map(([job]) => job.status)).toEqual(['CREATED', 'IN_PROGRESS', 'COMPLETE']); + }); + + it('shows the observer a structured failure as sent, then rejects with it as text', async () => { + const report = { error: 'not authorized', unauthorized_access: [{ table: 'dog' }] }; + const instanceClient = makeClient([{ status: 'IN_PROGRESS' }, { status: 'ERROR', message: report }]); + const onUpdate = vi.fn(); + const pending = waitForJob({ jobId: 'job-1', instanceClient, pollIntervalMs: 100, onUpdate }); + pending.catch(() => {}); + await vi.advanceTimersByTimeAsync(500); + + await expect(pending).rejects.toThrow(JSON.stringify(report)); + expect(onUpdate.mock.calls.map(([job]) => [job.status, job.message])).toEqual([ + ['IN_PROGRESS', undefined], + ['ERROR', report], + ]); + }); + + it('still resolves when the observer throws, so a completed load is never reported as failed', async () => { + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const instanceClient = makeClient([{ status: 'IN_PROGRESS' }, { status: 'COMPLETE', message: 'done' }]); + const pending = waitForJob({ + jobId: 'job-1', + instanceClient, + pollIntervalMs: 100, + onUpdate: () => { + throw new Error('toast exploded'); + }, + }); + await vi.advanceTimersByTimeAsync(500); + await expect(pending).resolves.toMatchObject({ status: 'COMPLETE' }); + debug.mockRestore(); + }); }); diff --git a/src/integrations/api/instance/database/getJob.ts b/src/integrations/api/instance/database/getJob.ts index 62c2d566b..cce14c06d 100644 --- a/src/integrations/api/instance/database/getJob.ts +++ b/src/integrations/api/instance/database/getJob.ts @@ -38,16 +38,19 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** * Polls get_job until the job leaves IN_PROGRESS. Resolves with the finished job, * or rejects with the job's error message (ERROR status) or on timeout. + * `onUpdate` sees every job record a poll returns, the finished one included. */ export async function waitForJob({ jobId, instanceClient, pollIntervalMs = 1500, timeoutMs = 10 * 60 * 1000, + onUpdate, }: { jobId: string; pollIntervalMs?: number; timeoutMs?: number; + onUpdate?: (job: HarperJob) => void; } & InstanceClientConfig): Promise { const deadline = Date.now() + timeoutMs; // A dropped poll shouldn't kill an import that is still running server-side, so @@ -65,6 +68,15 @@ export async function waitForJob({ throw err; } } + if (job && onUpdate) { + // An observer failure must not turn a load Harper completed into a reported failure, which + // invites a duplicating retry. + try { + onUpdate(job); + } catch (err) { + console.debug('waitForJob onUpdate failed', err); + } + } // Only COMPLETE and ERROR are terminal: a freshly submitted job can still be // CREATED (no worker picked it up yet), so anything else means keep polling. const status = job?.status?.toUpperCase(); diff --git a/src/integrations/api/instance/database/importData.test.tsx b/src/integrations/api/instance/database/importData.test.tsx new file mode 100644 index 000000000..1df8fe3f3 --- /dev/null +++ b/src/integrations/api/instance/database/importData.test.tsx @@ -0,0 +1,114 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook } from '@testing-library/react'; +import type { AxiosInstance } from 'axios'; +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { importData, type ImportDataParams, type ImportProgressListener, useImportDataMutation } from './importData'; + +function progressListener() { + return { + onJobUpdate: vi.fn(), + onSuccess: vi.fn(), + onError: vi.fn(), + }; +} + +function csvClient(statuses: string[]) { + let poll = 0; + const post = vi.fn(async (_url: string, body: { operation: string }) => { + if (body.operation === 'csv_url_load' || body.operation === 'csv_data_load') { + return { data: { message: 'Starting job', job_id: 'job-1' } }; + } + const status = statuses[Math.min(poll, statuses.length - 1)]; + poll += 1; + return { data: [{ id: 'job-1', status, message: status === 'COMPLETE' ? 'loaded 5 of 5' : undefined }] }; + }); + return { post } as unknown as AxiosInstance; +} + +function params(instanceClient: AxiosInstance, progress: ImportProgressListener): ImportDataParams { + return { + database: 'data', + table: 'dog', + tableExists: true, + replicated: false, + source: { kind: 'csv-url', url: 'https://example.com/dogs.csv' }, + instanceClient, + progress, + }; +} + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +describe('importData', () => { + it("hands each poll of a CSV load's job to the progress listener", async () => { + const progress = progressListener(); + const pending = importData(params(csvClient(['CREATED', 'IN_PROGRESS', 'COMPLETE']), progress)); + await vi.advanceTimersByTimeAsync(5_000); + + await expect(pending).resolves.toEqual({ message: 'loaded 5 of 5' }); + expect(progress.onJobUpdate.mock.calls.map(([job]) => job.status)).toEqual(['CREATED', 'IN_PROGRESS', 'COMPLETE']); + }); + + it('does the same for an uploaded CSV', async () => { + const progress = progressListener(); + const pending = importData({ + ...params(csvClient(['IN_PROGRESS', 'COMPLETE']), progress), + source: { kind: 'csv-data', data: 'name\nRex' }, + }); + await vi.advanceTimersByTimeAsync(5_000); + + await expect(pending).resolves.toEqual({ message: 'loaded 5 of 5' }); + expect(progress.onJobUpdate.mock.calls.map(([job]) => job.status)).toEqual(['IN_PROGRESS', 'COMPLETE']); + }); +}); + +describe('useImportDataMutation', () => { + function wrapper() { + const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); + } + + it('settles the progress listener even when the component that started the import has unmounted', async () => { + const progress = progressListener(); + const { result, unmount } = renderHook(() => useImportDataMutation(), { wrapper: wrapper() }); + + act(() => result.current.mutate(params(csvClient(['IN_PROGRESS', 'COMPLETE']), progress))); + unmount(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(progress.onSuccess).toHaveBeenCalledWith({ message: 'loaded 5 of 5' }); + expect(progress.onError).not.toHaveBeenCalled(); + }); + + it('keeps a completed import successful when the progress listener throws', async () => { + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const progress = progressListener(); + progress.onSuccess.mockImplementation(() => { + throw new Error('toast exploded'); + }); + const { result } = renderHook(() => useImportDataMutation(), { wrapper: wrapper() }); + + act(() => result.current.mutate(params(csvClient(['COMPLETE']), progress))); + await vi.advanceTimersByTimeAsync(5_000); + + expect(result.current.isSuccess).toBe(true); + expect(progress.onError).not.toHaveBeenCalled(); + debug.mockRestore(); + }); + + it('settles the progress listener as failed when the import fails', async () => { + const progress = progressListener(); + const { result } = renderHook(() => useImportDataMutation(), { wrapper: wrapper() }); + + act(() => result.current.mutate(params(csvClient(['IN_PROGRESS', 'ERROR']), progress))); + await vi.advanceTimersByTimeAsync(5_000); + + expect(progress.onError).toHaveBeenCalledTimes(1); + expect(progress.onSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/src/integrations/api/instance/database/importData.ts b/src/integrations/api/instance/database/importData.ts index 359fecaa3..ff344f8f7 100644 --- a/src/integrations/api/instance/database/importData.ts +++ b/src/integrations/api/instance/database/importData.ts @@ -3,7 +3,7 @@ import { errorText } from '@/lib/errorText'; import { useMutation } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { onAddCSVDataSubmit } from './addCSVData'; -import { waitForJob } from './getJob'; +import { type HarperJob, waitForJob } from './getJob'; import { insertTableRecords } from './insertTableRecords'; export type ImportSource = @@ -18,12 +18,24 @@ export interface ImportDataParams extends InstanceClientConfig { tableExists: boolean; replicated: boolean; source: ImportSource; + progress?: ImportProgressListener; } export interface ImportDataResult { message: string; } +/** + * Follows one import. The mutation's own callbacks settle it, not the caller's `mutate` options, which + * React Query skips once the component that started the import has unmounted. + */ +export interface ImportProgressListener { + /** Every poll of a CSV load's job. A JSON-records import runs no job, so it never calls this. */ + onJobUpdate(job: HarperJob): void; + onSuccess(result: ImportDataResult): void; + onError(): void; +} + function extractErrorMessage(err: unknown): string { const axiosErr = err as AxiosError; const data = axiosErr?.response?.data; @@ -43,7 +55,9 @@ export async function importData({ replicated, source, instanceClient, + progress, }: ImportDataParams): Promise { + const onUpdate = (job: HarperJob) => progress?.onJobUpdate(job); if (!tableExists) { try { await instanceClient.post('/', { @@ -67,7 +81,7 @@ export async function importData({ switch (source.kind) { case 'csv-data': { const { job_id } = await onAddCSVDataSubmit({ database, table, fileData: source.data, instanceClient }); - const job = await waitForJob({ jobId: job_id, instanceClient }); + const job = await waitForJob({ jobId: job_id, instanceClient, onUpdate }); return { message: errorText(job.message) ?? 'CSV data imported successfully' }; } case 'csv-url': { @@ -78,7 +92,7 @@ export async function importData({ table, action: 'insert', }); - const job = await waitForJob({ jobId: data.job_id, instanceClient }); + const job = await waitForJob({ jobId: data.job_id, instanceClient, onUpdate }); return { message: errorText(job.message) ?? 'CSV data imported successfully' }; } case 'json-records': { @@ -99,8 +113,20 @@ export async function importData({ } } +/** A throw from a mutation callback becomes the mutation's error, so a progress listener that fails + * would report an import Harper completed as failed and invite a duplicating retry. */ +function settleQuietly(settle: () => void) { + try { + settle(); + } catch (err) { + console.debug('Import progress listener failed', err); + } +} + export function useImportDataMutation() { return useMutation({ mutationFn: importData, + onSuccess: (result, { progress }) => settleQuietly(() => progress?.onSuccess(result)), + onError: (_error, { progress }) => settleQuietly(() => progress?.onError()), }); } diff --git a/src/integrations/api/instance/status/searchJobs.test.ts b/src/integrations/api/instance/status/searchJobs.test.ts new file mode 100644 index 000000000..8bebfd08b --- /dev/null +++ b/src/integrations/api/instance/status/searchJobs.test.ts @@ -0,0 +1,108 @@ +import type { AxiosInstance } from 'axios'; +import { AxiosError } from 'axios'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getSearchJobsQueryOptions, SEARCH_JOBS_OPERATION, searchJobs } from './searchJobs'; + +const HOUR = 60 * 60 * 1000; + +function clientAnswering(data: unknown) { + const post = vi.fn(async (_url: string, _body: Record) => ({ data })); + return { post, instanceClient: { post } as unknown as AxiosInstance }; +} + +function httpError(status: number): AxiosError { + const err = new AxiosError(`Request failed with status code ${status}`); + err.response = { status } as AxiosError['response']; + return err; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('searchJobs', () => { + it('asks for jobs started since the window opened, with an upper bound past any clock skew', async () => { + const { post, instanceClient } = clientAnswering([]); + const now = Date.UTC(2026, 9, 11, 12, 0, 0); + await searchJobs({ instanceClient, windowMs: HOUR, now }); + expect(post).toHaveBeenCalledWith('/', { + operation: SEARCH_JOBS_OPERATION, + from_date: new Date(now - HOUR).toISOString(), + to_date: new Date(now + 24 * HOUR).toISOString(), + }); + }); + + it('orders jobs newest first, falling back to the creation time for a job with no start time', async () => { + const { instanceClient } = clientAnswering([ + { id: 'old', start_datetime: 1_000 }, + { id: 'queued', created_datetime: 3_000 }, + { id: 'new', start_datetime: 2_000 }, + ]); + const jobs = await searchJobs({ instanceClient, windowMs: HOUR, now: 10_000 }); + expect(jobs.map((job) => job.id)).toEqual(['queued', 'new', 'old']); + }); + + it('refuses an answer that is not a list rather than reporting no jobs', async () => { + const { instanceClient } = clientAnswering({ message: 'unexpected' }); + await expect(searchJobs({ instanceClient, windowMs: HOUR, now: 0 })).rejects.toThrow(/other than a list/); + }); +}); + +describe('getSearchJobsQueryOptions', () => { + const base = { entityId: 'ins-1' as never, windowMs: HOUR, enabled: true }; + + it('keys on the entity and the window width, operation first', () => { + const { instanceClient } = clientAnswering([]); + const { queryKey } = getSearchJobsQueryOptions({ ...base, instanceClient, refreshMs: 60_000 }); + expect(queryKey).toEqual([SEARCH_JOBS_OPERATION, 'ins-1', HOUR]); + }); + + it('computes the window when it fetches, so each poll slides it forward', async () => { + vi.useFakeTimers(); + const { post, instanceClient } = clientAnswering([]); + const { queryFn } = getSearchJobsQueryOptions({ ...base, instanceClient, refreshMs: 60_000 }); + const run = queryFn as unknown as () => Promise; + + vi.setSystemTime(Date.UTC(2026, 9, 11, 12, 0, 0)); + await run(); + vi.setSystemTime(Date.UTC(2026, 9, 11, 12, 1, 0)); + await run(); + + const fromDates = post.mock.calls.map(([, body]) => body.from_date); + expect(fromDates).toEqual([ + new Date(Date.UTC(2026, 9, 11, 11, 0, 0)).toISOString(), + new Date(Date.UTC(2026, 9, 11, 11, 1, 0)).toISOString(), + ]); + }); + + function refetchIntervalFor(refreshMs: number, error: unknown) { + const { instanceClient } = clientAnswering([]); + const { refetchInterval } = getSearchJobsQueryOptions({ ...base, instanceClient, refreshMs }); + const resolve = refetchInterval as unknown as (query: { state: { error: unknown } }) => number | false; + return resolve({ state: { error } }); + } + + it('polls at the chosen refresh interval', () => { + expect(refetchIntervalFor(30_000, null)).toBe(30_000); + }); + + it('does not poll when auto-refresh is off', () => { + expect(refetchIntervalFor(0, null)).toBe(false); + }); + + it('stops polling once Harper answers 403', () => { + expect(refetchIntervalFor(30_000, httpError(403))).toBe(false); + }); + + it('keeps polling through a transient 5xx', () => { + expect(refetchIntervalFor(30_000, httpError(503))).toBe(30_000); + }); + + it('does not retry a 403', () => { + const { instanceClient } = clientAnswering([]); + const { retry } = getSearchJobsQueryOptions({ ...base, instanceClient, refreshMs: 30_000 }); + const shouldRetry = retry as (failureCount: number, error: unknown) => boolean; + expect(shouldRetry(0, httpError(403))).toBe(false); + expect(shouldRetry(0, httpError(503))).toBe(true); + }); +}); diff --git a/src/integrations/api/instance/status/searchJobs.ts b/src/integrations/api/instance/status/searchJobs.ts new file mode 100644 index 000000000..cf5c2f4d5 --- /dev/null +++ b/src/integrations/api/instance/status/searchJobs.ts @@ -0,0 +1,76 @@ +import type { InstanceClientConfig, InstanceClientIdConfig } from '@/config/instanceClientConfig'; +import { pollUnlessForbidden, retryUnlessRejected } from '@/react-query/pollUnlessForbidden'; +import { keepPreviousData, queryOptions } from '@tanstack/react-query'; + +export const SEARCH_JOBS_OPERATION = 'search_jobs_by_start_date'; + +/** + * A `system.hdb_job` row as `search_jobs_by_start_date` returns it. Harper strips `request` (it holds + * the whole job request — a CSV body, S3 credentials) and its internal owner/timestamp attributes + * before answering (harper `server/jobs/jobs.ts`, `handleGetJobsByStartDate`). + */ +export interface JobRecord { + id: string; + /** The operation that queued the job, e.g. `csv_url_load`. */ + type?: string; + /** CREATED → IN_PROGRESS → COMPLETE | ERROR. */ + status?: string; + /** Usually text. A refused bulk load records its structured permission report here instead. */ + message?: unknown; + /** What a job whose operation returned a non-string value finished with. */ + result?: unknown; + user?: string; + created_datetime?: number; + start_datetime?: number; + end_datetime?: number; +} + +/** + * Harper compares `start_datetime < to_date` strictly, against its own clock. An upper bound at this + * browser's `now` would hide a job that just started on a server whose clock runs ahead, so the bound + * sits well past any plausible skew — no job can start in the future anyway. + */ +const CLOCK_SKEW_ALLOWANCE_MS = 24 * 60 * 60 * 1000; + +function jobStartTime(job: JobRecord): number { + return job.start_datetime ?? job.created_datetime ?? 0; +} + +/** Newest first: Harper answers in no particular order. */ +export async function searchJobs( + { instanceClient, windowMs, now }: { windowMs: number; now: number } & InstanceClientConfig, +): Promise { + const { data } = await instanceClient.post('/', { + operation: SEARCH_JOBS_OPERATION, + from_date: new Date(now - windowMs).toISOString(), + to_date: new Date(now + CLOCK_SKEW_ALLOWANCE_MS).toISOString(), + }); + if (!Array.isArray(data)) { + throw new Error(`Harper answered ${SEARCH_JOBS_OPERATION} with something other than a list of jobs.`); + } + return (data as JobRecord[]).sort((a, b) => jobStartTime(b) - jobStartTime(a)); +} + +export function getSearchJobsQueryOptions({ + entityId, + instanceClient, + windowMs, + refreshMs, + enabled, +}: { windowMs: number; refreshMs: number; enabled: boolean } & InstanceClientIdConfig) { + return queryOptions({ + // The window is computed per fetch, so the key holds its width rather than its bounds: each poll + // slides the same query forward instead of minting a new cache entry. + queryKey: [SEARCH_JOBS_OPERATION, entityId, windowMs] as const, + queryFn: () => searchJobs({ instanceClient, windowMs, now: Date.now() }), + refetchInterval: pollUnlessForbidden(refreshMs > 0 ? refreshMs : false), + // With auto-refresh off, a refocus or reconnect does not refetch either: each fetch reads the whole + // window on the server. + refetchOnWindowFocus: refreshMs > 0, + refetchOnReconnect: refreshMs > 0, + retry: retryUnlessRejected(), + placeholderData: keepPreviousData, + staleTime: 5_000, + enabled, + }); +} diff --git a/src/lib/formatDuration.test.ts b/src/lib/formatDuration.test.ts new file mode 100644 index 000000000..59bdc822e --- /dev/null +++ b/src/lib/formatDuration.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { formatDuration } from './formatDuration'; + +describe('formatDuration', () => { + it.each([ + [0, '0 ms'], + [850, '850 ms'], + [1_000, '1s'], + [12_400, '12s'], + [125_000, '2m 05s'], + [3_725_000, '1h 02m'], + ])('formats %i ms as %s', (ms, expected) => { + expect(formatDuration(ms)).toBe(expected); + }); + + it('shows a dash for a negative or non-finite span', () => { + expect(formatDuration(-1)).toBe('—'); + expect(formatDuration(Number.NaN)).toBe('—'); + }); +}); diff --git a/src/lib/formatDuration.ts b/src/lib/formatDuration.ts new file mode 100644 index 000000000..478f933ca --- /dev/null +++ b/src/lib/formatDuration.ts @@ -0,0 +1,20 @@ +/** `12s`, `2m 05s`, `1h 02m`; sub-second spans read as milliseconds. */ +export function formatDuration(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) { + return '—'; + } + if (ms < 1000) { + return `${Math.round(ms)} ms`; + } + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) { + return `${hours}h ${String(minutes).padStart(2, '0')}m`; + } + if (minutes > 0) { + return `${minutes}m ${String(seconds).padStart(2, '0')}s`; + } + return `${seconds}s`; +} diff --git a/src/router/__tests__/statusSearchScope.test.ts b/src/router/__tests__/statusSearchScope.test.ts index b6a6bda5c..3e67cd058 100644 --- a/src/router/__tests__/statusSearchScope.test.ts +++ b/src/router/__tests__/statusSearchScope.test.ts @@ -28,20 +28,20 @@ describe('status route search-param scoping', () => { const router = makeRouter(`${STATUS_PATH}?tab=traffic&range=6h`); const leaf = router.matchRoutes(router.state.location).at(-1)!; expect(leaf.routeId).toContain('/status'); - expect(leaf.search).toEqual({ tab: 'traffic', range: '6h', refresh: 60_000 }); + expect(leaf.search).toEqual({ tab: 'traffic', range: '6h', refresh: 60_000, jobsRefresh: 0 }); }); it('normalizes invalid deep-link values to defaults instead of erroring', () => { const router = makeRouter(`${STATUS_PATH}?tab=garbage&range=999y&refresh=17`); const leaf = router.matchRoutes(router.state.location).at(-1)!; - expect(leaf.search).toEqual({ tab: 'health', range: '1h', refresh: 60_000 }); + expect(leaf.search).toEqual({ tab: 'health', range: '1h', refresh: 60_000, jobsRefresh: 0 }); }); it('strips default values from status URLs', () => { const router = makeRouter(STATUS_PATH); const loc = router.buildLocation({ to: STATUS_PATH, - search: { tab: 'health', range: '1h', refresh: 60_000 }, + search: { tab: 'health', range: '1h', refresh: 60_000, jobsRefresh: 0 }, }); expect(loc.searchStr).toBe(''); }); @@ -50,11 +50,29 @@ describe('status route search-param scoping', () => { const router = makeRouter(STATUS_PATH); const loc = router.buildLocation({ to: STATUS_PATH, - search: { tab: 'traffic', range: '1h', refresh: 60_000 }, + search: { tab: 'traffic', range: '1h', refresh: 60_000, jobsRefresh: 0 }, }); expect(loc.searchStr).toBe('?tab=traffic'); }); + it("opens a Jobs link with Jobs' auto-refresh off and the charts' at 60s", () => { + const router = makeRouter(`${STATUS_PATH}?tab=jobs`); + const leaf = router.matchRoutes(router.state.location).at(-1)!; + expect(leaf.search).toEqual({ tab: 'jobs', range: '1h', refresh: 60_000, jobsRefresh: 0 }); + }); + + it("keeps a chosen Jobs interval in the URL, including one equal to the charts' default", () => { + const router = makeRouter(STATUS_PATH); + const at = (jobsRefresh: number) => + router.buildLocation({ to: STATUS_PATH, search: { tab: 'jobs', range: '1h', refresh: 60_000, jobsRefresh } }) + .searchStr; + expect(at(0)).toBe('?tab=jobs'); + expect(at(60_000)).toBe('?tab=jobs&jobsRefresh=60000'); + + const linked = makeRouter(`${STATUS_PATH}?tab=jobs&jobsRefresh=60000`); + expect(linked.matchRoutes(linked.state.location).at(-1)!.search).toMatchObject({ jobsRefresh: 60_000 }); + }); + it('does not carry status params onto sibling routes', () => { // From a status location with non-default params, a plain nav-bar-style // navigation (no `search`) must land on a clean sibling URL.