@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.
- package/README.md +6 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/custom-fields.js +114 -0
- package/server/api/invoicing.js +2 -1
- package/server/api/work-moves.js +80 -0
- package/server/app.js +12 -0
- package/server/core/custom-fields.js +300 -0
- package/server/core/job-board.js +2 -0
- package/server/core/tracing.js +64 -0
- package/server/db/migrations/0018_custom_fields.sql +78 -0
- package/server/db/migrations/meta/0018_snapshot.json +4289 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +59 -0
- package/server/index.js +6 -1
- package/server/integrations/port.js +7 -1
- package/server/integrations/seazona/index.js +47 -1
- package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
- package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
- package/server/integrations/seazona/schema.js +26 -0
- package/server/integrations/seazona/settings.js +14 -0
- package/server/integrations/seazona/stage-moves.js +135 -0
- package/server/integrations/seazona/tables.js +2 -0
- package/server/integrations/sentry/index.js +61 -10
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +40 -2
- package/server/ui/custom-fields.js +190 -0
- package/server/ui/flow.css +48 -9
- package/server/ui/invoicing.js +83 -5
- package/server/ui/jobs.js +3 -1
- package/server/ui/monitoring.js +44 -2
- package/server/ui/routes.js +1 -1
package/server/ui/flow.css
CHANGED
|
@@ -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
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
.fx-
|
|
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 {
|
|
480
|
-
.fx-job__who { display: flex; align-items:
|
|
481
|
-
.fx-job__who span { min-width: 0; overflow
|
|
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; }
|
package/server/ui/invoicing.js
CHANGED
|
@@ -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', {},
|
|
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: '
|
|
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
|
|
package/server/ui/monitoring.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// Errors
|
|
2
|
-
// Sentry project (docs/SENTRY.md). Only while the Sentry
|
|
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.
|
package/server/ui/routes.js
CHANGED
|
@@ -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
|