Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/src/components/Actions/Actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,13 @@ function syncIndicatorPresentation(state: NotebookSyncState | null): {
clickable: boolean
} {
switch (state?.status) {
case 'not-downloaded':
return {
label: 'Notebook has not been downloaded to this browser. Click to download now.',
className: 'border border-nb-text-faint bg-transparent',
clickable: true,
}

case 'synced':
return {
label: 'Notebook is synced',
Expand Down
72 changes: 72 additions & 0 deletions app/src/components/DriveQueueMonitor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import '@testing-library/jest-dom/vitest'
import { act, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'

import { SyncWorkQueue } from '../storage/syncWorkQueue'
import { DriveQueueCharts, DriveQueueMonitor } from './DriveQueueMonitor'

afterEach(() => vi.useRealTimers())

it('shows backlog separately from completed dequeue observations', () => {
const queue = new SyncWorkQueue()
queue.add('waiting', async () => {}, 60_000)
try {
render(<DriveQueueCharts metrics={queue.getMetrics()} />)
expect(
screen.getByText('1 waiting · 0 eligible · 1 delayed · 0 active')
).toBeInTheDocument()
expect(
screen.getByRole('img', {
name: 'Waiting queue depth, peak per ten seconds',
})
).toBeInTheDocument()
expect(
screen.getByRole('img', {
name: 'Eligible-to-dequeue wait histogram, 0 attempts',
})
).toBeInTheDocument()
expect(screen.getByText(/No dequeue attempts yet/)).toBeInTheDocument()
} finally {
queue.close()
}
})

it('polls owner metrics without overlapping requests and stops on unmount', async () => {
vi.useFakeTimers()
const queue = new SyncWorkQueue()
let resolve!: (value: ReturnType<SyncWorkQueue['getMetrics']>) => void
const store = {
getDriveQueueMetrics: vi.fn(
() =>
new Promise<ReturnType<SyncWorkQueue['getMetrics']>>((r) => {
resolve = r
})
),
}
const view = render(<DriveQueueMonitor store={store} />)
await act(() => vi.advanceTimersByTimeAsync(15_000))
expect(store.getDriveQueueMetrics).toHaveBeenCalledTimes(1)
await act(async () => resolve(queue.getMetrics()))
expect(screen.getByText('Sync queue')).toBeInTheDocument()
await act(() => vi.advanceTimersByTimeAsync(5_000))
expect(store.getDriveQueueMetrics).toHaveBeenCalledTimes(2)
view.unmount()
await act(async () => resolve(queue.getMetrics()))
await act(() => vi.advanceTimersByTimeAsync(10_000))
expect(store.getDriveQueueMetrics).toHaveBeenCalledTimes(2)
queue.close()
})

it('reports unavailable diagnostics rather than displaying a healthy zero', async () => {
const store = {
getDriveQueueMetrics: vi
.fn()
.mockRejectedValue(new Error('Worker unavailable')),
}
render(<DriveQueueMonitor store={store} />)
expect(
await screen.findByText(
'Queue monitoring unavailable: Error: Worker unavailable'
)
).toBeInTheDocument()
})
221 changes: 221 additions & 0 deletions app/src/components/DriveQueueMonitor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { useEffect, useState } from 'react'

import type LocalNotebooks from '../storage/local'
import type { DriveQueueMetrics } from '../storage/syncQueueMetrics'

/** Keep duration labels comparable across the summary and histogram axes. */
function duration(ms: number): string {
if (ms < 1_000) return `${Math.round(ms)} ms`
if (ms < 60_000) return `${(ms / 1_000).toFixed(1)} s`
return `${(ms / 60_000).toFixed(1)} min`
}

/** Charts use owner snapshots, so closing this view does not lose queue history. */
export function DriveQueueCharts({ metrics }: { metrics: DriveQueueMetrics }) {
const maxDepth = Math.max(1, ...metrics.history.map((point) => point.depth))
const start = metrics.history[0]?.at ?? metrics.capturedAt
const span = Math.max(10_000, metrics.capturedAt - start)
const points = metrics.history.map(
(point) =>
`${44 + ((point.at - start) / span) * 480},${150 - (point.depth / maxDepth) * 120}`
)
const maxCount = Math.max(
1,
...metrics.waitHistogram.map((bucket) => bucket.count)
)
const attempts = metrics.waitHistogram.reduce(
(sum, bucket) => sum + bucket.count,
0
)
const time = (value: number) => new Date(value).toLocaleTimeString()
const labels = metrics.waitHistogram.map((bucket, index) => {
const lower = index ? metrics.waitHistogram[index - 1].upperBoundMs! : 0
return bucket.upperBoundMs === null
? `≥ ${duration(lower)}`
: `${duration(lower)}–<${duration(bucket.upperBoundMs)}`
})

return (
<section
id="drive-queue-charts"
aria-label="Sync queue monitoring"
className="rounded-lg border border-nb-border bg-white p-4"
>
<h2 className="font-semibold text-nb-text">Sync queue</h2>
<p className="mt-1 text-xs text-nb-text-muted">
{metrics.depth} waiting · {metrics.eligible} eligible ·{' '}
{metrics.delayed} delayed · {metrics.active} active
</p>
<p className="mt-1 text-xs text-nb-text-muted">
Oldest eligible wait: {duration(metrics.oldestEligibleWaitMs)}. Active
attempt: {duration(metrics.activeForMs)}.
</p>
<div id="drive-queue-plots" className="mt-3 grid gap-4 xl:grid-cols-2">
<figure className="min-w-0">
<figcaption className="text-sm font-medium">
Queue depth over time
</figcaption>
<svg
viewBox="0 0 560 190"
role="img"
aria-label="Waiting queue depth, peak per ten seconds"
className="w-full"
>
<line x1="44" y1="30" x2="44" y2="150" stroke="currentColor" />
<line x1="44" y1="150" x2="524" y2="150" stroke="currentColor" />
<text x="36" y="34" textAnchor="end" fontSize="12">
{maxDepth}
</text>
<text x="36" y="154" textAnchor="end" fontSize="12">
0
</text>
<text x="44" y="178" fontSize="11">
{time(start)}
</text>
<text x="524" y="178" textAnchor="end" fontSize="11">
{time(metrics.capturedAt)}
</text>
<polyline
points={points.join(' ')}
fill="none"
stroke="#0284c7"
strokeWidth="2"
/>
{metrics.history.map((point, index) => (
<circle
key={point.at}
cx={points[index].split(',')[0]}
cy={points[index].split(',')[1]}
r="2"
fill="#0284c7"
>
<title>
{time(point.at)}: {point.depth} waiting
</title>
</circle>
))}
</svg>
<p className="text-xs text-nb-text-muted">
Peak waiting keys per 10 seconds, up to one hour. Includes delayed
retries; excludes the active attempt.
</p>
</figure>
<figure className="min-w-0">
<figcaption className="text-sm font-medium">
Eligible-to-dequeue wait
</figcaption>
<svg
viewBox="0 0 560 230"
role="img"
aria-label={`Eligible-to-dequeue wait histogram, ${attempts} attempts`}
className="w-full"
>
<line x1="44" y1="30" x2="44" y2="150" stroke="currentColor" />
<line x1="44" y1="150" x2="524" y2="150" stroke="currentColor" />
<text x="36" y="34" textAnchor="end" fontSize="12">
{maxCount}
</text>
<text x="36" y="154" textAnchor="end" fontSize="12">
0
</text>
{metrics.waitHistogram.map((bucket, index) => {
const x = 52 + index * 67
const height = (bucket.count / maxCount) * 120
return (
<g key={index}>
<rect
x={x}
y={150 - height}
width="48"
height={height}
fill="#0284c7"
>
<title>
{labels[index]}: {bucket.count} attempts
</title>
</rect>
<text
x={x + 24}
y={144 - height}
textAnchor="middle"
fontSize="11"
>
{bucket.count}
</text>
<text
transform={`translate(${x + 24},165) rotate(30)`}
textAnchor="start"
fontSize="10"
>
{labels[index]}
</text>
</g>
)
})}
</svg>
<p className="text-xs text-nb-text-muted">
{attempts
? `${attempts} dequeue attempts`
: 'No dequeue attempts yet'}
. Excludes scheduled debounce/backoff, processing time and locks
acquired after dequeue. Retries count separately.
</p>
</figure>
</div>
<p className="mt-3 text-xs text-nb-text-muted">
Last updated {new Date(metrics.capturedAt).toLocaleTimeString()}. Shared
across tabs on this origin. History starts{' '}
{new Date(metrics.startedAt).toLocaleString()} and resets when the
storage worker restarts. Refreshes every 5 seconds.
</p>
</section>
)
}

/** Poll one lightweight owner RPC at a time; stale/unmounted requests cannot update React. */
export function DriveQueueMonitor({
store,
}: {
store: Pick<LocalNotebooks, 'getDriveQueueMetrics'> | null
}) {
const [metrics, setMetrics] = useState<DriveQueueMetrics | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
let timer: ReturnType<typeof setTimeout>
setMetrics(null)
setError(null)
if (!store) return
const refresh = async () => {
try {
const snapshot = await store.getDriveQueueMetrics()
if (!cancelled) {
setMetrics(snapshot)
setError(null)
}
} catch (error) {
if (!cancelled) setError(String(error))
} finally {
if (!cancelled) timer = setTimeout(() => void refresh(), 5_000)
}
}
void refresh()
return () => {
cancelled = true
clearTimeout(timer)
}
}, [store])
if (error)
return (
<p role="status" className="text-sm text-red-700">
Queue monitoring unavailable: {error}
</p>
)
if (!metrics)
return (
<p role="status" className="text-sm text-nb-text-muted">
Loading sync queue monitoring…
</p>
)
return <DriveQueueCharts metrics={metrics} />
}
26 changes: 26 additions & 0 deletions app/src/components/DriveSyncStatusTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { ButtonHTMLAttributes, ElementType, HTMLAttributes } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import type { NotebookSyncStatusRow } from '../storage/local'
import { SyncWorkQueue } from '../storage/syncWorkQueue'
import type { GoogleDriveCredentialStatus } from '../contexts/GoogleAuthContext'

let isDriveSyncing = false
Expand All @@ -33,7 +34,9 @@ const openNotebookMock = vi.fn(async (uri: string) => ({
const setCurrentDocMock = vi.fn()
const showDocumentMock = vi.fn()
const clearLinkedResourceCacheMock = vi.fn(async () => 1536)
const queueMetrics = new SyncWorkQueue().getMetrics()
const storeMock = {
getDriveQueueMetrics: vi.fn(async () => queueMetrics),
listFileSyncStatuses: listFileSyncStatusesMock,
sync: syncMock,
}
Expand Down Expand Up @@ -130,6 +133,29 @@ async function waitForStatusLoad(): Promise<void> {
}

describe('DriveSyncStatusTab', () => {
it('excludes untouched Drive placeholders from bulk sync and allows filtering them', async () => {
listFileSyncStatusesMock.mockResolvedValue([
...rows,
{ ...rows[0], localUri: 'local://file/unopened', title: 'Unopened', syncStatus: 'not-downloaded' },
])
render(<DriveSyncStatusTab />)
await waitForStatusLoad()
fireEvent.click(screen.getByRole('button', { name: 'Sync Required (1)' }))
await waitFor(() => expect(syncMock).toHaveBeenCalledWith('local://file/beta'))
expect(syncMock).not.toHaveBeenCalledWith('local://file/unopened')
fireEvent.click(screen.getByRole('button', { name: 'Filter Sync Status: All statuses' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'Filter Sync Status: not-downloaded' }))
expect(screen.getByText('Unopened')).toBeTruthy()
expect(screen.queryByText('Beta Notebook')).toBeNull()
})

it('includes owner queue monitoring above the file status table', async () => {
render(<DriveSyncStatusTab />)
await waitForStatusLoad()
expect(await screen.findByRole('img', { name: 'Waiting queue depth, peak per ten seconds' })).toBeTruthy()
expect(screen.getByRole('img', { name: 'Eligible-to-dequeue wait histogram, 0 attempts' })).toBeTruthy()
})

beforeEach(() => {
window.localStorage.clear()
isDriveSyncing = true
Expand Down
Loading
Loading