Skip to content

Commit 37adee2

Browse files
committed
Add to diagnostics app
1 parent aab7f3e commit 37adee2

File tree

7 files changed

+238
-34
lines changed

7 files changed

+238
-34
lines changed

packages/common/src/client/ConnectionManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ export class ConnectionManager extends BaseObserver<ConnectionManagerListener> {
277277
const desc = { name, parameters } satisfies SyncStreamDescription;
278278

279279
const waitForFirstSync = (abort?: AbortSignal) => {
280-
return adapter.firstStatusMatching((s) => s.statusFor(desc)?.subscription.hasSynced, abort);
280+
return adapter.firstStatusMatching((s) => s.forStream(desc)?.subscription.hasSynced, abort);
281281
};
282282

283283
return {

packages/common/src/db/crud/SyncStatus.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,14 @@ export class SyncStatus {
123123
* This returns null when the database is currently being opened and we don't have reliable information about all
124124
* included streams yet.
125125
*/
126-
get subscriptions(): SyncStreamStatus[] | undefined {
126+
get syncStreams(): SyncStreamStatus[] | undefined {
127127
return this.options.dataFlow?.internalStreamSubscriptions?.map((core) => new SyncStreamStatusView(this, core));
128128
}
129129

130130
/**
131-
* If the `stream` appears in {@link subscriptions}, returns the current status for that stream.
131+
* If the `stream` appears in {@link syncStreams}, returns the current status for that stream.
132132
*/
133-
statusFor(stream: SyncStreamDescription): SyncStreamStatus | undefined {
133+
forStream(stream: SyncStreamDescription): SyncStreamStatus | undefined {
134134
const asJson = JSON.stringify(stream.parameters);
135135
const raw = this.options.dataFlow?.internalStreamSubscriptions?.find(
136136
(r) => r.name == stream.name && asJson == JSON.stringify(r.parameters)
@@ -280,9 +280,9 @@ class SyncStreamStatusView implements SyncStreamStatus {
280280
active: core.active,
281281
isDefault: core.is_default,
282282
hasExplicitSubscription: core.has_explicit_subscription,
283-
expiresAt: core.expires_at != null ? new Date(core.expires_at) : null,
283+
expiresAt: core.expires_at != null ? new Date(core.expires_at * 1000) : null,
284284
hasSynced: core.last_synced_at != null,
285-
lastSyncedAt: core.last_synced_at != null ? new Date(core.last_synced_at) : null
285+
lastSyncedAt: core.last_synced_at != null ? new Date(core.last_synced_at * 1000) : null
286286
};
287287
}
288288

packages/node/tests/sync-stream.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,16 @@ describe('Sync streams', () => {
6464
);
6565
let status = await statusPromise;
6666
for (const subscription of [a, b]) {
67-
expect(status.statusFor(subscription).subscription.active).toBeTruthy();
68-
expect(status.statusFor(subscription).subscription.lastSyncedAt).toBeNull();
69-
expect(status.statusFor(subscription).subscription.hasExplicitSubscription).toBeTruthy();
67+
expect(status.forStream(subscription).subscription.active).toBeTruthy();
68+
expect(status.forStream(subscription).subscription.lastSyncedAt).toBeNull();
69+
expect(status.forStream(subscription).subscription.hasExplicitSubscription).toBeTruthy();
7070
}
7171

7272
statusPromise = nextStatus(database);
7373
syncService.pushLine({ partial_checkpoint_complete: { last_op_id: '0', priority: 1 } });
7474
status = await statusPromise;
75-
expect(status.statusFor(a).subscription.lastSyncedAt).toBeNull();
76-
expect(status.statusFor(b).subscription.lastSyncedAt).not.toBeNull();
75+
expect(status.forStream(a).subscription.lastSyncedAt).toBeNull();
76+
expect(status.forStream(b).subscription.lastSyncedAt).not.toBeNull();
7777
await b.waitForFirstSync();
7878

7979
syncService.pushLine({ checkpoint_complete: { last_op_id: '0' } });
@@ -94,8 +94,8 @@ describe('Sync streams', () => {
9494
);
9595
let status = await statusPromise;
9696

97-
expect(status.subscriptions).toHaveLength(1);
98-
expect(status.subscriptions[0]).toMatchObject({
97+
expect(status.syncStreams).toHaveLength(1);
98+
expect(status.syncStreams[0]).toMatchObject({
9999
subscription: {
100100
name: 'default_stream',
101101
parameters: null,
@@ -144,6 +144,6 @@ describe('Sync streams', () => {
144144
let statusPromise = nextStatus(database);
145145
const subscription = await database.syncStream('foo').subscribe();
146146
let status = await statusPromise;
147-
expect(status.statusFor(subscription)).not.toBeNull();
147+
expect(status.forStream(subscription)).not.toBeNull();
148148
});
149149
});

tools/diagnostics-app/src/app/views/layout.tsx

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
Typography,
2626
styled
2727
} from '@mui/material';
28-
import React from 'react';
28+
import React, { useMemo } from 'react';
2929

3030
import {
3131
CLIENT_PARAMETERS_ROUTE,
@@ -35,16 +35,16 @@ import {
3535
SYNC_DIAGNOSTICS_ROUTE
3636
} from '@/app/router';
3737
import { useNavigationPanel } from '@/components/navigation/NavigationPanelContext';
38-
import { signOut, sync } from '@/library/powersync/ConnectionManager';
38+
import { signOut, useSyncStatus } from '@/library/powersync/ConnectionManager';
3939
import { usePowerSync } from '@powersync/react';
4040
import { useNavigate } from 'react-router-dom';
4141

4242
export default function ViewsLayout({ children }: { children: React.ReactNode }) {
4343
const powerSync = usePowerSync();
4444
const navigate = useNavigate();
4545

46-
const [syncStatus, setSyncStatus] = React.useState(sync?.syncStatus);
47-
const [syncError, setSyncError] = React.useState<Error | null>(null);
46+
const syncStatus = useSyncStatus();
47+
const syncError = useMemo(() => syncStatus?.dataFlowStatus?.downloadError, [syncStatus]);
4848
const { title } = useNavigationPanel();
4949

5050
const [mobileOpen, setMobileOpen] = React.useState(false);
@@ -99,17 +99,6 @@ export default function ViewsLayout({ children }: { children: React.ReactNode })
9999
[powerSync]
100100
);
101101

102-
// Cannot use `useStatus()`, since we're not using the default sync implementation.
103-
React.useEffect(() => {
104-
const l = sync?.registerListener({
105-
statusChanged: (status) => {
106-
setSyncStatus(status);
107-
setSyncError(status.dataFlowStatus.downloadError ?? null);
108-
}
109-
});
110-
return () => l?.();
111-
}, []);
112-
113102
const drawerWidth = 320;
114103

115104
const drawer = (

tools/diagnostics-app/src/app/views/sync-diagnostics.tsx

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NavigationPage } from '@/components/navigation/NavigationPage';
2-
import { clearData, db, sync } from '@/library/powersync/ConnectionManager';
2+
import { NewStreamSubscription } from '@/components/widgets/NewStreamSubscription';
3+
import { clearData, db, sync, useSyncStatus } from '@/library/powersync/ConnectionManager';
34
import {
45
Box,
56
Button,
@@ -15,7 +16,8 @@ import {
1516
styled
1617
} from '@mui/material';
1718
import { DataGrid, GridColDef } from '@mui/x-data-grid';
18-
import React from 'react';
19+
import { SyncStreamStatus } from '@powersync/web';
20+
import React, { useState } from 'react';
1921

2022
const BUCKETS_QUERY = `
2123
WITH
@@ -295,11 +297,78 @@ export default function SyncDiagnosticsPage() {
295297
</Typography>
296298
{bucketRowsLoading ? <CircularProgress /> : bucketsTable}
297299
</S.QueryResultContainer>
300+
<StreamsState />
298301
</S.MainContainer>
299302
</NavigationPage>
300303
);
301304
}
302305

306+
function StreamsState() {
307+
const syncStreams = useSyncStatus()?.syncStreams;
308+
const [dialogOpen, setDialogOpen] = useState(false);
309+
const syncStreamsLoading = syncStreams == null;
310+
311+
return (
312+
<S.QueryResultContainer>
313+
<Typography variant="h4" gutterBottom>
314+
Sync stream subscriptions
315+
</Typography>
316+
{syncStreamsLoading ? <CircularProgress /> : <StreamsGrid streams={syncStreams} />}
317+
<NewStreamSubscription open={dialogOpen} close={() => setDialogOpen(false)} />
318+
<Button sx={{ margin: '10px' }} variant="contained" onClick={() => setDialogOpen(true)}>
319+
Add subscription
320+
</Button>
321+
</S.QueryResultContainer>
322+
);
323+
}
324+
325+
function StreamsGrid(props: { streams: SyncStreamStatus[] }) {
326+
const columns: GridColDef[] = [
327+
{ field: 'name', headerName: 'Stream name', flex: 2 },
328+
{ field: 'parameters', headerName: 'Parameters', flex: 3, type: 'text' },
329+
{ field: 'default', headerName: 'Default', flex: 1, type: 'boolean' },
330+
{ field: 'active', headerName: 'Active', flex: 1, type: 'boolean' },
331+
{ field: 'has_explicit_subscription', headerName: 'Explicit', flex: 1, type: 'boolean' },
332+
{ field: 'priority', headerName: 'Priority', flex: 1, type: 'number' },
333+
{ field: 'last_synced_at', headerName: 'Last synced at', flex: 2, type: 'dateTime' },
334+
{ field: 'expires', headerName: 'Eviction time', flex: 2, type: 'dateTime' }
335+
];
336+
337+
const rows = props.streams.map((stream) => {
338+
const name = stream.subscription.name;
339+
const parameters = JSON.stringify(stream.subscription.parameters);
340+
341+
return {
342+
id: `${name}-${parameters}`,
343+
name,
344+
parameters,
345+
default: stream.subscription.isDefault,
346+
has_explicit_subscription: stream.subscription.hasExplicitSubscription,
347+
active: stream.subscription.active,
348+
last_synced_at: stream.subscription.lastSyncedAt,
349+
expires: stream.subscription.expiresAt,
350+
priority: stream.priority
351+
};
352+
});
353+
354+
return (
355+
<DataGrid
356+
autoHeight={true}
357+
rows={rows}
358+
columns={columns}
359+
initialState={{
360+
pagination: {
361+
paginationModel: {
362+
pageSize: 10
363+
}
364+
}
365+
}}
366+
pageSizeOptions={[10, 50, 100]}
367+
disableRowSelectionOnClick
368+
/>
369+
);
370+
}
371+
303372
namespace S {
304373
export const MainPaper = styled(Paper)`
305374
margin-bottom: 10px;
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { activeSubscriptions, db } from '@/library/powersync/ConnectionManager';
2+
import Button from '@mui/material/Button';
3+
import Dialog from '@mui/material/Dialog';
4+
import DialogActions from '@mui/material/DialogActions';
5+
import DialogContent from '@mui/material/DialogContent';
6+
import DialogContentText from '@mui/material/DialogContentText';
7+
import DialogTitle from '@mui/material/DialogTitle';
8+
import FormControl from '@mui/material/FormControl';
9+
import InputLabel from '@mui/material/InputLabel';
10+
import MenuItem from '@mui/material/MenuItem';
11+
import Select from '@mui/material/Select';
12+
import Stack from '@mui/material/Stack';
13+
import TextField from '@mui/material/TextField';
14+
import { Formik, FormikErrors } from 'formik';
15+
16+
interface NewStreamSubscriptionValues {
17+
stream: string;
18+
parameters: string;
19+
override_priority: 0 | 1 | 2 | 3 | null;
20+
}
21+
22+
export function NewStreamSubscription(props: { open: boolean; close: () => void }) {
23+
const { open, close } = props;
24+
25+
const validate = (values: NewStreamSubscriptionValues) => {
26+
const errors: FormikErrors<NewStreamSubscriptionValues> = {};
27+
28+
if (values.stream.length == 0) {
29+
errors.stream = 'Stream is required';
30+
}
31+
32+
if (values.parameters.length) {
33+
try {
34+
JSON.parse(values.parameters);
35+
} catch (e) {
36+
errors.parameters = 'Must be empty or a JSON object';
37+
}
38+
}
39+
40+
return errors;
41+
};
42+
43+
const addSubscription = async (values: NewStreamSubscriptionValues) => {
44+
const parameters = values.parameters == '' ? null : JSON.parse(values.parameters);
45+
46+
const subscription = await db
47+
.syncStream(values.stream, parameters)
48+
.subscribe({ priority: values.override_priority ?? undefined });
49+
50+
// We need to store subscriptions globally, because they have a finalizer set on them that would eventually clear
51+
// them otherwise.
52+
activeSubscriptions.push(subscription);
53+
close();
54+
};
55+
56+
return (
57+
<Formik<NewStreamSubscriptionValues>
58+
initialValues={{ stream: '', parameters: '', override_priority: null }}
59+
validateOnChange={true}
60+
onSubmit={addSubscription}
61+
validate={validate}>
62+
{({ values, errors, handleChange, handleBlur, isSubmitting, handleSubmit }) => (
63+
<Dialog onClose={close} open={open}>
64+
<DialogTitle>Subscribe to sync stream</DialogTitle>
65+
<DialogContent>
66+
<form id="subscription-form" onSubmit={handleSubmit}>
67+
<DialogContentText>
68+
Enter stream name and parameters (as a JSON object or an empty string for null) to subscribe to a
69+
stream.
70+
</DialogContentText>
71+
<Stack direction="row" useFlexGap spacing={1} sx={{ mt: 2, mb: 1 }}>
72+
<FormControl sx={{ flex: 2, minWidth: 120 }}>
73+
<TextField
74+
autoFocus
75+
required
76+
label="Stream name"
77+
name="stream"
78+
value={values.stream}
79+
onChange={handleChange}
80+
onBlur={handleBlur}
81+
error={!!errors.stream}
82+
helperText={errors.stream}
83+
/>
84+
</FormControl>
85+
<FormControl sx={{ flex: 1, minWidth: 120 }}>
86+
<InputLabel id="new-stream-priority">Override priority</InputLabel>
87+
<Select
88+
labelId="new-stream-priority"
89+
value={`${values.override_priority}`}
90+
label="Override priority"
91+
name="override_priority"
92+
onChange={handleChange}>
93+
<MenuItem value="null">Use default</MenuItem>
94+
<MenuItem value="0">0</MenuItem>
95+
<MenuItem value="1">1</MenuItem>
96+
<MenuItem value="2">2</MenuItem>
97+
<MenuItem value="3">2</MenuItem>
98+
</Select>
99+
</FormControl>
100+
</Stack>
101+
<Stack direction={'column'} sx={{ mt: 1, mb: 1 }}>
102+
<TextField
103+
label="Parameters"
104+
name="parameters"
105+
value={values.parameters}
106+
onChange={handleChange}
107+
onBlur={handleBlur}
108+
error={!!errors.parameters}
109+
helperText={errors.parameters}
110+
/>
111+
</Stack>
112+
</form>
113+
</DialogContent>
114+
<DialogActions>
115+
<Button onClick={close}>Cancel</Button>
116+
<Button type="submit" form="subscription-form" disabled={isSubmitting}>
117+
Subscribe
118+
</Button>
119+
</DialogActions>
120+
</Dialog>
121+
)}
122+
</Formik>
123+
);
124+
}

0 commit comments

Comments
 (0)