@arcforgelabs/flow 2026.10.53-4 → 2026.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/SECURITY.md +7 -4
- package/npm-shrinkwrap.json +442 -4
- package/package.json +8 -3
- package/server/agents/auth-worker.js +7 -0
- package/server/agents/auth.js +50 -0
- package/server/agents/gate.js +138 -0
- package/server/agents/grants.js +51 -0
- package/server/agents/reads.js +47 -0
- package/server/agents/server.js +74 -0
- package/server/agents/settings.js +24 -0
- package/server/app.js +9 -0
- package/server/core/ledger.js +25 -5
- package/server/db/migrations/0026_bitter_sersi.sql +38 -0
- package/server/db/migrations/meta/0026_snapshot.json +4926 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +34 -0
- package/server/index.js +9 -1
- package/server/integrations/seazona/history/backfill.js +3 -1
- package/server/integrations/seazona/ledger.js +3 -2
- package/server/rate-limit.js +2 -1
- package/server/ui/app.js +74 -62
- package/server/ui/flow.css +36 -5
- package/server/ui/mobile.js +17 -0
- package/server/ui/routes.js +1 -1
- package/server/ui/sync-status.js +67 -0
package/server/db/schema.js
CHANGED
|
@@ -911,3 +911,37 @@ export const captures = pgTable('captures', {
|
|
|
911
911
|
check('captures_sha256_valid', sql`${t.fileSha256} ~ '^[0-9a-f]{64}$'`),
|
|
912
912
|
check('captures_problem_valid', sql`${t.problem} is null or length(${t.problem}) between 1 and 200`),
|
|
913
913
|
]);
|
|
914
|
+
|
|
915
|
+
// A read grant binds one person and client to one installation and organisation.
|
|
916
|
+
export const agentGrants = pgTable('agent_grants', {
|
|
917
|
+
id: id(),
|
|
918
|
+
personId: uuid('person_id').notNull().references(() => people.id, { onDelete: 'cascade' }),
|
|
919
|
+
clientId: text('client_id').notNull(),
|
|
920
|
+
appName: text('app_name').notNull(),
|
|
921
|
+
resource: text('resource').notNull(),
|
|
922
|
+
organisationId: organisationId().references(() => organisations.id),
|
|
923
|
+
accessLevel: text('access_level').notNull().default('read'),
|
|
924
|
+
createdAt: createdAt(),
|
|
925
|
+
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
|
926
|
+
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
|
927
|
+
failureDay: date('failure_day'),
|
|
928
|
+
failureCount: integer('failure_count').notNull().default(0),
|
|
929
|
+
pausedAt: timestamp('paused_at', { withTimezone: true }),
|
|
930
|
+
}, (t) => [
|
|
931
|
+
unique('agent_grants_person_client_resource').on(t.personId, t.clientId, t.resource),
|
|
932
|
+
check('agent_grants_read_only', sql`access_level = 'read'`),
|
|
933
|
+
]);
|
|
934
|
+
|
|
935
|
+
// No arguments, token, source evidence, error text or patient data belongs here.
|
|
936
|
+
// Historical UUID snapshots deliberately have no foreign keys: removing a
|
|
937
|
+
// person or grant must not erase or rewrite the retained read history.
|
|
938
|
+
export const agentAudit = pgTable('agent_audit', {
|
|
939
|
+
id: id(),
|
|
940
|
+
createdAt: createdAt(),
|
|
941
|
+
grantId: uuid('grant_id').notNull(),
|
|
942
|
+
personId: uuid('person_id').notNull(),
|
|
943
|
+
tool: text('tool').notNull(),
|
|
944
|
+
status: integer('status').notNull(),
|
|
945
|
+
rowCount: integer('row_count').notNull().default(0),
|
|
946
|
+
durationMs: integer('duration_ms').notNull(),
|
|
947
|
+
}, (t) => [index('agent_audit_created').on(t.createdAt)]);
|
package/server/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createApp } from './app.js';
|
|
3
|
+
import { createMcpVerifier } from './agents/auth.js';
|
|
4
|
+
import { resolveMcpSettings } from './agents/settings.js';
|
|
3
5
|
import { createClerkProxy, ensureClerkAccount, loadClerkAdapter, resolveClerkProxyPath } from './auth/clerk.js';
|
|
4
6
|
import { seedFirstOwner } from './auth/people.js';
|
|
5
7
|
import { createPersonAuth } from './auth/session.js';
|
|
@@ -31,8 +33,14 @@ const port = Number(process.env.FLOW_PORT || process.env.PORT || 19300);
|
|
|
31
33
|
|
|
32
34
|
// Settings first: a malformed setting stops boot before anything runs.
|
|
33
35
|
let settings;
|
|
36
|
+
let mcp;
|
|
34
37
|
try {
|
|
35
38
|
settings = resolveSignInSettings();
|
|
39
|
+
const mcpSettings = resolveMcpSettings();
|
|
40
|
+
if (mcpSettings) {
|
|
41
|
+
if (settings.mode !== 'clerk') throw new Error('MCP requires FLOW_AUTH=clerk');
|
|
42
|
+
mcp = { ...mcpSettings, publishableKey: settings.keys.publishableKey, verify: createMcpVerifier({ keys: settings.keys, ...mcpSettings }) };
|
|
43
|
+
}
|
|
36
44
|
} catch (error) {
|
|
37
45
|
console.error(`[auth] ${error.message}`);
|
|
38
46
|
process.exit(1);
|
|
@@ -201,7 +209,7 @@ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : fa
|
|
|
201
209
|
const updates = createUpdateChecker();
|
|
202
210
|
|
|
203
211
|
createApp({
|
|
204
|
-
database, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log, tracing, storage,
|
|
212
|
+
database, mcp, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log, tracing, storage,
|
|
205
213
|
publicOrigin: process.env.FLOW_PUBLIC_ORIGIN,
|
|
206
214
|
reader: reader ? { on: true, schedule: reader.schedule } : null,
|
|
207
215
|
}).listen(port, () => {
|
|
@@ -78,7 +78,7 @@ export async function stopBackfill(db, { now = Date.now } = {}) {
|
|
|
78
78
|
const [state] = await db.select().from(seazonaLaneState).where(eq(seazonaLaneState.lane, LANE));
|
|
79
79
|
const cursor = state?.cursor;
|
|
80
80
|
if (!cursor?.batchId || cursor.done) throw Object.assign(new FlowError('conflict', 'No history backfill is running'), { status: 409 });
|
|
81
|
-
const stopped = { ...cursor, done: true, stopped: true };
|
|
81
|
+
const stopped = { ...cursor, done: true, stopped: true, endedAt: at.toISOString() };
|
|
82
82
|
await db.update(seazonaLaneState).set({ cursor: stopped, updatedAt: at }).where(eq(seazonaLaneState.lane, LANE));
|
|
83
83
|
await finishApiBatch(db, cursor.batchId, { at });
|
|
84
84
|
return describeBackfill(stopped);
|
|
@@ -91,6 +91,7 @@ export function describeBackfill(cursor) {
|
|
|
91
91
|
from: cursor.from,
|
|
92
92
|
through: cursor.through,
|
|
93
93
|
startedAt: cursor.startedAt ?? null,
|
|
94
|
+
endedAt: cursor.endedAt ?? null,
|
|
94
95
|
nextDay: cursor.done ? null : cursor.nextDay,
|
|
95
96
|
// The day whose orders it reads now: the last day listed while orders
|
|
96
97
|
// from it wait, else the next day to list.
|
|
@@ -160,6 +161,7 @@ export async function runHistoryBackfill(ctx, { isLaneLevel, describeReadFailure
|
|
|
160
161
|
if (!cursor.pending.length) {
|
|
161
162
|
if (cursor.nextDay > cursor.through) {
|
|
162
163
|
cursor.done = true;
|
|
164
|
+
cursor.endedAt = new Date(at).toISOString();
|
|
163
165
|
break;
|
|
164
166
|
}
|
|
165
167
|
calls += 1;
|
|
@@ -53,8 +53,9 @@ export function toLedgerJob(item) {
|
|
|
53
53
|
// The Seazona order id is the case number. Never patient data.
|
|
54
54
|
caseRef: item.sourceId,
|
|
55
55
|
practitioner: item.doctorName ?? null,
|
|
56
|
-
// The Seazona API gives a shipped status but no shipped date
|
|
57
|
-
|
|
56
|
+
// The Seazona API gives a shipped status but no shipped date: the
|
|
57
|
+
// ledger keeps the day it first sees the job shipped.
|
|
58
|
+
shipped: item.shipped === true,
|
|
58
59
|
// The day of the work, for matching it to its accounting invoice.
|
|
59
60
|
jobDate: item.invoice?.invoiceDate ?? item.dueOn ?? item.orderedOn ?? null,
|
|
60
61
|
// The draft rules bill only the statuses the lab chose (for example Shipped).
|
package/server/rate-limit.js
CHANGED
|
@@ -4,12 +4,13 @@ import expressRateLimit from 'express-rate-limit';
|
|
|
4
4
|
// Express's `trust proxy`). In memory: one Flow process serves one lab. Each
|
|
5
5
|
// call makes its own counter. Built on express-rate-limit, which CodeQL
|
|
6
6
|
// recognises as rate limiting (js/missing-rate-limiting).
|
|
7
|
-
export function rateLimit({ limit, windowMs }) {
|
|
7
|
+
export function rateLimit({ limit, windowMs, ...options }) {
|
|
8
8
|
return expressRateLimit({
|
|
9
9
|
limit,
|
|
10
10
|
windowMs,
|
|
11
11
|
standardHeaders: 'draft-8', // RateLimit-Policy, RateLimit and Retry-After
|
|
12
12
|
legacyHeaders: false,
|
|
13
13
|
handler: (_req, res) => res.status(429).json({ ok: false, code: 'rate_limited', error: 'Too many requests. Wait and try again.' }),
|
|
14
|
+
...options,
|
|
14
15
|
});
|
|
15
16
|
}
|
package/server/ui/app.js
CHANGED
|
@@ -20,6 +20,7 @@ import { renderCustomFields } from '/assets/ui/custom-fields.js';
|
|
|
20
20
|
import { renderJobs } from '/assets/ui/jobs.js';
|
|
21
21
|
import { renderReconcile } from '/assets/ui/reconcile.js';
|
|
22
22
|
import { renderXeroImport } from '/assets/ui/xero-import.js';
|
|
23
|
+
import { syncStatus } from '/assets/ui/sync-status.js';
|
|
23
24
|
import { xeroUsage } from '/assets/ui/xero-usage.js';
|
|
24
25
|
import { startMonitoring } from '/assets/ui/monitoring.js';
|
|
25
26
|
import { isSearchKey, openSearch } from '/assets/ui/search.js';
|
|
@@ -998,74 +999,79 @@ function seazonaHistorySection() {
|
|
|
998
999
|
const canAct = ['owner', 'admin'].includes(me?.person?.role);
|
|
999
1000
|
const yearAgo = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(0, 10);
|
|
1000
1001
|
const from = h('input', { class: 'fx-input fx-history__day', type: 'date', 'aria-label': 'Read from', value: yearAgo, disabled: !canAct });
|
|
1001
|
-
const
|
|
1002
|
-
const
|
|
1003
|
-
const
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1002
|
+
const sync = syncStatus();
|
|
1003
|
+
const fromRow = settingRow({ label: 'Read from', help: 'Reads past Seazona orders into Flow. Orders Flow read before are skipped. Each job waits on hold and never drafts by itself.', control: from });
|
|
1004
|
+
const section = h('section', { class: 'fx-set fx-int-section fx-card fx-history' },
|
|
1005
|
+
h('h2', { class: 'fx-set__title' }, 'History'), fromRow, sync.element);
|
|
1006
|
+
let timer;
|
|
1007
|
+
let pending = false;
|
|
1008
|
+
let run = null;
|
|
1009
|
+
let mounted = false;
|
|
1010
|
+
let disposed = false;
|
|
1011
|
+
let loaded = false;
|
|
1012
|
+
const active = () => ['running', 'waiting'].includes(run?.phase);
|
|
1013
|
+
const schedule = () => {
|
|
1014
|
+
clearTimeout(timer);
|
|
1015
|
+
if (!disposed && !document.hidden && active()) timer = setTimeout(load, 5000);
|
|
1016
|
+
};
|
|
1017
|
+
const press = async (button, label) => {
|
|
1014
1018
|
button.disabled = true;
|
|
1019
|
+
clearTimeout(timer);
|
|
1015
1020
|
try {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1021
|
+
let resume = run?.resumeFrom;
|
|
1022
|
+
if (label === 'Stop' || label === 'Retry') {
|
|
1023
|
+
const stopped = await flow('/api/seazona/history/backfill/stop', { method: 'POST' });
|
|
1024
|
+
resume = stopped.backfill?.resumeFrom;
|
|
1025
|
+
}
|
|
1026
|
+
if (label !== 'Stop') await flow('/api/seazona/history/backfill', {
|
|
1027
|
+
method: 'POST', body: JSON.stringify(label === 'Resume' || label === 'Retry'
|
|
1028
|
+
? { from: resume ?? run.from, through: run.through } : { from: from.value }),
|
|
1029
|
+
});
|
|
1030
|
+
toast(label === 'Stop' ? 'History read stopped.' : 'History read started.', 'success');
|
|
1031
|
+
} catch (error) { toast(error.message, 'danger'); }
|
|
1032
|
+
loaded = false;
|
|
1033
|
+
await load();
|
|
1023
1034
|
};
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill', body()),
|
|
1027
|
-
}, label);
|
|
1035
|
+
let actionLabel;
|
|
1036
|
+
const actionButton = h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: event => press(event.currentTarget, actionLabel) });
|
|
1028
1037
|
const load = async () => {
|
|
1029
1038
|
clearTimeout(timer);
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
let body;
|
|
1039
|
+
if (disposed || pending || document.hidden) return;
|
|
1040
|
+
pending = true;
|
|
1033
1041
|
try {
|
|
1034
|
-
body = await flow('/api/seazona/history');
|
|
1042
|
+
const body = await flow('/api/seazona/history');
|
|
1043
|
+
if (disposed) return;
|
|
1044
|
+
loaded = true;
|
|
1045
|
+
run = body.backfill;
|
|
1046
|
+
fromRow.hidden = Boolean(run && run.phase !== 'done');
|
|
1047
|
+
const label = !run ? 'Start' : ({ running: 'Stop', waiting: 'Stop', stopped: run.resumeFrom ? 'Resume' : 'Again', failed: 'Retry', done: 'Again' }[run.phase]);
|
|
1048
|
+
actionLabel = label;
|
|
1049
|
+
actionButton.textContent = label;
|
|
1050
|
+
actionButton.disabled = !canAct;
|
|
1051
|
+
sync.draw({
|
|
1052
|
+
phase: run?.phase ?? 'not-started', startedAt: run?.startedAt, endedAt: run?.endedAt,
|
|
1053
|
+
count: body.history.jobs, unit: 'jobs', from: run?.from, through: run?.through,
|
|
1054
|
+
at: run?.readingDay ?? run?.resumeFrom ?? run?.through,
|
|
1055
|
+
cause: run?.cause, nextRunAt: run?.nextRunAt,
|
|
1056
|
+
}, actionButton);
|
|
1035
1057
|
} catch (error) {
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
}
|
|
1039
|
-
const run = body.backfill;
|
|
1040
|
-
barRow.hidden = !run;
|
|
1041
|
-
if (run) {
|
|
1042
|
-
const span = Date.parse(run.through) - Date.parse(run.from) + 86_400_000;
|
|
1043
|
-
const at = run.running ? run.readingDay ?? run.through : run.resumeFrom ?? run.through;
|
|
1044
|
-
bar.value = run.running || run.resumeFrom ? Math.min(1, Math.max(0, (Date.parse(at) - Date.parse(run.from)) / span)) : 1;
|
|
1045
|
-
barRow.querySelector('.fx-setting__hint').replaceChildren(`${day(run.from)} to ${day(run.through)}`);
|
|
1046
|
-
}
|
|
1047
|
-
if (run?.running) {
|
|
1048
|
-
const what = { waiting: 'Waiting', failed: 'Failed' }[run.phase] ?? `Reading ${day(run.readingDay ?? run.through)}`;
|
|
1049
|
-
const reading = `${what} · ${run.ordersRead} read${run.waitingToRead ? ` · ${run.waitingToRead} to read` : ''}`;
|
|
1050
|
-
const next = h('span', { class: 'fx-dim' });
|
|
1051
|
-
const count = () => {
|
|
1052
|
-
const left = Math.round((Date.parse(run.nextRunAt) - Date.now()) / 1000);
|
|
1053
|
-
next.replaceChildren(left > 0 ? `· ${run.phase === 'running' ? 'next' : 'retry'} in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}` : '· reading now');
|
|
1054
|
-
};
|
|
1055
|
-
if (run.nextRunAt && run.phase !== 'failed') { count(); tick = setInterval(count, 1000); }
|
|
1056
|
-
status.replaceChildren(
|
|
1057
|
-
run.phase === 'running' ? h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }) : '',
|
|
1058
|
-
reading, next, run.cause ? h('span', { class: 'fx-dim' }, `· ${run.cause}`) : '');
|
|
1059
|
-
control.replaceChildren(h('button', { class: 'fx-btn fx-btn--sm', type: 'button', disabled: !canAct, onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill/stop') }, 'Stop'));
|
|
1060
|
-
} else if (run?.resumeFrom) {
|
|
1061
|
-
status.replaceChildren(`Stopped at ${day(run.resumeFrom)} · ${run.ordersRead} read`);
|
|
1062
|
-
control.replaceChildren(from, start('Start', () => ({ from: from.value }), false), start('Resume', () => ({ from: run.resumeFrom, through: run.through })));
|
|
1063
|
-
} else {
|
|
1064
|
-
status.replaceChildren(run ? `Done · ${run.ordersRead} read` : 'Not started');
|
|
1065
|
-
control.replaceChildren(from, start('Start', () => ({ from: from.value })));
|
|
1066
|
-
}
|
|
1067
|
-
timer = setTimeout(() => { if (section.isConnected && run?.running) load(); }, 5_000);
|
|
1058
|
+
sync.draw({ phase: 'failed', cause: 'Could not check progress. Try again.' }, h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: load }, 'Retry'));
|
|
1059
|
+
} finally { pending = false; schedule(); }
|
|
1068
1060
|
};
|
|
1061
|
+
const visible = () => { clearTimeout(timer); if (!document.hidden && (!loaded || active())) load(); };
|
|
1062
|
+
document.addEventListener('visibilitychange', visible);
|
|
1063
|
+
const observer = new MutationObserver(() => {
|
|
1064
|
+
if (section.isConnected) mounted = true;
|
|
1065
|
+
else if (mounted) {
|
|
1066
|
+
disposed = true;
|
|
1067
|
+
clearTimeout(timer);
|
|
1068
|
+
sync.dispose();
|
|
1069
|
+
document.removeEventListener('visibilitychange', visible);
|
|
1070
|
+
observer.disconnect();
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
1074
|
+
sync.draw({ phase: 'loading' });
|
|
1069
1075
|
load();
|
|
1070
1076
|
return section;
|
|
1071
1077
|
}
|
|
@@ -1331,11 +1337,17 @@ drawModeToggles();
|
|
|
1331
1337
|
// ── Start ──────────────────────────────────────────────────────────────────
|
|
1332
1338
|
|
|
1333
1339
|
// Clerk's words where they name the app: the installation is Flow, whatever
|
|
1334
|
-
// the Clerk instance is called.
|
|
1340
|
+
// the Clerk instance is called. With sign-up on, Clerk shows the combined
|
|
1341
|
+
// sign-in-or-up form, which reads the *Combined keys.
|
|
1335
1342
|
const LOCALIZATION = {
|
|
1336
1343
|
signIn: {
|
|
1337
1344
|
// titleCombined is the sign-in-or-sign-up screen (public sign-up on).
|
|
1338
|
-
start: {
|
|
1345
|
+
start: {
|
|
1346
|
+
title: 'Sign in to Flow',
|
|
1347
|
+
subtitle: 'Choose how to sign in.',
|
|
1348
|
+
titleCombined: 'Sign in to Flow',
|
|
1349
|
+
subtitleCombined: 'Choose how to sign in.',
|
|
1350
|
+
},
|
|
1339
1351
|
password: { subtitle: 'Enter the password for your account.' },
|
|
1340
1352
|
},
|
|
1341
1353
|
signUp: { start: { title: 'Ask for access to Flow', subtitle: 'Use your work email. An owner approves new people.' } },
|
package/server/ui/flow.css
CHANGED
|
@@ -639,9 +639,13 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
639
639
|
.fx-pop__tick { display: inline-grid; place-items: center; flex: none; width: 16px; height: 16px; color: var(--t-accent); }
|
|
640
640
|
.fx-pop__sep { height: 1px; margin: 6px 4px; background: var(--fx-line-soft); }
|
|
641
641
|
/* A row with a trailing control (the sidebar pin). The control does not
|
|
642
|
-
close the menu. An unpinned pin shows when the pointer is on the row.
|
|
643
|
-
|
|
642
|
+
close the menu. An unpinned pin shows when the pointer is on the row.
|
|
643
|
+
The whole row lights on hover; the pin under the pointer goes back to the
|
|
644
|
+
menu's own colour, so both read. */
|
|
645
|
+
.fx-pop__row { display: flex; align-items: center; gap: 2px; padding-right: 4px; border-radius: var(--t-radius); }
|
|
646
|
+
.fx-pop__row:hover, .fx-pop__row:has(:focus-visible) { background: var(--fx-hover); }
|
|
644
647
|
.fx-pop__row > .fx-pop__item { flex: 1; min-width: 0; }
|
|
648
|
+
.fx-pop__row > .fx-pop__item:is(:hover, :focus-visible) { background: none; }
|
|
645
649
|
.fx-pop__row > .fx-pop__item > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
646
650
|
.fx-pop__pin, .fx-pop__pin--fixed {
|
|
647
651
|
display: inline-grid; place-items: center; flex: none; width: 28px; height: 28px;
|
|
@@ -654,13 +658,14 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
654
658
|
.fx-pop__row:focus-within .fx-pop__pin[aria-pressed="false"],
|
|
655
659
|
.fx-pop__pin:focus-visible { opacity: 1; }
|
|
656
660
|
.fx-pop__pin[aria-pressed="true"], .fx-pop__pin--fixed { color: var(--t-strong); opacity: 1; }
|
|
657
|
-
.fx-pop__pin:hover, .fx-pop__pin:focus-visible { background: var(--
|
|
661
|
+
.fx-pop__pin:hover, .fx-pop__pin:focus-visible { background: var(--t-paper); color: var(--t-strong); outline: none; }
|
|
658
662
|
.fx-pop.fx-pop--side { min-width: 240px; }
|
|
659
663
|
.fx-pop--side .fx-pop__tick { color: inherit; }
|
|
660
664
|
@media (hover: none) { .fx-pop__pin[aria-pressed="false"] { opacity: 1; } }
|
|
661
665
|
@media (max-width: 820px) {
|
|
662
666
|
.fx-pop--side .fx-pop__item { min-height: 44px; }
|
|
663
667
|
.fx-pop--side .fx-pop__pin, .fx-pop--side .fx-pop__pin--fixed { width: 44px; height: 44px; }
|
|
668
|
+
.fx-pop--side .fx-pop__row { padding-right: 0; }
|
|
664
669
|
}
|
|
665
670
|
.fx-pop__foot { display: flex; justify-content: flex-end; padding: 6px 4px 2px; border-top: 1px solid var(--fx-line-soft); margin-top: 6px; }
|
|
666
671
|
/* A chip with an × that clears it (as ClickUp's "Clear all filters"). */
|
|
@@ -1287,9 +1292,32 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1287
1292
|
.fx-setting__head { display: flex; align-items: center; gap: 2px; min-width: 0; }
|
|
1288
1293
|
.fx-setting__head .fx-help { margin: -4px 0; border: 0; background: none; cursor: pointer; }
|
|
1289
1294
|
.fx-setting__hint { margin: 0; color: var(--t-dim); font-size: 13px; line-height: 1.45; }
|
|
1290
|
-
.fx-history__status { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
1291
1295
|
.fx-history__day { width: 160px; flex: none; }
|
|
1292
|
-
|
|
1296
|
+
/* SyncStatus: Layers, shared by integration history readers. */
|
|
1297
|
+
.fx-history { padding: 8px 20px 16px; }
|
|
1298
|
+
.fx-sync { min-width: 0; padding-block: 8px; }
|
|
1299
|
+
.fx-sync__line { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
|
|
1300
|
+
.fx-sync__status { min-width: 0; overflow-wrap: anywhere; color: var(--t-text); font-size: 13px; }
|
|
1301
|
+
.fx-sync__word { position: relative; display: inline-block; overflow: hidden; vertical-align: bottom; }
|
|
1302
|
+
.fx-sync[data-phase="running"] .fx-sync__word::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, var(--t-paper), transparent); opacity: .5; transform: translateX(-100%); animation: fx-sync-sweep 2.6s linear infinite; }
|
|
1303
|
+
.fx-sync[data-phase="failed"] .fx-sync__word { color: var(--t-danger); }
|
|
1304
|
+
.fx-sync[data-phase="done"] .fx-sync__word { color: var(--t-success); }
|
|
1305
|
+
.fx-sync__clock { font: 12px var(--t-data); color: var(--t-dim); }
|
|
1306
|
+
.fx-sync__action { margin-left: auto; }
|
|
1307
|
+
.fx-sync__action .fx-btn { min-width: 44px; min-height: 44px; }
|
|
1308
|
+
.fx-sync__dots { display: inline-flex; gap: 3px; margin-right: 8px; vertical-align: middle; }
|
|
1309
|
+
.fx-sync__dots[hidden], .fx-sync__track[hidden] { display: none; }
|
|
1310
|
+
.fx-sync__dots i { width: 4px; height: 4px; border-radius: var(--t-radius); background: var(--t-accent); animation: fx-sync-breathe 1.8s ease-in-out infinite; }
|
|
1311
|
+
.fx-sync__dots i:nth-child(2) { animation-delay: .2s; }
|
|
1312
|
+
.fx-sync__dots i:nth-child(3) { animation-delay: .4s; }
|
|
1313
|
+
.fx-sync__track { display: grid; gap: 4px; margin-top: 8px; }
|
|
1314
|
+
.fx-sync__rail { width: 100%; height: 4px; accent-color: var(--t-brand); }
|
|
1315
|
+
.fx-sync__range { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; color: var(--t-dim); font-size: 12px; }
|
|
1316
|
+
@media (max-width: 639px) {
|
|
1317
|
+
.fx-sync__line { gap: 4px 8px; }
|
|
1318
|
+
.fx-sync__status { flex: 1 1 60%; }
|
|
1319
|
+
.fx-sync__clock { order: 1; flex-basis: 100%; }
|
|
1320
|
+
}
|
|
1293
1321
|
.fx-setting[data-tone="danger"] .fx-setting__hint { color: var(--t-danger); }
|
|
1294
1322
|
.fx-setting__control { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
|
|
1295
1323
|
.fx-setting__control > .fx-input, .fx-setting__control > .fx-select, .fx-setting__stack { width: 320px; max-width: 100%; }
|
|
@@ -1539,9 +1567,12 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1539
1567
|
}
|
|
1540
1568
|
|
|
1541
1569
|
@keyframes fx-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
|
1570
|
+
@keyframes fx-sync-breathe { 50% { opacity: .35; transform: scale(.75); } }
|
|
1571
|
+
@keyframes fx-sync-sweep { to { transform: translateX(100%); } }
|
|
1542
1572
|
@keyframes fx-spin { to { transform: rotate(360deg); } }
|
|
1543
1573
|
@keyframes fx-shimmer { to { background-position: -200% 0; } }
|
|
1544
1574
|
@media (prefers-reduced-motion: reduce) {
|
|
1575
|
+
.fx-sync__word::after { display: none; }
|
|
1545
1576
|
*, *::before, *::after { animation: none !important; transition: none !important; }
|
|
1546
1577
|
}
|
|
1547
1578
|
|
package/server/ui/mobile.js
CHANGED
|
@@ -618,6 +618,23 @@ export const RULES = {
|
|
|
618
618
|
],
|
|
619
619
|
"card": "Sidecar"
|
|
620
620
|
},
|
|
621
|
+
"syncStatus": {
|
|
622
|
+
"component": "syncStatus",
|
|
623
|
+
"direction": "Layers",
|
|
624
|
+
"phone": {
|
|
625
|
+
"layout": "wrap"
|
|
626
|
+
},
|
|
627
|
+
"tablet": {
|
|
628
|
+
"layout": "row"
|
|
629
|
+
},
|
|
630
|
+
"desk": {
|
|
631
|
+
"layout": "row"
|
|
632
|
+
},
|
|
633
|
+
"touch": 44,
|
|
634
|
+
"pageOverflow": "none",
|
|
635
|
+
"screens": [],
|
|
636
|
+
"card": "SyncStatus"
|
|
637
|
+
},
|
|
621
638
|
"fx-tabs": {
|
|
622
639
|
"component": "fx-tabs",
|
|
623
640
|
"direction": "scrolling tabs",
|
package/server/ui/routes.js
CHANGED
|
@@ -22,7 +22,7 @@ const themeDir = join(here, '..', 'theme');
|
|
|
22
22
|
const logoDir = join(here, 'logos');
|
|
23
23
|
const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
|
|
24
24
|
const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
|
|
25
|
-
export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'xero-contact.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'captures.js', 'capture-fields.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
|
|
25
|
+
export const UI_FILES = new Set(['sync-status.js', 'flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'xero-contact.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'captures.js', 'capture-fields.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
|
|
26
26
|
const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
|
|
27
27
|
const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
|
|
28
28
|
const MAX_AGE = '1h';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { h } from './kit.js';
|
|
2
|
+
import { applyPhoneRule } from './mobile.js';
|
|
3
|
+
|
|
4
|
+
const DAY = 86_400_000;
|
|
5
|
+
const words = ['Polishing', 'Glazing', 'Milling', 'Sintering'];
|
|
6
|
+
const date = value => value ? new Date(`${value}T00:00:00Z`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', timeZone: 'UTC' }) : '';
|
|
7
|
+
const duration = ms => { const seconds = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; };
|
|
8
|
+
|
|
9
|
+
// Neutral, read-only view of server state. No inferred event history. The caller
|
|
10
|
+
// owns actions and polling; dispose stops the display clock when unmounted.
|
|
11
|
+
export function syncStatus() {
|
|
12
|
+
const word = h('span', { class: 'fx-sync__word' });
|
|
13
|
+
const dots = h('span', { class: 'fx-sync__dots', 'aria-hidden': 'true' }, ...[0, 1, 2].map(() => h('i')));
|
|
14
|
+
const result = h('span');
|
|
15
|
+
const status = h('span', { class: 'fx-sync__status', role: 'status', 'aria-live': 'polite' }, dots, word, result);
|
|
16
|
+
const clock = h('span', { class: 'fx-sync__clock', 'aria-label': 'Elapsed', 'aria-live': 'off' });
|
|
17
|
+
const action = h('span', { class: 'fx-sync__action' });
|
|
18
|
+
const rail = h('progress', { class: 'fx-sync__rail', max: 1, value: 0, 'aria-label': 'Days read' });
|
|
19
|
+
const range = h('span');
|
|
20
|
+
const eta = h('span');
|
|
21
|
+
const track = h('div', { class: 'fx-sync__track' }, rail, h('span', { class: 'fx-sync__range' }, range, eta));
|
|
22
|
+
const element = applyPhoneRule(h('div', { class: 'fx-sync' }, h('div', { class: 'fx-sync__line' }, status, clock, action), track), 'syncStatus');
|
|
23
|
+
let current = {};
|
|
24
|
+
let timer;
|
|
25
|
+
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
|
26
|
+
const updateClock = () => {
|
|
27
|
+
const { phase, startedAt, endedAt, nextRunAt, count, from, through, at } = current;
|
|
28
|
+
const active = ['running', 'waiting'].includes(phase);
|
|
29
|
+
const elapsed = (active ? Date.now() : Date.parse(endedAt)) - Date.parse(startedAt);
|
|
30
|
+
clock.textContent = Number.isFinite(elapsed) ? duration(elapsed) : '';
|
|
31
|
+
// Rotating words and ticking clocks are decorative, not live announcements.
|
|
32
|
+
if (phase === 'running' && !count) word.textContent = reduced.matches ? 'Reading' : words[Math.floor(Math.max(0, elapsed) / 2600) % words.length] ?? 'Reading';
|
|
33
|
+
eta.textContent = '';
|
|
34
|
+
if (phase === 'waiting' && nextRunAt) {
|
|
35
|
+
const seconds = Math.max(0, Math.ceil((Date.parse(nextRunAt) - Date.now()) / 1000));
|
|
36
|
+
clock.textContent = `${clock.textContent}${clock.textContent ? ' · ' : ''}${seconds}s until retry`;
|
|
37
|
+
}
|
|
38
|
+
const completed = Date.parse(at) - Date.parse(from);
|
|
39
|
+
const total = Date.parse(through) - Date.parse(from) + DAY;
|
|
40
|
+
if (phase === 'running' && count > 0 && completed > 0 && total > completed && elapsed > 0) {
|
|
41
|
+
eta.textContent = `about ${Math.max(1, Math.ceil(elapsed * (total - completed) / completed / 60000))} min left`;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const draw = (state, button = null) => {
|
|
45
|
+
current = state;
|
|
46
|
+
const { phase = 'not-started', count, unit = 'jobs', from, through, at, cause } = state;
|
|
47
|
+
element.dataset.phase = phase;
|
|
48
|
+
const running = phase === 'running';
|
|
49
|
+
dots.hidden = !running || count > 0;
|
|
50
|
+
word.setAttribute('aria-hidden', String(running && !count));
|
|
51
|
+
status.setAttribute('aria-label', running && !count ? 'Reading' : '');
|
|
52
|
+
word.textContent = running ? (count > 0 ? `Reading${at ? ` ${date(at)}` : ''}` : 'Reading') : ({ waiting: 'Waiting', stopped: 'Stopped', failed: 'Failed', done: 'Done', loading: 'Checking…' }[phase] ?? 'Not started');
|
|
53
|
+
result.textContent = `${count != null && (count > 0 || !running) ? ` · ${count.toLocaleString('en-AU')} ${unit}` : ''}${cause ? ` · ${cause}` : ''}${phase === 'stopped' && at ? ` · ${date(at)}` : ''}`;
|
|
54
|
+
if (action.firstChild !== button) action.replaceChildren(...(button ? [button] : []));
|
|
55
|
+
const total = Date.parse(through) - Date.parse(from) + DAY;
|
|
56
|
+
track.hidden = !(count > 0 && total > 0);
|
|
57
|
+
rail.value = phase === 'done' ? 1 : Math.min(1, Math.max(0, (Date.parse(at) - Date.parse(from)) / total || 0));
|
|
58
|
+
range.textContent = track.hidden ? '' : `${date(from)} – ${date(through)}`;
|
|
59
|
+
clearInterval(timer);
|
|
60
|
+
updateClock();
|
|
61
|
+
if (['running', 'waiting'].includes(phase)) timer = setInterval(() => {
|
|
62
|
+
if (!element.isConnected) { clearInterval(timer); return; }
|
|
63
|
+
if (!document.hidden) updateClock();
|
|
64
|
+
}, 1000);
|
|
65
|
+
};
|
|
66
|
+
return { element, draw, dispose: () => clearInterval(timer) };
|
|
67
|
+
}
|