From 8f6346b0904697c394d1ee5198c069e802d4eb80 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sun, 11 Oct 2026 03:56:42 -0400 Subject: [PATCH 1/3] feat(status): list Harper jobs on a Jobs tab, and follow a CSV import's job in a toast Adds a Jobs tab to the instance Status page that lists the jobs Harper ran in the selected window (search_jobs_by_start_date), newest first, with an expandable row for each job's full record. It polls on the page's refresh interval through pollUnlessForbidden, is offered only to roles that can run the operation, and sits outside the get_analytics capability gate. The operation takes no limit, so Jobs offers windows up to 7 days and renders the 200 newest rows. CSV imports now show their job in a loading toast that updates on every get_job poll (queued/running and elapsed time) and becomes the result. The toast is settled from the mutation's own callbacks, so it still resolves if the modal unmounts mid-import, and a throwing listener cannot turn a completed import into a reported failure. Refs #1266 Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 2 +- docs/status-redesign.md | 15 +- .../functions/importProgressToast.test.ts | 78 +++++ .../functions/importProgressToast.ts | 37 +++ .../modals/ImportDataModal.progress.test.tsx | 82 +++++ .../databases/modals/ImportDataModal.tsx | 5 +- .../instance/status/analytics/StatusTabs.tsx | 18 +- .../StatusTabs.capability-gating.test.tsx | 36 ++- .../instance/status/jobs/JobsTab.test.tsx | 172 ++++++++++ src/features/instance/status/jobs/JobsTab.tsx | 293 ++++++++++++++++++ .../instance/status/jobs/canListJobs.test.ts | 57 ++++ .../instance/status/jobs/canListJobs.ts | 24 ++ .../instance/status/jobs/jobFormat.test.ts | 45 +++ .../instance/status/jobs/jobFormat.ts | 34 ++ src/features/instance/status/statusSearch.ts | 1 + .../api/instance/database/getJob.test.ts | 30 ++ .../api/instance/database/getJob.ts | 12 + .../api/instance/database/importData.test.tsx | 102 ++++++ .../api/instance/database/importData.ts | 32 +- .../api/instance/status/searchJobs.test.ts | 108 +++++++ .../api/instance/status/searchJobs.ts | 72 +++++ src/lib/formatDuration.test.ts | 20 ++ src/lib/formatDuration.ts | 20 ++ 23 files changed, 1280 insertions(+), 15 deletions(-) create mode 100644 src/features/instance/databases/functions/importProgressToast.test.ts create mode 100644 src/features/instance/databases/functions/importProgressToast.ts create mode 100644 src/features/instance/databases/modals/ImportDataModal.progress.test.tsx create mode 100644 src/features/instance/status/jobs/JobsTab.test.tsx create mode 100644 src/features/instance/status/jobs/JobsTab.tsx create mode 100644 src/features/instance/status/jobs/canListJobs.test.ts create mode 100644 src/features/instance/status/jobs/canListJobs.ts create mode 100644 src/features/instance/status/jobs/jobFormat.test.ts create mode 100644 src/features/instance/status/jobs/jobFormat.ts create mode 100644 src/integrations/api/instance/database/importData.test.tsx create mode 100644 src/integrations/api/instance/status/searchJobs.test.ts create mode 100644 src/integrations/api/instance/status/searchJobs.ts create mode 100644 src/lib/formatDuration.test.ts create mode 100644 src/lib/formatDuration.ts 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..8bcc00559 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=` 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,17 @@ 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(refresh)` so a 403 stops the timer. It shares the `range`/`refresh` search params and their 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. +- **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 `isGrantGateInert` says it is not inert, so the tab and the role editor never disagree. + +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..d7601630f 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 @@ -179,7 +184,7 @@ function StatusTabsInner({ instanceParams, isLocalStudio, capability }: InnerPro } if (landedOnOverviewRef.current) { return; } landedOnOverviewRef.current = true; - if (tab !== 'overview') { + if (!ANALYTICS_FREE_TABS.has(tab)) { void navigate({ to: '.', search: { tab: 'overview', range: presetId, refresh: refreshMs }, replace: true }); } }, [capability.error, tab, navigate, presetId, refreshMs]); @@ -209,7 +214,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 +298,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..b383581ca 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(); }); @@ -227,6 +231,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/jobs/JobsTab.test.tsx b/src/features/instance/status/jobs/JobsTab.test.tsx new file mode 100644 index 000000000..26ae8eefd --- /dev/null +++ b/src/features/instance/status/jobs/JobsTab.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment jsdom +import type { LocalRolePermission } from '@/integrations/api/api.patch'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { AxiosError } from 'axios'; +import { afterEach, 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; +} + +beforeEach(() => { + search.current = { tab: 'jobs', range: '24h', refresh: 0 }; +}); + +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('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(); + }); + + 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', refresh: 0 }; + 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)); + }); +}); diff --git a/src/features/instance/status/jobs/JobsTab.tsx b/src/features/instance/status/jobs/JobsTab.tsx new file mode 100644 index 000000000..beb11956c --- /dev/null +++ b/src/features/instance/status/jobs/JobsTab.tsx @@ -0,0 +1,293 @@ +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 { 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 } 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 allowed = canListJobs(user?.role?.permission); + const range = jobWindow(search.range); + const windowMs = getPreset(range).durationMs; + + const query = useQuery(getSearchJobsQueryOptions({ + ...instanceParams, + windowMs, + refreshMs: search.refresh, + enabled: allowed === true, + })); + + const setRange = (range: TimePresetId) => void navigate({ to: '.', search: { ...search, range } }); + const setRefresh = (refresh: number) => void navigate({ to: '.', search: { ...search, refresh } }); + + 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, and not every Harper version honours that grant. +

+
+ ); + } + 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. +

+ )} +
+ ); +} + +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 ( + + 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..170df180a --- /dev/null +++ b/src/features/instance/status/jobs/canListJobs.test.ts @@ -0,0 +1,57 @@ +import type { LocalRolePermission } from '@/integrations/api/api.patch'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { canListJobs } from './canListJobs'; + +const gate = vi.hoisted(() => ({ inert: true })); + +vi.mock('@/features/instance/config/roles/operations/operationsCatalog', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isGrantGateInert: (name: string) => + name === 'search_jobs_by_start_date' ? gate.inert : actual.isGrantGateInert(name), + }; +}); + +const perm = (value: Record) => value as unknown as LocalRolePermission; + +afterEach(() => { + gate.inert = true; +}); + +describe('canListJobs', () => { + it('is undecided while the permission is still loading', () => { + expect(canListJobs(undefined)).toBeUndefined(); + }); + + it('allows a super user', () => { + expect(canListJobs(perm({ super_user: true }))).toBe(true); + }); + + it('denies a role with neither super_user nor an allowlist', () => { + expect(canListJobs(perm({ super_user: false, data: { tables: {} } }))).toBe(false); + }); + + it('denies a listed grant while the role catalog calls it inert', () => { + expect(canListJobs(perm({ operations: ['search_jobs_by_start_date'] }))).toBe(false); + }); + + describe('once the catalog no longer calls the grant inert', () => { + it('allows an allowlist that lists the operation', () => { + gate.inert = false; + expect(canListJobs(perm({ operations: ['read_only', 'search_jobs_by_start_date'] }))).toBe(true); + }); + + it('denies an allowlist that does not list it', () => { + gate.inert = false; + expect(canListJobs(perm({ operations: ['read_only'] }))).toBe(false); + }); + + it('denies an absent or malformed allowlist instead of reading it as unrestricted', () => { + gate.inert = false; + expect(canListJobs(perm({}))).toBe(false); + expect(canListJobs(perm({ operations: 'search_jobs_by_start_date' }))).toBe(false); + expect(canListJobs(perm({ operations: { tables: {} } }))).toBe(false); + }); + }); +}); diff --git a/src/features/instance/status/jobs/canListJobs.ts b/src/features/instance/status/jobs/canListJobs.ts new file mode 100644 index 000000000..01e2f72f2 --- /dev/null +++ b/src/features/instance/status/jobs/canListJobs.ts @@ -0,0 +1,24 @@ +import { isGrantGateInert } 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 this role may list jobs, or `undefined` while its permission is still loading. + * + * Harper registers `search_jobs_by_start_date` as super_user-only (`requiredPermissions` in harper + * `utility/operation_authorization.ts`). An allowlist naming it is honoured only where the role editor's + * catalog says the grant is not inert, so the two never disagree about what such a role can do. + */ +export function canListJobs(permission: LocalRolePermission | undefined): boolean | undefined { + if (!permission) { + return undefined; + } + if (permission.super_user === true) { + return true; + } + return !isGrantGateInert(SEARCH_JOBS_OPERATION) + && getOperationsAllowlist(permission) !== undefined + && checkAnyOperationAllowed(permission, [SEARCH_JOBS_OPERATION]); +} 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.ts b/src/features/instance/status/statusSearch.ts index 19ff11350..4ed297712 100644 --- a/src/features/instance/status/statusSearch.ts +++ b/src/features/instance/status/statusSearch.ts @@ -18,6 +18,7 @@ export const STATUS_TAB_IDS = [ 'replication', 'storage', 'overview', + 'jobs', ] as const; export type StatusTabId = (typeof STATUS_TAB_IDS)[number]; diff --git a/src/integrations/api/instance/database/getJob.test.ts b/src/integrations/api/instance/database/getJob.test.ts index f5ebdf970..669350cda 100644 --- a/src/integrations/api/instance/database/getJob.test.ts +++ b/src/integrations/api/instance/database/getJob.test.ts @@ -98,4 +98,34 @@ 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('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..8796c5d69 --- /dev/null +++ b/src/integrations/api/instance/database/importData.test.tsx @@ -0,0 +1,102 @@ +// @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') { + 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']); + }); +}); + +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..a5fc927f6 --- /dev/null +++ b/src/integrations/api/instance/status/searchJobs.ts @@ -0,0 +1,72 @@ +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), + 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`; +} From 39edbabc2d1dbc3889f0f6161062fb9a41053ee9 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 12 Oct 2026 11:40:22 -0400 Subject: [PATCH 2/3] fix(status): let a job row's text be selected without toggling its details A drag that selects text in a summary row (the user, type or message) ends in a click on the row, so selecting a job's text opened or closed its details. The row now skips the toggle when the selection is non-empty and starts or ends inside it. The check is scoped to the row rather than to any page selection, so a click right after selecting text elsewhere still toggles. Co-Authored-By: Claude Opus 5.5 --- docs/status-redesign.md | 2 +- .../instance/status/jobs/JobsTab.test.tsx | 69 +++++++++++++++++++ src/features/instance/status/jobs/JobsTab.tsx | 34 +++++++-- .../instance/status/jobs/canListJobs.test.ts | 62 ++++++++--------- .../instance/status/jobs/canListJobs.ts | 33 ++++++--- .../api/instance/database/getJob.test.ts | 15 ++++ .../api/instance/database/importData.test.tsx | 14 +++- 7 files changed, 180 insertions(+), 49 deletions(-) diff --git a/docs/status-redesign.md b/docs/status-redesign.md index 8bcc00559..c23d96e0e 100644 --- a/docs/status-redesign.md +++ b/docs/status-redesign.md @@ -25,7 +25,7 @@ Jobs lists Harper's background jobs (CSV/S3 loads, exports, bulk deletes, backup - **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(refresh)` so a 403 stops the timer. It shares the `range`/`refresh` search params and their 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. - **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 `isGrantGateInert` says it is not inert, so the tab and the role editor never disagree. +- **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. diff --git a/src/features/instance/status/jobs/JobsTab.test.tsx b/src/features/instance/status/jobs/JobsTab.test.tsx index 26ae8eefd..8da08fcc4 100644 --- a/src/features/instance/status/jobs/JobsTab.test.tsx +++ b/src/features/instance/status/jobs/JobsTab.test.tsx @@ -90,6 +90,30 @@ describe('JobsTab', () => { 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: [] })); @@ -100,6 +124,51 @@ describe('JobsTab', () => { 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); diff --git a/src/features/instance/status/jobs/JobsTab.tsx b/src/features/instance/status/jobs/JobsTab.tsx index beb11956c..ab4ff43cf 100644 --- a/src/features/instance/status/jobs/JobsTab.tsx +++ b/src/features/instance/status/jobs/JobsTab.tsx @@ -5,6 +5,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ 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'; @@ -18,7 +19,7 @@ import { getPreset, REFRESH_OPTIONS, TIME_PRESETS, type TimePresetId } from '../ import { PanelErrorBoundary } from '../analytics/tabs/PanelErrorBoundary'; import { PanelStateOrChart } from '../analytics/tabs/PanelShell'; import { validateStatusSearch } from '../statusSearch'; -import { canListJobs } from './canListJobs'; +import { canListJobs, listsJobsGrant } from './canListJobs'; import { describeJobStatus, formatJobTime, jobDurationMs } from './jobFormat'; interface Props { @@ -40,7 +41,18 @@ export function JobsTab({ instanceParams }: Props) { const navigate = useNavigate(); const search = validateStatusSearch(useSearch({ strict: false }) as Record); const { user } = useInstanceAuth(instanceParams.entityId); - const allowed = canListJobs(user?.role?.permission); + 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; @@ -94,7 +106,7 @@ function JobsBody({ allowed, query }: { allowed: boolean | undefined; query: Use

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, and not every Harper version honours that grant. + for super users unless an operations allowlist grants it, which Harper honours from 5.3.

); @@ -121,6 +133,13 @@ function JobsBody({ allowed, query }: { allowed: boolean | undefined; query: Use ); } +/** 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) => @@ -156,7 +175,14 @@ function JobsTable({ jobs }: { jobs: JobRecord[] }) { const detailsId = `job-details-${job.id}`; return ( - toggle(job.id)}> + { + if (!isSelectingTextIn(event.currentTarget)) { + toggle(job.id); + } + }} + >