@arcforgelabs/flow 2026.10.33 → 2026.10.34

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.
Files changed (33) hide show
  1. package/README.md +6 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +1 -1
  4. package/server/api/custom-fields.js +114 -0
  5. package/server/api/invoicing.js +2 -1
  6. package/server/api/work-moves.js +80 -0
  7. package/server/app.js +12 -0
  8. package/server/core/custom-fields.js +300 -0
  9. package/server/core/job-board.js +2 -0
  10. package/server/core/tracing.js +64 -0
  11. package/server/db/migrations/0018_custom_fields.sql +78 -0
  12. package/server/db/migrations/meta/0018_snapshot.json +4289 -0
  13. package/server/db/migrations/meta/_journal.json +7 -0
  14. package/server/db/schema.js +59 -0
  15. package/server/index.js +6 -1
  16. package/server/integrations/port.js +7 -1
  17. package/server/integrations/seazona/index.js +47 -1
  18. package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
  19. package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
  20. package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
  21. package/server/integrations/seazona/schema.js +26 -0
  22. package/server/integrations/seazona/settings.js +14 -0
  23. package/server/integrations/seazona/stage-moves.js +135 -0
  24. package/server/integrations/seazona/tables.js +2 -0
  25. package/server/integrations/sentry/index.js +61 -10
  26. package/server/ui/app-page.js +1 -1
  27. package/server/ui/app.js +40 -2
  28. package/server/ui/custom-fields.js +190 -0
  29. package/server/ui/flow.css +48 -9
  30. package/server/ui/invoicing.js +83 -5
  31. package/server/ui/jobs.js +3 -1
  32. package/server/ui/monitoring.js +44 -2
  33. package/server/ui/routes.js +1 -1
@@ -439,14 +439,23 @@ a.fx-strong:hover { text-decoration: underline; }
439
439
  .fx-toolbar--plain .fx-search { margin-bottom: 0; }
440
440
  .fx-empty .fx-btn { margin-top: 12px; }
441
441
  .fx-panel.fx-empty { display: grid; justify-items: center; }
442
+ .fx-toolbar--end { justify-content: flex-end; }
442
443
  /* The board never scrolls sideways: its columns (one per work system status)
443
- wrap onto more rows when the page is narrow, and stack on a phone. A long
444
- column scrolls inside itself. */
445
- .fx-board { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 210px), 1fr)); gap: 10px; align-items: start; }
446
- .fx-board__col { display: grid; gap: 8px; align-content: start; min-width: 0; padding: 10px 8px 8px; border-radius: var(--t-radius-lg); background: var(--t-s1); }
447
- .fx-board__head { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 2px 2px 4px; }
444
+ are about a phone wide, wrap onto more rows when the page is narrow, and
445
+ stack on a phone. On a wide, tall screen the board fills the page down to
446
+ its bottom edge, and each column scrolls inside itself (jobs.js sets
447
+ fx-main--board while the board shows). */
448
+ .fx-board { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 256px), 1fr)); gap: 12px; align-items: start; }
449
+ .fx-board__col { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; padding: 10px 6px 6px 8px; border-radius: var(--t-radius-lg); background: var(--t-s1); }
450
+ .fx-board__head { display: flex; flex: none; align-items: center; gap: 8px; min-width: 0; padding: 2px 4px 4px 2px; }
448
451
  .fx-board__sum { flex: none; margin-left: auto; color: var(--t-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
449
- .fx-board__cards { display: grid; gap: 8px; max-height: 70vh; overflow-y: auto; }
452
+ .fx-board__cards { display: grid; gap: 8px; align-content: start; min-height: 0; max-height: 70vh; padding-right: 2px; overflow-y: auto; scrollbar-width: thin; }
453
+ @media (min-width: 821px) and (min-height: 560px) {
454
+ .fx-main--board { height: 100vh; height: 100dvh; padding: 24px 24px 16px; grid-template-rows: auto auto minmax(0, 1fr); align-content: stretch; }
455
+ .fx-main--board > .fx-jobs { min-height: 0; }
456
+ .fx-main--board .fx-board { height: 100%; overflow-y: auto; grid-auto-rows: 100%; align-items: stretch; }
457
+ .fx-main--board .fx-board__cards { flex: 1; max-height: none; }
458
+ }
450
459
  .fx-board__none { padding: 8px 4px; color: var(--t-dim); font-size: 13px; }
451
460
  .fx-table--jobs tbody tr { cursor: pointer; }
452
461
 
@@ -476,9 +485,10 @@ a.fx-strong:hover { text-decoration: underline; }
476
485
  .fx-job { position: relative; display: grid; min-width: 0; padding: 11px 12px 9px; border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); box-shadow: var(--t-shadow); font-size: 13px; cursor: pointer; }
477
486
  .fx-job:hover { border-color: var(--t-brd-hi, var(--fx-line)); }
478
487
  .fx-job:focus-visible { outline: 2px solid var(--t-ring, var(--t-accent)); outline-offset: 2px; }
479
- .fx-job__client { display: -webkit-box; overflow: hidden; color: var(--t-strong); font-size: 14px; font-weight: 600; line-height: 1.3; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
480
- .fx-job__who { display: flex; align-items: center; gap: 6px; min-width: 0; margin-top: 5px; color: var(--t-dim); font-size: 12.5px; }
481
- .fx-job__who span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
488
+ .fx-job__client { color: var(--t-strong); font-size: 14px; font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; }
489
+ .fx-job__who { display: flex; align-items: flex-start; gap: 6px; min-width: 0; margin-top: 5px; color: var(--t-dim); font-size: 12.5px; }
490
+ .fx-job__who span { min-width: 0; overflow-wrap: anywhere; }
491
+ .fx-job__who svg { margin-top: 2px; }
482
492
  .fx-job__who svg, .fx-job__date svg { flex: none; color: var(--t-mute); }
483
493
  .fx-job__foot { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--fx-line-soft); color: var(--t-dim); font-size: 12px; }
484
494
  .fx-job__date { display: inline-flex; align-items: center; gap: 5px; min-width: 0; overflow: hidden; white-space: nowrap; }
@@ -755,6 +765,18 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
755
765
  .fx-price__systems { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; }
756
766
  .fx-set__control .fx-price__link { width: 260px; min-width: 0; }
757
767
  @media (max-width: 560px) { .fx-set__control .fx-price__link { width: 150px; } }
768
+ /* Custom fields (custom-fields.js): option chips with a colour dot, and the
769
+ dialog's option lines. The option colours are fixed, like ClickUp's. */
770
+ .fx-cf__opt { display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px; border: 1px solid var(--fx-line); border-radius: 999px; font-size: 12.5px; color: var(--t-text); white-space: nowrap; }
771
+ .fx-cf__opt::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--cf); flex: none; }
772
+ [data-color="gray"] { --cf: #8a8f98; } [data-color="blue"] { --cf: #3b82f6; } [data-color="teal"] { --cf: #14b8a6; }
773
+ [data-color="green"] { --cf: #22a35a; } [data-color="amber"] { --cf: #e0a019; } [data-color="red"] { --cf: #e5484d; }
774
+ [data-color="purple"] { --cf: #8b5cf6; } [data-color="pink"] { --cf: #e5469a; }
775
+ .fx-cf__options { display: grid; gap: 8px; }
776
+ .fx-cf__line { display: grid; grid-template-columns: minmax(0, 1fr) 120px auto auto; gap: 8px; align-items: center; }
777
+ .fx-cf__line .fx-input, .fx-cf__line .fx-select { width: 100%; min-width: 0; }
778
+ .fx-cf__color { border-left: 4px solid var(--cf); }
779
+ @media (max-width: 560px) { .fx-cf__line { grid-template-columns: minmax(0, 1fr) 96px; } }
758
780
  /* Invoice rules (invoice-rules.js): a row's Edit button and switch, and the
759
781
  rule dialog's condition lines. */
760
782
  .fx-rule__controls { align-items: center; gap: 10px; }
@@ -827,3 +849,20 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
827
849
  .fx-pager__step { display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 1px solid var(--fx-line-soft); border-radius: 999px; background: transparent; color: var(--t-text); cursor: pointer; transition: background 120ms var(--fx-ease), border-color 120ms var(--fx-ease); }
828
850
  .fx-pager__step:hover:not(:disabled) { background: var(--fx-hover); border-color: var(--fx-line); }
829
851
  .fx-pager__step:disabled { opacity: 0.35; cursor: default; }
852
+
853
+ /* New invoice from a job: a quiet strip above the form, and a searchable
854
+ list of jobs with no invoice. */
855
+ .fx-fromjob {
856
+ display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px;
857
+ padding: 12px 16px; border: 1px dashed var(--fx-line); border-radius: var(--t-radius);
858
+ }
859
+ .fx-fromjob__text { display: grid; gap: 2px; font-size: 14px; }
860
+ .fx-dialog:has(.fx-pick__body) { width: min(620px, calc(100vw - 32px)); }
861
+ .fx-pick__body { display: grid; gap: 12px; }
862
+ .fx-pick { display: grid; max-height: min(420px, 55vh); overflow-y: auto; border: 1px solid var(--fx-line); border-radius: var(--t-radius); }
863
+ .fx-pick__row { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--fx-line); cursor: pointer; }
864
+ .fx-pick__row:last-child { border-bottom: 0; }
865
+ .fx-pick__row:hover, .fx-pick__row:has(input:checked) { background: var(--fx-hover); }
866
+ .fx-pick__row[hidden] { display: none; }
867
+ .fx-pick__main { display: grid; gap: 2px; min-width: 0; }
868
+ .fx-pick > .fx-dim { padding: 14px; }
@@ -226,9 +226,13 @@ export async function renderInvoiceList(main, ui) {
226
226
  // The invoice page steps through this list, in this order (navBar).
227
227
  saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
228
228
  const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY }); go(href); };
229
+ const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
229
230
  const ticks = TICKS[state.status];
230
231
  const showProblem = state.status === 'problem';
231
232
  const created = ['to_check', 'problem', 'ready'].includes(state.status);
233
+ // An invoice gets its number when it is sent or issued, so only the
234
+ // Issued and Void tabs show a Number column; the others open on Client.
235
+ const numbered = !created;
232
236
  const boxes = [];
233
237
  const tickBox = (invoice) => {
234
238
  const box = h('input', {
@@ -252,7 +256,7 @@ export async function renderInvoiceList(main, ui) {
252
256
  host.replaceChildren(h('table', { class: 'fx-table fx-table--rows' },
253
257
  h('thead', {}, h('tr', {},
254
258
  ticks ? h('th', { class: 'fx-tick-cell' }, all) : null,
255
- h('th', {}, 'Number'), h('th', {}, 'Client'), h('th', {}, showProblem ? 'Problem' : 'Reference'),
259
+ numbered ? h('th', {}, 'Number') : null, h('th', {}, 'Client'), h('th', {}, showProblem ? 'Problem' : 'Reference'),
256
260
  h('th', {}, created ? 'Created' : 'Issued'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'))),
257
261
  h('tbody', {}, body.invoices.map((invoice) => {
258
262
  const href = `/invoices/${invoice.id}`;
@@ -260,8 +264,8 @@ export async function renderInvoiceList(main, ui) {
260
264
  const view = invoice.status === 'void' ? null : invoice.view;
261
265
  return h('tr', { tabindex: '0', onclick: () => open(href), onkeydown: (event) => { if (event.key === 'Enter') open(href); } },
262
266
  ticks ? h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)) : null,
263
- h('td', {}, h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, invoice.number ?? 'Draft')),
264
- h('td', {}, invoice.contactName ?? '—'),
267
+ numbered ? h('td', {}, link(href, invoice.number ?? 'Draft')) : null,
268
+ h('td', {}, numbered ? (invoice.contactName ?? '—') : link(href, invoice.contactName ?? 'No client')),
265
269
  showProblem
266
270
  ? h('td', { class: 'fx-problem' }, first?.message ?? '—', invoice.problems.length > 1 ? h('span', { class: 'fx-dim' }, ` +${invoice.problems.length - 1} more`) : null)
267
271
  : h('td', { class: 'fx-dim' }, invoice.reference ?? '—'),
@@ -389,6 +393,8 @@ function draftView(invoice, ui, context, redraw) {
389
393
  const lines = (invoice?.lines ?? []).map((line) => ({ ...line }));
390
394
  if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null });
391
395
  let contact = invoice?.contact ?? null;
396
+ // Issue invoice waits for a client and a due date (syncIssue, below).
397
+ let onContact = () => {};
392
398
 
393
399
  // Client: type to find a customer.
394
400
  const clientInput = h('input', { class: 'fx-input', type: 'text', autocomplete: 'off', placeholder: 'Type a client name', value: contact?.name ?? '', 'aria-label': 'Client' });
@@ -410,6 +416,7 @@ function draftView(invoice, ui, context, redraw) {
410
416
  contact = c;
411
417
  clientInput.value = c.name;
412
418
  clientList.hidden = true;
419
+ onContact();
413
420
  },
414
421
  }, c.name, c.email ? h('span', { class: 'fx-dim' }, c.email) : null)) : [h('p', { class: 'fx-dim fx-suggest__none' }, 'No client by that name.')]));
415
422
  clientList.hidden = false;
@@ -420,7 +427,7 @@ function draftView(invoice, ui, context, redraw) {
420
427
  });
421
428
  clientInput.addEventListener('blur', () => { setTimeout(() => { clientList.hidden = true; }, 120); });
422
429
 
423
- const reference = h('input', { class: 'fx-input', type: 'text', value: invoice?.reference ?? '', placeholder: 'Case or order number' });
430
+ const reference = h('input', { class: 'fx-input', type: 'text', value: invoice?.reference ?? '', placeholder: 'Practitioner - Patient' });
424
431
  const dueDate = h('input', { class: 'fx-input', type: 'date', value: invoice?.dueDate ?? '' });
425
432
 
426
433
  // Lines.
@@ -555,7 +562,7 @@ function draftView(invoice, ui, context, redraw) {
555
562
  // With an accounting system on, a ready invoice is sent there: Flow issues
556
563
  // it and queues it, the same as Send all ready. With none, Flow issues it
557
564
  // and keeps it. A draft that is not ready has no send: mark it ready first.
558
- const to = invoice?.sendsTo ?? null;
565
+ const to = invoice?.sendsTo ?? context.sendsTo ?? null;
559
566
  const ready = invoice?.view === 'ready';
560
567
  const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
561
568
  const emails = invoice?.sendMode?.mode === 'approve_and_email';
@@ -600,6 +607,28 @@ function draftView(invoice, ui, context, redraw) {
600
607
  }
601
608
  },
602
609
  }, 'Issue invoice') : null;
610
+ // Issue needs a client and a due date: until both are set it is greyed.
611
+ const syncIssue = () => {
612
+ if (!issueButton) return;
613
+ const missing = [contact ? null : 'a client', dueDate.value ? null : 'a due date'].filter(Boolean);
614
+ issueButton.disabled = missing.length > 0;
615
+ issueButton.title = missing.length ? `Choose ${missing.join(' and ')} first.` : '';
616
+ };
617
+ onContact = syncIssue;
618
+ clientInput.addEventListener('input', syncIssue);
619
+ dueDate.addEventListener('input', syncIssue);
620
+ dueDate.addEventListener('change', syncIssue);
621
+ syncIssue();
622
+
623
+ // A new invoice can start from a job that has no invoice yet: a quiet
624
+ // strip above the form says so. Flow drafts it the way the invoicing rules
625
+ // do (client, Reference, lines) and links it to the job. An invoice with
626
+ // no job is fine too.
627
+ const fromJob = isNew && context.canIssue ? h('section', { class: 'fx-fromjob' },
628
+ h('div', { class: 'fx-fromjob__text' },
629
+ h('span', { class: 'fx-strong' }, 'Billing a job?'),
630
+ h('span', { class: 'fx-dim' }, 'Flow fills in the details.')),
631
+ h('button', { class: 'fx-btn', type: 'button', onclick: () => pickJob(ui) }, 'Choose a job')) : null;
603
632
 
604
633
  // Mark ready: the draft is saved, then Flow checks it; one with a problem
605
634
  // stays a draft. Move to Drafts, on the Status card, undoes it.
@@ -648,6 +677,7 @@ function draftView(invoice, ui, context, redraw) {
648
677
  h('p', { class: 'fx-ph__sub' }, draftNote(to, invoice, ready))),
649
678
  h('div', { class: 'fx-ph__actions' }, saveButton, markReadyButton, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), sendButton ?? issueButton)),
650
679
  problems,
680
+ fromJob,
651
681
  h('div', { class: 'fx-invoice__grid' },
652
682
  h('div', { class: 'fx-invoice__main' },
653
683
  h('section', { class: 'fx-card fx-invoice__head' },
@@ -871,3 +901,51 @@ function showSent(button) {
871
901
  button.textContent = 'Sent to Xero';
872
902
  button.title = 'Flow sent this invoice to Xero.';
873
903
  }
904
+
905
+ // The jobs a new invoice can start from: no invoice yet, newest first.
906
+ const PICKABLE = new Set(['in_work', 'ready', 'pending']);
907
+
908
+ async function pickJob(ui) {
909
+ const { h, flow, toast, go } = ui;
910
+ let jobs;
911
+ try {
912
+ jobs = (await flow('/api/invoicing/jobs?limit=500')).jobs.filter((job) => PICKABLE.has(job.view) && !job.invoiceId);
913
+ } catch (error) {
914
+ toast(error.message, 'danger');
915
+ return;
916
+ }
917
+ const search = h('input', { class: 'fx-input', type: 'search', placeholder: 'Find a client or practitioner', 'aria-label': 'Find a job' });
918
+ const rows = jobs.map((job) => {
919
+ const label = h('label', { class: 'fx-pick__row' },
920
+ h('input', { type: 'radio', name: 'job', value: job.id, required: true }),
921
+ h('span', { class: 'fx-pick__main' },
922
+ h('span', { class: 'fx-strong' }, job.contactName ?? 'No client yet'),
923
+ h('span', { class: 'fx-dim' }, [job.practitioner, job.shippedOn ? showDate(job.shippedOn) : null].filter(Boolean).join(' · '))),
924
+ h('span', { class: 'fx-num fx-strong' }, job.totalCents == null ? '—' : formatCents(job.totalCents)));
925
+ label.dataset.find = `${job.contactName ?? ''} ${job.practitioner ?? ''}`.toLowerCase();
926
+ return label;
927
+ });
928
+ const list = h('div', { class: 'fx-pick', role: 'radiogroup', 'aria-label': 'Jobs' },
929
+ rows.length ? rows : h('p', { class: 'fx-dim' }, 'Every job has an invoice already.'));
930
+ search.addEventListener('input', () => {
931
+ const q = search.value.trim().toLowerCase();
932
+ for (const row of rows) row.hidden = Boolean(q) && !row.dataset.find.includes(q);
933
+ });
934
+ const picked = await ui.dialog({
935
+ title: 'New invoice from a job',
936
+ body: h('div', { class: 'fx-pick__body' },
937
+ h('p', { class: 'fx-dim' }, 'Flow fills in the client, Reference and lines from the invoice rules, and links the invoice to the job.'),
938
+ search, list),
939
+ confirm: 'Make draft',
940
+ });
941
+ const jobId = picked?.get('job');
942
+ if (!jobId) return;
943
+ try {
944
+ const { invoice } = await flow(`/api/invoicing/jobs/${jobId}/draft`, { method: 'POST', body: '{}' });
945
+ toast('Draft invoice made from the job.', 'success');
946
+ go(`/invoices/${invoice.id}`, { replace: true });
947
+ } catch (error) {
948
+ const reasons = error.details?.reasons;
949
+ toast(reasons?.length ? `Not drafted: ${reasons.join('; ')}.` : error.message, 'danger');
950
+ }
951
+ }
package/server/ui/jobs.js CHANGED
@@ -59,7 +59,7 @@ export async function renderJobs(main, ui) {
59
59
  main.classList.add('fx-main--wide');
60
60
  main.append(
61
61
  pageHeader('Jobs', { subtitle: 'Every job in the lab system, by its status there.' }),
62
- h('div', { class: 'fx-toolbar fx-toolbar--plain' }, search, toggle),
62
+ h('div', { class: 'fx-toolbar fx-toolbar--plain fx-toolbar--end' }, search, toggle),
63
63
  host);
64
64
 
65
65
  const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
@@ -73,12 +73,14 @@ export async function renderJobs(main, ui) {
73
73
 
74
74
  function draw() {
75
75
  if (!state.jobs.length) {
76
+ main.classList.remove('fx-main--board');
76
77
  host.replaceChildren(h('div', { class: 'fx-panel fx-empty' },
77
78
  h('p', {}, 'No jobs yet. Jobs appear here once the lab system is connected under Integrations.'),
78
79
  h('a', { class: 'fx-btn', href: '/invoices/new', onclick: ui.go }, 'Make an invoice by hand')));
79
80
  return;
80
81
  }
81
82
  const shown = state.jobs.filter((job) => matches(job, state.q));
83
+ main.classList.toggle('fx-main--board', state.layout === 'board');
82
84
  host.replaceChildren(state.layout === 'list' ? list(shown) : board(shown));
83
85
  }
84
86
 
@@ -1,5 +1,6 @@
1
- // Errors and problem reports from the browser, to the installation's own
2
- // Sentry project (docs/SENTRY.md). Only while the Sentry integration is on:
1
+ // Errors, problem reports and performance traces from the browser, to the
2
+ // installation's own Sentry project (docs/SENTRY.md). Only while the Sentry
3
+ // integration is on:
3
4
  // GET /api/monitoring says so and gives the project's public DSN. The SDK
4
5
  // comes from Sentry's CDN, pinned and checked against its integrity hash.
5
6
  // With the integration off, nothing loads and there is no "Report a problem".
@@ -26,6 +27,36 @@ export function tagNotify(event) {
26
27
  event.tags = { ...event.tags, notify: email ? 'yes' : 'no' };
27
28
  }
28
29
 
30
+ // A path as a pattern: each id in it becomes ":id", so every invoice's page
31
+ // is one name in a trace. The server names its requests the same way when no
32
+ // route matched (server/core/tracing.js).
33
+ const ID_SEGMENT = /^(\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-z_-]*\d[0-9a-z_-]{15,})$/i;
34
+ export function routePattern(path) {
35
+ const bare = String(path ?? '').replace(/[?#].*$/, '');
36
+ return bare.split('/').map((part) => (ID_SEGMENT.test(part) ? ':id' : part)).join('/');
37
+ }
38
+
39
+ // What a trace span keeps: timings, the route, the status. Never a query
40
+ // string (a search can hold a name), a header, a cookie, an address or an
41
+ // email. The person's Flow id stays, as on errors. Used here and by the
42
+ // server (server/integrations/sentry/index.js).
43
+ const DROPPED_ATTRIBUTE = /^(http\.(request|response)\.header\.|client\.|network\.(peer|local)\.|url\.(query|fragment)$|user\.(?!id$))/;
44
+ export function scrubSpan(span) {
45
+ if (!span) return span;
46
+ // "GET /api/search?q=…" loses its query; a database span's SQL keeps its "?".
47
+ const request = /^((?:GET|POST|PUT|PATCH|DELETE) \S*?)[?#]\S*/;
48
+ if (typeof span.name === 'string') span.name = span.name.replace(request, '$1');
49
+ if (typeof span.description === 'string') span.description = span.description.replace(request, '$1');
50
+ const attributes = span.attributes ?? span.data;
51
+ if (attributes) {
52
+ for (const key of Object.keys(attributes)) {
53
+ if (DROPPED_ATTRIBUTE.test(key)) delete attributes[key];
54
+ else if (typeof attributes[key] === 'string' && /(^|\.)url(\.full)?$|^http\.url$/.test(key)) attributes[key] = attributes[key].replace(/[?#].*$/, '');
55
+ }
56
+ }
57
+ return span;
58
+ }
59
+
29
60
  /** Resolves to a function that opens the "Report a problem" form, or null. */
30
61
  export async function startMonitoring({ flow, person }) {
31
62
  const config = await flow('/api/monitoring');
@@ -51,12 +82,23 @@ export async function startMonitoring({ flow, person }) {
51
82
  submitButtonLabel: 'Send report',
52
83
  successMessageText: 'Thank you. Your report is sent.',
53
84
  })] : [];
85
+ // Traces: page loads, moves between pages and each click's wait, with the
86
+ // calls to Flow's server joined to the server's own trace. Only when the
87
+ // installation set a share above 0 (SENTRY_TRACES_SAMPLE_RATE).
88
+ const tracesSampleRate = Number(config.tracesSampleRate) || 0;
89
+ if (tracesSampleRate > 0) {
90
+ integrations.push(Sentry.browserTracingIntegration({
91
+ beforeStartSpan: (options) => ({ ...options, name: routePattern(options.name) }),
92
+ }));
93
+ }
54
94
  Sentry.init({
55
95
  dsn: config.dsn,
56
96
  environment: config.environment,
57
97
  release: config.release ?? undefined,
58
98
  sendDefaultPii: false,
59
99
  integrations,
100
+ tracesSampleRate,
101
+ beforeSendSpan: scrubSpan,
60
102
  });
61
103
  Sentry.getClient()?.on('beforeSendFeedback', tagNotify);
62
104
  // Who reported or met the error, so the lab can follow up.
@@ -20,7 +20,7 @@ const themeDir = join(here, '..', 'theme');
20
20
  const logoDir = join(here, 'logos');
21
21
  const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
22
22
  const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
23
- const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'jobs.js', 'job-board.js', 'monitoring.js', 'search.js', 'alerts.js', 'flow-mark.svg']);
23
+ const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'monitoring.js', 'search.js', 'alerts.js', 'flow-mark.svg']);
24
24
  const MAX_AGE = '1h';
25
25
 
26
26
  // Send one named file from dir. Passing dir as root limits the dotfile check