@murumets-ee/queue-ui 0.12.0
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/LICENSE +94 -0
- package/dist/index.d.mts +78 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3 -0
- package/dist/index.mjs.map +1 -0
- package/dist/pages.d.mts +12 -0
- package/dist/pages.d.mts.map +1 -0
- package/dist/pages.mjs +2 -0
- package/dist/pages.mjs.map +1 -0
- package/dist/plugin.d.mts +8 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/dist/plugin.mjs.map +1 -0
- package/dist/widgets.d.mts +15 -0
- package/dist/widgets.d.mts.map +1 -0
- package/dist/widgets.mjs +2 -0
- package/dist/widgets.mjs.map +1 -0
- package/package.json +73 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Elastic License 2.0 (ELv2)
|
|
2
|
+
|
|
3
|
+
URL: https://www.elastic.co/licensing/elastic-license
|
|
4
|
+
|
|
5
|
+
## Acceptance
|
|
6
|
+
|
|
7
|
+
By using the software, you agree to all of the terms and conditions below.
|
|
8
|
+
|
|
9
|
+
## Copyright License
|
|
10
|
+
|
|
11
|
+
The licensor grants you a non-exclusive, royalty-free, worldwide,
|
|
12
|
+
non-sublicensable, non-transferable license to use, copy, distribute, make
|
|
13
|
+
available, and prepare derivative works of the software, in each case subject
|
|
14
|
+
to the limitations and conditions below.
|
|
15
|
+
|
|
16
|
+
## Limitations
|
|
17
|
+
|
|
18
|
+
You may not provide the software to third parties as a hosted or managed
|
|
19
|
+
service, where the service provides users with access to any substantial set
|
|
20
|
+
of the features or functionality of the software.
|
|
21
|
+
|
|
22
|
+
You may not move, change, disable, or circumvent the license key functionality
|
|
23
|
+
in the software, and you may not remove or obscure any functionality in the
|
|
24
|
+
software that is protected by the license key.
|
|
25
|
+
|
|
26
|
+
You may not alter, remove, or obscure any licensing, copyright, or other
|
|
27
|
+
notices of the licensor in the software. Any use of the licensor's trademarks
|
|
28
|
+
is subject to applicable law.
|
|
29
|
+
|
|
30
|
+
## Patents
|
|
31
|
+
|
|
32
|
+
The licensor grants you a license, under any patent claims the licensor can
|
|
33
|
+
license, or becomes able to license, to make, have made, use, sell, offer for
|
|
34
|
+
sale, import and have imported the software, in each case subject to the
|
|
35
|
+
limitations and conditions in this license. This license does not cover any
|
|
36
|
+
patent claims that you cause to be infringed by modifications or additions to
|
|
37
|
+
the software. If you or your company make any written claim that the software
|
|
38
|
+
infringes or contributes to infringement of any patent, your patent license
|
|
39
|
+
for the software granted under these terms ends immediately. If your company
|
|
40
|
+
makes such a claim, your patent license ends immediately for work on behalf
|
|
41
|
+
of your company.
|
|
42
|
+
|
|
43
|
+
## Notices
|
|
44
|
+
|
|
45
|
+
You must ensure that anyone who gets a copy of any part of the software from
|
|
46
|
+
you also gets a copy of these terms.
|
|
47
|
+
|
|
48
|
+
If you modify the software, you must include in any modified copies of the
|
|
49
|
+
software prominent notices stating that you have modified the software.
|
|
50
|
+
|
|
51
|
+
## No Other Rights
|
|
52
|
+
|
|
53
|
+
These terms do not imply any licenses other than those expressly granted in
|
|
54
|
+
these terms.
|
|
55
|
+
|
|
56
|
+
## Termination
|
|
57
|
+
|
|
58
|
+
If you use the software in violation of these terms, such use is not licensed,
|
|
59
|
+
and your licenses will automatically terminate. If the licensor provides you
|
|
60
|
+
with a notice of your violation, and you cease all violation of this license
|
|
61
|
+
no later than 30 days after you receive that notice, your licenses will be
|
|
62
|
+
reinstated retroactively. However, if you violate these terms after such
|
|
63
|
+
reinstatement, any additional violation of these terms will cause your
|
|
64
|
+
licenses to terminate automatically and permanently.
|
|
65
|
+
|
|
66
|
+
## No Liability
|
|
67
|
+
|
|
68
|
+
As far as the law allows, the software comes as is, without any warranty or
|
|
69
|
+
condition, and the licensor will not be liable to you for any damages arising
|
|
70
|
+
out of these terms or the use or nature of the software, under any kind of
|
|
71
|
+
legal claim.
|
|
72
|
+
|
|
73
|
+
## Definitions
|
|
74
|
+
|
|
75
|
+
The **licensor** is the entity offering these terms, and the **software** is
|
|
76
|
+
the software the licensor makes available under these terms, including any
|
|
77
|
+
portion of it.
|
|
78
|
+
|
|
79
|
+
**you** refers to the individual or entity agreeing to these terms.
|
|
80
|
+
|
|
81
|
+
**your company** is any legal entity, sole proprietorship, or other kind of
|
|
82
|
+
organization that you work for, plus all organizations that have control over,
|
|
83
|
+
are under the control of, or are under common control with that organization.
|
|
84
|
+
**control** means ownership of substantially all the assets of an entity, or
|
|
85
|
+
the power to direct the management and policies of an entity (for example, by
|
|
86
|
+
voting right, contract, or otherwise). Control can be direct or indirect.
|
|
87
|
+
|
|
88
|
+
**your licenses** are all the licenses granted to you for the software under
|
|
89
|
+
these terms.
|
|
90
|
+
|
|
91
|
+
**use** means anything you do with the software requiring one of your
|
|
92
|
+
licenses.
|
|
93
|
+
|
|
94
|
+
**trademark** means trademarks, service marks, and similar rights.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
|
|
2
|
+
import { QueueCatalogEntry, QueueCatalogEntry as QueueCatalogEntry$1 } from "@murumets-ee/queue/admin";
|
|
3
|
+
|
|
4
|
+
//#region src/types.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Shared types between the server `QueuePage` and client `QueuePageClient`.
|
|
7
|
+
*
|
|
8
|
+
* All `Date` fields are ISO strings — server pages serialize the snapshot
|
|
9
|
+
* once before handing it to the client, so the client never needs to deal
|
|
10
|
+
* with `Date` instances.
|
|
11
|
+
*/
|
|
12
|
+
interface QueueJobSummary {
|
|
13
|
+
id: string;
|
|
14
|
+
type: string;
|
|
15
|
+
status: string;
|
|
16
|
+
attempts: number;
|
|
17
|
+
maxRetries: number;
|
|
18
|
+
runAt: string;
|
|
19
|
+
lockedAt: string | null;
|
|
20
|
+
lockedBy: string | null;
|
|
21
|
+
completedAt: string | null;
|
|
22
|
+
failedAt: string | null;
|
|
23
|
+
/** Truncated to 500 chars at fetch time. */
|
|
24
|
+
lastError: string | null;
|
|
25
|
+
progress: unknown;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
}
|
|
28
|
+
interface QueueScheduledSummary {
|
|
29
|
+
id: string;
|
|
30
|
+
name: string;
|
|
31
|
+
type: string;
|
|
32
|
+
schedule: string;
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
nextRunAt: string | null;
|
|
35
|
+
lastRunAt: string | null;
|
|
36
|
+
}
|
|
37
|
+
interface QueueHeartbeat {
|
|
38
|
+
workerId: string;
|
|
39
|
+
startedAt: string;
|
|
40
|
+
lastSeenAt: string;
|
|
41
|
+
concurrency: number;
|
|
42
|
+
activeJobs: number;
|
|
43
|
+
stale: boolean;
|
|
44
|
+
}
|
|
45
|
+
interface QueueInitialSnapshot {
|
|
46
|
+
/** Total counts by status — pending, processing, completed, failed, dead. */
|
|
47
|
+
counts: Record<string, number>;
|
|
48
|
+
/** Recent dead jobs, newest first. */
|
|
49
|
+
dead: readonly QueueJobSummary[];
|
|
50
|
+
/** Currently-processing jobs. */
|
|
51
|
+
processing: readonly QueueJobSummary[];
|
|
52
|
+
/** Last 100 jobs (any status). */
|
|
53
|
+
recent: readonly QueueJobSummary[];
|
|
54
|
+
/** All scheduled (recurring) jobs. */
|
|
55
|
+
scheduled: readonly QueueScheduledSummary[];
|
|
56
|
+
/** All known workers. */
|
|
57
|
+
heartbeats: readonly QueueHeartbeat[];
|
|
58
|
+
/** True if any heartbeat row is fresh. */
|
|
59
|
+
healthyWorker: boolean;
|
|
60
|
+
/** Job-type catalog (every `defineJob` + types seen in jobs). */
|
|
61
|
+
catalog: readonly QueueCatalogEntry$1[];
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/queue-page-client.d.ts
|
|
65
|
+
interface QueuePageClientProps {
|
|
66
|
+
apiBasePath: string;
|
|
67
|
+
initialData: QueueInitialSnapshot;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Public component — wraps the live page in a RealtimeProvider so this
|
|
71
|
+
* surface works whether or not the surrounding admin shell mounts one.
|
|
72
|
+
* The provider is cheap (single SSE connection per mount) and the queue
|
|
73
|
+
* page only renders once per admin tab.
|
|
74
|
+
*/
|
|
75
|
+
declare function QueuePageClient(props: QueuePageClientProps): React.ReactElement;
|
|
76
|
+
//#endregion
|
|
77
|
+
export { type QueueCatalogEntry, type QueueHeartbeat, type QueueInitialSnapshot, type QueueJobSummary, QueuePageClient, type QueueScheduledSummary };
|
|
78
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/queue-page-client.tsx"],"mappings":";;;;;;;AAQA;;;;UAAiB,eAAA;EACf,EAAA;EACA,IAAA;EACA,MAAA;EACA,QAAA;EACA,UAAA;EACA,KAAA;EACA,QAAA;EACA,QAAA;EACA,WAAA;EACA,QAAA;EAGA;EADA,SAAA;EACA,QAAA;EACA,SAAA;AAAA;AAAA,UAGe,qBAAA;EACf,EAAA;EACA,IAAA;EACA,IAAA;EACA,QAAA;EACA,OAAA;EACA,SAAA;EACA,SAAA;AAAA;AAAA,UAGe,cAAA;EACf,QAAA;EACA,SAAA;EACA,UAAA;EACA,WAAA;EACA,UAAA;EACA,KAAA;AAAA;AAAA,UAce,oBAAA;EAEP;EAAR,MAAA,EAAQ,MAAA;EAIa;EAFrB,IAAA,WAAe,eAAA;EAMK;EAJpB,UAAA,WAAqB,eAAA;EAUH;EARlB,MAAA,WAAiB,eAAA;EAQkB;EANnC,SAAA,WAAoB,qBAAA;EARZ;EAUR,UAAA,WAAqB,cAAA;EARN;EAUf,aAAA;EARqB;EAUrB,OAAA,WAAkB,mBAAA;AAAA;;;UCvBH,oBAAA;EACf,WAAA;EACA,WAAA,EAAa,oBAAA;AAAA;;;ADzBf;;;;iBCkCgB,eAAA,CAAgB,KAAA,EAAO,oBAAA,GAAuB,KAAA,CAAM,YAAA"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import{RealtimeProvider as e,useLiveSnapshot as t,useRealtimeStatus as n}from"@murumets-ee/admin-ui/realtime";import{Badge as r,Button as i,cn as a}from"@murumets-ee/ui";import{useCallback as o,useMemo as s,useState as c}from"react";import{jsx as l,jsxs as u}from"react/jsx-runtime";const d=[`queue.**`],f=[{key:`in-progress`,label:`In progress`},{key:`dead`,label:`Dead letter`},{key:`scheduled`,label:`Scheduled`},{key:`recent`,label:`Recent`},{key:`stats`,label:`Stats`},{key:`catalog`,label:`Catalog`}];function p(t){return l(e,{subscribePatterns:d,children:l(m,{...t})})}function m({apiBasePath:e,initialData:p}){let[m,x]=c(`in-progress`),[S,w]=c(null),[E,D]=c(null),O=n(),{data:k,isLoading:A,error:j,refetch:M}=t({fetcher:o(async t=>{let n={credentials:`same-origin`,signal:t},[r,i,a,o,s,c,l]=await Promise.all([fetch(`${e}/queue/stats`,n),fetch(`${e}/queue/heartbeat`,n),fetch(`${e}/queue/scheduled`,n),fetch(`${e}/queue/jobs?status=dead&limit=50`,n),fetch(`${e}/queue/jobs?status=processing&limit=50`,n),fetch(`${e}/queue/jobs?limit=100`,n),fetch(`${e}/queue/catalog`,n)]);if(!r.ok||!i.ok||!a.ok||!o.ok||!s.ok||!c.ok||!l.ok)throw Error(`Failed to refresh queue data`);let[u,d,f,p,m,h,g]=await Promise.all([r.json(),i.json(),a.json(),o.json(),s.json(),c.json(),l.json()]);return{counts:u.counts,dead:p.items,processing:m.items,recent:h.items,scheduled:f.items,heartbeats:d.workers,healthyWorker:d.healthy,catalog:g.items}},[e]),topics:d,initialData:p}),N=k??p,P=E??(j?j.message:null),F=o(async(t,n,r=`POST`)=>{w(t),D(null);try{let t=await fetch(`${e}/queue${n}`,{method:r,credentials:`same-origin`});if(!t.ok){let e=await t.json().catch(()=>({}));throw Error(e.error??`Request failed (${t.status})`)}M()}catch(e){D(e instanceof Error?e.message:String(e))}finally{w(null)}},[e,M]),I=N.healthyWorker?`Worker healthy`:`Worker stale`,L=N.healthyWorker?`bg-emerald-500/10 text-emerald-600 dark:text-emerald-400`:`bg-rose-500/10 text-rose-600 dark:text-rose-400`,R=s(()=>C(O),[O]);return u(`div`,{className:`p-6 space-y-4`,children:[u(`div`,{className:`flex items-center justify-between flex-wrap gap-3`,children:[u(`div`,{children:[l(`h1`,{className:`text-2xl font-bold`,children:`Queue`}),l(`p`,{className:`text-sm text-muted-foreground`,children:`Background jobs, schedules, and worker health.`})]}),u(`div`,{className:`flex items-center gap-3`,children:[l(`span`,{className:a(`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium`,L),children:I}),l(`span`,{className:a(`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium`,R.color),title:R.title,children:R.label}),l(i,{onClick:M,disabled:A,variant:`outline`,size:`sm`,children:A?`Refreshing…`:`Refresh`})]})]}),P&&l(`div`,{className:`rounded-md border border-rose-300/60 bg-rose-50/80 p-3 text-sm text-rose-700 dark:border-rose-600/40 dark:bg-rose-950/40 dark:text-rose-300`,children:P}),l(h,{counts:N.counts}),l(`nav`,{className:`flex gap-1 border-b`,role:`tablist`,"aria-label":`Queue views`,children:f.map(e=>u(`button`,{role:`tab`,type:`button`,"aria-selected":m===e.key,onClick:()=>x(e.key),className:a(`px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors`,m===e.key?`border-foreground text-foreground`:`border-transparent text-muted-foreground hover:text-foreground`),children:[e.label,e.key===`in-progress`&&N.processing.length>0&&l(r,{variant:`secondary`,className:`ml-2`,children:N.processing.length}),e.key===`dead`&&N.dead.length>0&&l(r,{variant:`destructive`,className:`ml-2`,children:N.dead.length})]},e.key))}),m===`in-progress`&&l(g,{jobs:N.processing,action:F,busyId:S}),m===`dead`&&l(_,{jobs:N.dead,action:F,busyId:S}),m===`scheduled`&&l(v,{schedules:N.scheduled,action:F,busyId:S}),m===`recent`&&l(y,{jobs:N.recent}),m===`stats`&&l(b,{counts:N.counts,heartbeats:N.heartbeats}),m===`catalog`&&l(T,{entries:N.catalog})]})}function h({counts:e}){return l(`div`,{className:`flex flex-wrap gap-3`,children:[[`pending`,e.pending??0,`bg-amber-500/10 text-amber-700 dark:text-amber-400`],[`processing`,e.processing??0,`bg-sky-500/10 text-sky-700 dark:text-sky-400`],[`dead`,e.dead??0,`bg-rose-500/10 text-rose-700 dark:text-rose-400`]].map(([e,t,n])=>u(`div`,{className:a(`rounded-md px-3 py-2 text-sm font-medium`,n),children:[l(`span`,{className:`capitalize`,children:e}),l(`span`,{className:`ml-2 font-mono`,children:t})]},e))})}function g({jobs:e,action:t,busyId:n}){return e.length===0?l(S,{text:`Nothing is processing right now.`}):l(`div`,{className:`overflow-x-auto rounded-md border`,children:u(`table`,{className:`w-full text-sm`,children:[l(`thead`,{className:`bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground`,children:u(`tr`,{children:[l(`th`,{className:`px-3 py-2 text-left`,children:`Type`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Worker`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Locked at`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Runtime`}),l(`th`,{className:`px-3 py-2 text-right`,children:`Actions`})]})}),l(`tbody`,{children:e.map(e=>u(`tr`,{className:`border-t`,children:[l(`td`,{className:`px-3 py-2 font-mono text-xs`,children:e.type}),l(`td`,{className:`px-3 py-2 font-mono text-xs`,children:e.lockedBy??`—`}),l(`td`,{className:`px-3 py-2`,children:e.lockedAt?new Date(e.lockedAt).toLocaleTimeString():`—`}),l(`td`,{className:`px-3 py-2`,children:e.lockedAt?w(e.lockedAt):`—`}),l(`td`,{className:`px-3 py-2 text-right`,children:l(i,{size:`sm`,variant:`outline`,disabled:n===e.id,onClick:()=>t(e.id,`/jobs/${e.id}/cancel`),children:`Cancel`})})]},e.id))})]})})}function _({jobs:e,action:t,busyId:n}){return e.length===0?l(S,{text:`Dead letter is empty. Nothing to triage.`}):l(`div`,{className:`overflow-x-auto rounded-md border`,children:u(`table`,{className:`w-full text-sm`,children:[l(`thead`,{className:`bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground`,children:u(`tr`,{children:[l(`th`,{className:`px-3 py-2 text-left`,children:`Type`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Failed at`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Attempts`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Last error`}),l(`th`,{className:`px-3 py-2 text-right`,children:`Actions`})]})}),l(`tbody`,{children:e.map(e=>u(`tr`,{className:`border-t align-top`,children:[l(`td`,{className:`px-3 py-2 font-mono text-xs whitespace-nowrap`,children:e.type}),l(`td`,{className:`px-3 py-2 whitespace-nowrap`,children:e.failedAt?new Date(e.failedAt).toLocaleString():`—`}),u(`td`,{className:`px-3 py-2`,children:[e.attempts,`/`,e.maxRetries]}),l(`td`,{className:`px-3 py-2 text-xs text-muted-foreground max-w-md break-words`,children:e.lastError??`—`}),u(`td`,{className:`px-3 py-2 text-right whitespace-nowrap`,children:[l(i,{size:`sm`,variant:`outline`,disabled:n===e.id,onClick:()=>t(e.id,`/jobs/${e.id}/requeue`),className:`mr-2`,children:`Requeue`}),l(i,{size:`sm`,variant:`destructive`,disabled:n===e.id,onClick:()=>t(e.id,`/jobs/${e.id}`,`DELETE`),children:`Delete`})]})]},e.id))})]})})}function v({schedules:e,action:t,busyId:n}){return e.length===0?l(S,{text:`No scheduled jobs registered.`}):l(`div`,{className:`overflow-x-auto rounded-md border`,children:u(`table`,{className:`w-full text-sm`,children:[l(`thead`,{className:`bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground`,children:u(`tr`,{children:[l(`th`,{className:`px-3 py-2 text-left`,children:`Name`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Schedule`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Next run`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Last run`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Status`}),l(`th`,{className:`px-3 py-2 text-right`,children:`Actions`})]})}),l(`tbody`,{children:e.map(e=>u(`tr`,{className:`border-t`,children:[u(`td`,{className:`px-3 py-2`,children:[l(`div`,{className:`font-mono text-xs`,children:e.name}),l(`div`,{className:`text-xs text-muted-foreground`,children:e.type})]}),l(`td`,{className:`px-3 py-2 font-mono text-xs`,children:e.schedule}),l(`td`,{className:`px-3 py-2 whitespace-nowrap`,children:e.nextRunAt?new Date(e.nextRunAt).toLocaleString():`—`}),l(`td`,{className:`px-3 py-2 whitespace-nowrap`,children:e.lastRunAt?new Date(e.lastRunAt).toLocaleString():`Never`}),l(`td`,{className:`px-3 py-2`,children:e.enabled?l(r,{variant:`secondary`,children:`Enabled`}):l(r,{variant:`outline`,children:`Disabled`})}),u(`td`,{className:`px-3 py-2 text-right whitespace-nowrap`,children:[l(i,{size:`sm`,variant:`outline`,disabled:n===e.id,onClick:()=>t(e.id,`/scheduled/${e.id}/run-now`),className:`mr-1`,children:`Run now`}),l(i,{size:`sm`,variant:`ghost`,disabled:n===e.id,onClick:()=>t(e.id,`/scheduled/${e.id}/${e.enabled?`disable`:`enable`}`),className:`mr-1`,children:e.enabled?`Disable`:`Enable`}),l(i,{size:`sm`,variant:`ghost`,disabled:n===e.id,onClick:()=>t(e.id,`/scheduled/${e.id}/reset-lease`),children:`Reset lease`})]})]},e.id))})]})})}function y({jobs:e}){return e.length===0?l(S,{text:`No recent jobs.`}):l(`div`,{className:`overflow-x-auto rounded-md border`,children:u(`table`,{className:`w-full text-sm`,children:[l(`thead`,{className:`bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground`,children:u(`tr`,{children:[l(`th`,{className:`px-3 py-2 text-left`,children:`Type`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Status`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Created`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Completed`})]})}),l(`tbody`,{children:e.map(e=>u(`tr`,{className:`border-t`,children:[l(`td`,{className:`px-3 py-2 font-mono text-xs`,children:e.type}),l(`td`,{className:`px-3 py-2`,children:l(x,{status:e.status})}),l(`td`,{className:`px-3 py-2 whitespace-nowrap`,children:new Date(e.createdAt).toLocaleString()}),l(`td`,{className:`px-3 py-2 whitespace-nowrap`,children:e.completedAt?new Date(e.completedAt).toLocaleString():e.failedAt?new Date(e.failedAt).toLocaleString():`—`})]},e.id))})]})})}function b({counts:e,heartbeats:t}){return u(`div`,{className:`space-y-6`,children:[u(`section`,{children:[l(`h2`,{className:`font-semibold mb-2`,children:`Status counts`}),l(`div`,{className:`grid grid-cols-2 md:grid-cols-5 gap-2`,children:Object.entries(e).map(([e,t])=>u(`div`,{className:`rounded-md border p-3`,children:[l(`div`,{className:`text-xs uppercase tracking-wide text-muted-foreground`,children:e}),l(`div`,{className:`text-2xl font-mono`,children:t})]},e))})]}),u(`section`,{children:[l(`h2`,{className:`font-semibold mb-2`,children:`Workers`}),t.length===0?l(`p`,{className:`text-sm text-muted-foreground`,children:`No worker has reported a heartbeat yet.`}):l(`div`,{className:`overflow-x-auto rounded-md border`,children:u(`table`,{className:`w-full text-sm`,children:[l(`thead`,{className:`bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground`,children:u(`tr`,{children:[l(`th`,{className:`px-3 py-2 text-left`,children:`Worker`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Started`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Last seen`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Active`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Status`})]})}),l(`tbody`,{children:t.map(e=>u(`tr`,{className:`border-t`,children:[l(`td`,{className:`px-3 py-2 font-mono text-xs`,children:e.workerId}),l(`td`,{className:`px-3 py-2 whitespace-nowrap`,children:new Date(e.startedAt).toLocaleString()}),l(`td`,{className:`px-3 py-2 whitespace-nowrap`,children:new Date(e.lastSeenAt).toLocaleString()}),u(`td`,{className:`px-3 py-2`,children:[e.activeJobs,`/`,e.concurrency]}),l(`td`,{className:`px-3 py-2`,children:e.stale?l(r,{variant:`destructive`,children:`Stale`}):l(r,{variant:`secondary`,children:`Fresh`})})]},e.workerId))})]})})]})]})}function x({status:e}){return l(r,{variant:e===`completed`?`secondary`:e===`dead`?`destructive`:e===`processing`?`default`:`outline`,children:e})}function S({text:e}){return l(`div`,{className:`rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground`,children:e})}function C(e){return e===`open`?{label:`Live`,color:`bg-emerald-500/10 text-emerald-600 dark:text-emerald-400`,title:`Realtime connection active — table updates as the worker reports them.`}:e===`connecting`?{label:`Connecting…`,color:`bg-amber-500/10 text-amber-700 dark:text-amber-400`,title:`Connecting to realtime stream — the page will revalidate when events arrive.`}:{label:`Offline`,color:`bg-zinc-500/10 text-zinc-600 dark:text-zinc-400`,title:`Realtime stream unavailable — use the Refresh button to update.`}}function w(e){let t=Date.now()-new Date(e).getTime();return t<0?`—`:t<6e4?`${Math.round(t/1e3)}s`:t<36e5?`${Math.round(t/6e4)}m`:`${Math.round(t/36e5)}h`}function T({entries:e}){return e.length===0?l(S,{text:`No jobs registered yet. Plugins declare jobs via defineJob/registerJob.`}):l(`div`,{className:`overflow-x-auto rounded-md border`,children:u(`table`,{className:`w-full text-sm`,children:[l(`thead`,{className:`bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground`,children:u(`tr`,{children:[l(`th`,{className:`px-3 py-2 text-left`,children:`Job type`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Description`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Schedule`}),l(`th`,{className:`px-3 py-2 text-left`,children:`Last run`}),l(`th`,{className:`px-3 py-2 text-left`,children:`24 h volume`}),l(`th`,{className:`px-3 py-2 text-left`,children:`24 h success`})]})}),l(`tbody`,{children:e.map(e=>u(`tr`,{className:`border-t align-top`,children:[u(`td`,{className:`px-3 py-2 whitespace-nowrap`,children:[l(`div`,{className:`font-mono text-xs`,children:e.name}),!e.registered&&l(r,{variant:`outline`,className:`mt-1`,children:`unregistered`}),e.scheduled&&!e.scheduled.enabled&&l(r,{variant:`outline`,className:`mt-1 ml-1`,children:`schedule disabled`})]}),l(`td`,{className:`px-3 py-2 text-xs text-muted-foreground max-w-md`,children:e.description??l(`span`,{className:`italic`,children:`—`})}),l(`td`,{className:`px-3 py-2 font-mono text-xs whitespace-nowrap`,children:e.schedule??l(`span`,{className:`text-muted-foreground italic`,children:`event-driven`})}),l(`td`,{className:`px-3 py-2 whitespace-nowrap`,children:e.lastRunAt?new Date(e.lastRunAt).toLocaleString():`—`}),l(`td`,{className:`px-3 py-2 whitespace-nowrap`,children:e.stats24h.total===0?`—`:u(`span`,{className:`font-mono text-xs`,children:[e.stats24h.total,` total`,e.stats24h.dead>0&&u(`span`,{className:`ml-1 text-rose-600 dark:text-rose-400`,children:[`(`,e.stats24h.dead,` dead)`]})]})}),l(`td`,{className:`px-3 py-2 whitespace-nowrap`,children:e.successRate24h===null?`—`:`${Math.round(e.successRate24h*100)}%`})]},e.name))})]})})}export{p as QueuePageClient};
|
|
3
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/queue-page-client.tsx"],"sourcesContent":["/**\n * Interactive client surface for the queue admin page.\n *\n * Renders six tabs: in-progress, dead letter, scheduled, recent activity,\n * per-type stats, catalog. Per-row actions (requeue, delete, cancel,\n * run-now, disable/enable, reset-lease) hit the admin API and refetch\n * on success.\n *\n * The initial snapshot comes from the server page so first paint has data.\n * Subsequent revalidation is event-driven via `useLiveSnapshot` against\n * the `queue.**` topics published by the worker and admin routes\n * (PLAN-REALTIME PR D). The previous manual Refresh button is replaced\n * with a transport-status indicator + an explicit Refresh action that\n * triggers an immediate refetch.\n */\n\nimport {\n RealtimeProvider,\n useLiveSnapshot,\n useRealtimeStatus,\n} from '@murumets-ee/admin-ui/realtime'\nimport { Badge, Button, cn } from '@murumets-ee/ui'\nimport { useCallback, useMemo, useState } from 'react'\nimport type {\n QueueCatalogEntry,\n QueueHeartbeat,\n QueueInitialSnapshot,\n QueueJobSummary,\n QueueScheduledSummary,\n} from './types.js'\n\n/**\n * Topics the queue page revalidates on. Stable reference so\n * `useLiveSnapshot` doesn't re-subscribe each render.\n */\nconst QUEUE_LIVE_TOPICS: readonly string[] = ['queue.**'] as const\n\ntype TabKey = 'in-progress' | 'dead' | 'scheduled' | 'recent' | 'stats' | 'catalog'\n\nconst TABS: ReadonlyArray<{ key: TabKey; label: string }> = [\n { key: 'in-progress', label: 'In progress' },\n { key: 'dead', label: 'Dead letter' },\n { key: 'scheduled', label: 'Scheduled' },\n { key: 'recent', label: 'Recent' },\n { key: 'stats', label: 'Stats' },\n { key: 'catalog', label: 'Catalog' },\n]\n\nexport interface QueuePageClientProps {\n apiBasePath: string\n initialData: QueueInitialSnapshot\n}\n\n/**\n * Public component — wraps the live page in a RealtimeProvider so this\n * surface works whether or not the surrounding admin shell mounts one.\n * The provider is cheap (single SSE connection per mount) and the queue\n * page only renders once per admin tab.\n */\nexport function QueuePageClient(props: QueuePageClientProps): React.ReactElement {\n return (\n <RealtimeProvider subscribePatterns={QUEUE_LIVE_TOPICS}>\n <QueuePageClientInner {...props} />\n </RealtimeProvider>\n )\n}\n\nfunction QueuePageClientInner({\n apiBasePath,\n initialData,\n}: QueuePageClientProps): React.ReactElement {\n const [tab, setTab] = useState<TabKey>('in-progress')\n const [busyId, setBusyId] = useState<string | null>(null)\n const [actionError, setActionError] = useState<string | null>(null)\n const transportStatus = useRealtimeStatus()\n\n // Stable fetcher reference so `useLiveSnapshot` re-uses the same\n // closure across renders. The hook itself holds a ref so a fresh\n // closure on each render wouldn't re-subscribe, but useCallback keeps\n // the function identity matched to its dependencies anyway.\n const fetcher = useCallback(\n async (signal: AbortSignal): Promise<QueueInitialSnapshot> => {\n const opts = { credentials: 'same-origin' as const, signal }\n const [\n statsRes,\n heartbeatRes,\n scheduledRes,\n deadRes,\n processingRes,\n recentRes,\n catalogRes,\n ] = await Promise.all([\n fetch(`${apiBasePath}/queue/stats`, opts),\n fetch(`${apiBasePath}/queue/heartbeat`, opts),\n fetch(`${apiBasePath}/queue/scheduled`, opts),\n fetch(`${apiBasePath}/queue/jobs?status=dead&limit=50`, opts),\n fetch(`${apiBasePath}/queue/jobs?status=processing&limit=50`, opts),\n fetch(`${apiBasePath}/queue/jobs?limit=100`, opts),\n fetch(`${apiBasePath}/queue/catalog`, opts),\n ])\n\n if (\n !statsRes.ok ||\n !heartbeatRes.ok ||\n !scheduledRes.ok ||\n !deadRes.ok ||\n !processingRes.ok ||\n !recentRes.ok ||\n !catalogRes.ok\n ) {\n throw new Error('Failed to refresh queue data')\n }\n\n const [\n statsBody,\n heartbeatBody,\n scheduledBody,\n deadBody,\n processingBody,\n recentBody,\n catalogBody,\n ] = await Promise.all([\n statsRes.json() as Promise<{ counts: Record<string, number> }>,\n heartbeatRes.json() as Promise<{\n workers: QueueHeartbeat[]\n healthy: boolean\n }>,\n scheduledRes.json() as Promise<{ items: QueueScheduledSummary[] }>,\n deadRes.json() as Promise<{ items: QueueJobSummary[] }>,\n processingRes.json() as Promise<{ items: QueueJobSummary[] }>,\n recentRes.json() as Promise<{ items: QueueJobSummary[] }>,\n catalogRes.json() as Promise<{ items: QueueCatalogEntry[] }>,\n ])\n\n return {\n counts: statsBody.counts,\n dead: deadBody.items,\n processing: processingBody.items,\n recent: recentBody.items,\n scheduled: scheduledBody.items,\n heartbeats: heartbeatBody.workers,\n healthyWorker: heartbeatBody.healthy,\n catalog: catalogBody.items,\n }\n },\n [apiBasePath],\n )\n\n const {\n data: live,\n isLoading,\n error: liveError,\n refetch,\n } = useLiveSnapshot<QueueInitialSnapshot>({\n fetcher,\n topics: QUEUE_LIVE_TOPICS,\n initialData,\n })\n\n // `live` cannot be null when `initialData` is supplied; the hook seeds\n // `data` synchronously. Fall back defensively so a misconfigured caller\n // still renders rather than crashing.\n const data = live ?? initialData\n const error = actionError ?? (liveError ? liveError.message : null)\n\n const action = useCallback(\n async (id: string, path: string, method: 'POST' | 'DELETE' = 'POST') => {\n setBusyId(id)\n setActionError(null)\n try {\n const res = await fetch(`${apiBasePath}/queue${path}`, {\n method,\n credentials: 'same-origin',\n })\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as { error?: string }\n throw new Error(body.error ?? `Request failed (${res.status})`)\n }\n // The realtime publish from the route handler will trigger\n // `useLiveSnapshot` debounce too, but we refetch immediately so\n // the operator sees their action's effect without waiting for\n // the debounce window.\n refetch()\n } catch (err) {\n setActionError(err instanceof Error ? err.message : String(err))\n } finally {\n setBusyId(null)\n }\n },\n [apiBasePath, refetch],\n )\n\n const healthLabel = data.healthyWorker ? 'Worker healthy' : 'Worker stale'\n const healthColor = data.healthyWorker\n ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'\n : 'bg-rose-500/10 text-rose-600 dark:text-rose-400'\n const liveBadge = useMemo(() => liveStatusBadge(transportStatus), [transportStatus])\n\n return (\n <div className=\"p-6 space-y-4\">\n <div className=\"flex items-center justify-between flex-wrap gap-3\">\n <div>\n <h1 className=\"text-2xl font-bold\">Queue</h1>\n <p className=\"text-sm text-muted-foreground\">\n Background jobs, schedules, and worker health.\n </p>\n </div>\n <div className=\"flex items-center gap-3\">\n <span\n className={cn(\n 'inline-flex items-center rounded-full px-3 py-1 text-xs font-medium',\n healthColor,\n )}\n >\n {healthLabel}\n </span>\n <span\n className={cn(\n 'inline-flex items-center rounded-full px-3 py-1 text-xs font-medium',\n liveBadge.color,\n )}\n title={liveBadge.title}\n >\n {liveBadge.label}\n </span>\n <Button onClick={refetch} disabled={isLoading} variant=\"outline\" size=\"sm\">\n {isLoading ? 'Refreshing…' : 'Refresh'}\n </Button>\n </div>\n </div>\n\n {error && (\n <div className=\"rounded-md border border-rose-300/60 bg-rose-50/80 p-3 text-sm text-rose-700 dark:border-rose-600/40 dark:bg-rose-950/40 dark:text-rose-300\">\n {error}\n </div>\n )}\n\n <CountsBar counts={data.counts} />\n\n <nav className=\"flex gap-1 border-b\" role=\"tablist\" aria-label=\"Queue views\">\n {TABS.map((t) => (\n <button\n key={t.key}\n role=\"tab\"\n type=\"button\"\n aria-selected={tab === t.key}\n onClick={() => setTab(t.key)}\n className={cn(\n 'px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',\n tab === t.key\n ? 'border-foreground text-foreground'\n : 'border-transparent text-muted-foreground hover:text-foreground',\n )}\n >\n {t.label}\n {t.key === 'in-progress' && data.processing.length > 0 && (\n <Badge variant=\"secondary\" className=\"ml-2\">\n {data.processing.length}\n </Badge>\n )}\n {t.key === 'dead' && data.dead.length > 0 && (\n <Badge variant=\"destructive\" className=\"ml-2\">\n {data.dead.length}\n </Badge>\n )}\n </button>\n ))}\n </nav>\n\n {tab === 'in-progress' && (\n <ProcessingTab jobs={data.processing} action={action} busyId={busyId} />\n )}\n {tab === 'dead' && <DeadTab jobs={data.dead} action={action} busyId={busyId} />}\n {tab === 'scheduled' && (\n <ScheduledTab schedules={data.scheduled} action={action} busyId={busyId} />\n )}\n {tab === 'recent' && <RecentTab jobs={data.recent} />}\n {tab === 'stats' && <StatsTab counts={data.counts} heartbeats={data.heartbeats} />}\n {tab === 'catalog' && <CatalogTab entries={data.catalog} />}\n </div>\n )\n}\n\n// ---------------------------------------------------------------------------\n// Sub-components\n// ---------------------------------------------------------------------------\n\nfunction CountsBar({ counts }: { counts: Record<string, number> }): React.ReactElement {\n // Show only the actionable counts: work queued, work in flight, work\n // that died and needs triage. The all-time `completed` count grows\n // forever and tells the operator nothing — it's surfaced on the Stats\n // tab for completeness but doesn't deserve a prime slot here.\n const entries: Array<[string, number, string]> = [\n ['pending', counts.pending ?? 0, 'bg-amber-500/10 text-amber-700 dark:text-amber-400'],\n ['processing', counts.processing ?? 0, 'bg-sky-500/10 text-sky-700 dark:text-sky-400'],\n ['dead', counts.dead ?? 0, 'bg-rose-500/10 text-rose-700 dark:text-rose-400'],\n ]\n return (\n <div className=\"flex flex-wrap gap-3\">\n {entries.map(([label, count, color]) => (\n <div\n key={label}\n className={cn('rounded-md px-3 py-2 text-sm font-medium', color)}\n >\n <span className=\"capitalize\">{label}</span>\n <span className=\"ml-2 font-mono\">{count}</span>\n </div>\n ))}\n </div>\n )\n}\n\nfunction ProcessingTab({\n jobs,\n action,\n busyId,\n}: {\n jobs: readonly QueueJobSummary[]\n action: (id: string, path: string, method?: 'POST' | 'DELETE') => Promise<void>\n busyId: string | null\n}): React.ReactElement {\n if (jobs.length === 0) return <EmptyState text=\"Nothing is processing right now.\" />\n\n return (\n <div className=\"overflow-x-auto rounded-md border\">\n <table className=\"w-full text-sm\">\n <thead className=\"bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground\">\n <tr>\n <th className=\"px-3 py-2 text-left\">Type</th>\n <th className=\"px-3 py-2 text-left\">Worker</th>\n <th className=\"px-3 py-2 text-left\">Locked at</th>\n <th className=\"px-3 py-2 text-left\">Runtime</th>\n <th className=\"px-3 py-2 text-right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n {jobs.map((j) => (\n <tr key={j.id} className=\"border-t\">\n <td className=\"px-3 py-2 font-mono text-xs\">{j.type}</td>\n <td className=\"px-3 py-2 font-mono text-xs\">{j.lockedBy ?? '—'}</td>\n <td className=\"px-3 py-2\">\n {j.lockedAt ? new Date(j.lockedAt).toLocaleTimeString() : '—'}\n </td>\n <td className=\"px-3 py-2\">{j.lockedAt ? runtimeOf(j.lockedAt) : '—'}</td>\n <td className=\"px-3 py-2 text-right\">\n <Button\n size=\"sm\"\n variant=\"outline\"\n disabled={busyId === j.id}\n onClick={() => action(j.id, `/jobs/${j.id}/cancel`)}\n >\n Cancel\n </Button>\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )\n}\n\nfunction DeadTab({\n jobs,\n action,\n busyId,\n}: {\n jobs: readonly QueueJobSummary[]\n action: (id: string, path: string, method?: 'POST' | 'DELETE') => Promise<void>\n busyId: string | null\n}): React.ReactElement {\n if (jobs.length === 0) return <EmptyState text=\"Dead letter is empty. Nothing to triage.\" />\n\n return (\n <div className=\"overflow-x-auto rounded-md border\">\n <table className=\"w-full text-sm\">\n <thead className=\"bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground\">\n <tr>\n <th className=\"px-3 py-2 text-left\">Type</th>\n <th className=\"px-3 py-2 text-left\">Failed at</th>\n <th className=\"px-3 py-2 text-left\">Attempts</th>\n <th className=\"px-3 py-2 text-left\">Last error</th>\n <th className=\"px-3 py-2 text-right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n {jobs.map((j) => (\n <tr key={j.id} className=\"border-t align-top\">\n <td className=\"px-3 py-2 font-mono text-xs whitespace-nowrap\">{j.type}</td>\n <td className=\"px-3 py-2 whitespace-nowrap\">\n {j.failedAt ? new Date(j.failedAt).toLocaleString() : '—'}\n </td>\n <td className=\"px-3 py-2\">\n {j.attempts}/{j.maxRetries}\n </td>\n <td className=\"px-3 py-2 text-xs text-muted-foreground max-w-md break-words\">\n {j.lastError ?? '—'}\n </td>\n <td className=\"px-3 py-2 text-right whitespace-nowrap\">\n <Button\n size=\"sm\"\n variant=\"outline\"\n disabled={busyId === j.id}\n onClick={() => action(j.id, `/jobs/${j.id}/requeue`)}\n className=\"mr-2\"\n >\n Requeue\n </Button>\n <Button\n size=\"sm\"\n variant=\"destructive\"\n disabled={busyId === j.id}\n onClick={() => action(j.id, `/jobs/${j.id}`, 'DELETE')}\n >\n Delete\n </Button>\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )\n}\n\nfunction ScheduledTab({\n schedules,\n action,\n busyId,\n}: {\n schedules: readonly QueueScheduledSummary[]\n action: (id: string, path: string, method?: 'POST' | 'DELETE') => Promise<void>\n busyId: string | null\n}): React.ReactElement {\n if (schedules.length === 0) return <EmptyState text=\"No scheduled jobs registered.\" />\n\n return (\n <div className=\"overflow-x-auto rounded-md border\">\n <table className=\"w-full text-sm\">\n <thead className=\"bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground\">\n <tr>\n <th className=\"px-3 py-2 text-left\">Name</th>\n <th className=\"px-3 py-2 text-left\">Schedule</th>\n <th className=\"px-3 py-2 text-left\">Next run</th>\n <th className=\"px-3 py-2 text-left\">Last run</th>\n <th className=\"px-3 py-2 text-left\">Status</th>\n <th className=\"px-3 py-2 text-right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n {schedules.map((s) => (\n <tr key={s.id} className=\"border-t\">\n <td className=\"px-3 py-2\">\n <div className=\"font-mono text-xs\">{s.name}</div>\n <div className=\"text-xs text-muted-foreground\">{s.type}</div>\n </td>\n <td className=\"px-3 py-2 font-mono text-xs\">{s.schedule}</td>\n <td className=\"px-3 py-2 whitespace-nowrap\">\n {s.nextRunAt ? new Date(s.nextRunAt).toLocaleString() : '—'}\n </td>\n <td className=\"px-3 py-2 whitespace-nowrap\">\n {s.lastRunAt ? new Date(s.lastRunAt).toLocaleString() : 'Never'}\n </td>\n <td className=\"px-3 py-2\">\n {s.enabled ? (\n <Badge variant=\"secondary\">Enabled</Badge>\n ) : (\n <Badge variant=\"outline\">Disabled</Badge>\n )}\n </td>\n <td className=\"px-3 py-2 text-right whitespace-nowrap\">\n <Button\n size=\"sm\"\n variant=\"outline\"\n disabled={busyId === s.id}\n onClick={() => action(s.id, `/scheduled/${s.id}/run-now`)}\n className=\"mr-1\"\n >\n Run now\n </Button>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n disabled={busyId === s.id}\n onClick={() =>\n action(s.id, `/scheduled/${s.id}/${s.enabled ? 'disable' : 'enable'}`)\n }\n className=\"mr-1\"\n >\n {s.enabled ? 'Disable' : 'Enable'}\n </Button>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n disabled={busyId === s.id}\n onClick={() => action(s.id, `/scheduled/${s.id}/reset-lease`)}\n >\n Reset lease\n </Button>\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )\n}\n\nfunction RecentTab({ jobs }: { jobs: readonly QueueJobSummary[] }): React.ReactElement {\n if (jobs.length === 0) return <EmptyState text=\"No recent jobs.\" />\n\n return (\n <div className=\"overflow-x-auto rounded-md border\">\n <table className=\"w-full text-sm\">\n <thead className=\"bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground\">\n <tr>\n <th className=\"px-3 py-2 text-left\">Type</th>\n <th className=\"px-3 py-2 text-left\">Status</th>\n <th className=\"px-3 py-2 text-left\">Created</th>\n <th className=\"px-3 py-2 text-left\">Completed</th>\n </tr>\n </thead>\n <tbody>\n {jobs.map((j) => (\n <tr key={j.id} className=\"border-t\">\n <td className=\"px-3 py-2 font-mono text-xs\">{j.type}</td>\n <td className=\"px-3 py-2\">\n <StatusBadge status={j.status} />\n </td>\n <td className=\"px-3 py-2 whitespace-nowrap\">\n {new Date(j.createdAt).toLocaleString()}\n </td>\n <td className=\"px-3 py-2 whitespace-nowrap\">\n {j.completedAt\n ? new Date(j.completedAt).toLocaleString()\n : j.failedAt\n ? new Date(j.failedAt).toLocaleString()\n : '—'}\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )\n}\n\nfunction StatsTab({\n counts,\n heartbeats,\n}: {\n counts: Record<string, number>\n heartbeats: readonly QueueHeartbeat[]\n}): React.ReactElement {\n return (\n <div className=\"space-y-6\">\n <section>\n <h2 className=\"font-semibold mb-2\">Status counts</h2>\n <div className=\"grid grid-cols-2 md:grid-cols-5 gap-2\">\n {Object.entries(counts).map(([status, count]) => (\n <div key={status} className=\"rounded-md border p-3\">\n <div className=\"text-xs uppercase tracking-wide text-muted-foreground\">\n {status}\n </div>\n <div className=\"text-2xl font-mono\">{count}</div>\n </div>\n ))}\n </div>\n </section>\n <section>\n <h2 className=\"font-semibold mb-2\">Workers</h2>\n {heartbeats.length === 0 ? (\n <p className=\"text-sm text-muted-foreground\">No worker has reported a heartbeat yet.</p>\n ) : (\n <div className=\"overflow-x-auto rounded-md border\">\n <table className=\"w-full text-sm\">\n <thead className=\"bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground\">\n <tr>\n <th className=\"px-3 py-2 text-left\">Worker</th>\n <th className=\"px-3 py-2 text-left\">Started</th>\n <th className=\"px-3 py-2 text-left\">Last seen</th>\n <th className=\"px-3 py-2 text-left\">Active</th>\n <th className=\"px-3 py-2 text-left\">Status</th>\n </tr>\n </thead>\n <tbody>\n {heartbeats.map((h) => (\n <tr key={h.workerId} className=\"border-t\">\n <td className=\"px-3 py-2 font-mono text-xs\">{h.workerId}</td>\n <td className=\"px-3 py-2 whitespace-nowrap\">\n {new Date(h.startedAt).toLocaleString()}\n </td>\n <td className=\"px-3 py-2 whitespace-nowrap\">\n {new Date(h.lastSeenAt).toLocaleString()}\n </td>\n <td className=\"px-3 py-2\">\n {h.activeJobs}/{h.concurrency}\n </td>\n <td className=\"px-3 py-2\">\n {h.stale ? (\n <Badge variant=\"destructive\">Stale</Badge>\n ) : (\n <Badge variant=\"secondary\">Fresh</Badge>\n )}\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )}\n </section>\n </div>\n )\n}\n\nfunction StatusBadge({ status }: { status: string }): React.ReactElement {\n const variant: 'default' | 'secondary' | 'outline' | 'destructive' =\n status === 'completed'\n ? 'secondary'\n : status === 'dead'\n ? 'destructive'\n : status === 'processing'\n ? 'default'\n : 'outline'\n return <Badge variant={variant}>{status}</Badge>\n}\n\nfunction EmptyState({ text }: { text: string }): React.ReactElement {\n return (\n <div className=\"rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground\">\n {text}\n </div>\n )\n}\n\nfunction liveStatusBadge(status: 'connecting' | 'open' | 'closed'): {\n label: string\n color: string\n title: string\n} {\n if (status === 'open') {\n return {\n label: 'Live',\n color: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',\n title: 'Realtime connection active — table updates as the worker reports them.',\n }\n }\n if (status === 'connecting') {\n return {\n label: 'Connecting…',\n color: 'bg-amber-500/10 text-amber-700 dark:text-amber-400',\n title: 'Connecting to realtime stream — the page will revalidate when events arrive.',\n }\n }\n return {\n label: 'Offline',\n color: 'bg-zinc-500/10 text-zinc-600 dark:text-zinc-400',\n title: 'Realtime stream unavailable — use the Refresh button to update.',\n }\n}\n\nfunction runtimeOf(iso: string): string {\n const ms = Date.now() - new Date(iso).getTime()\n if (ms < 0) return '—'\n if (ms < 60_000) return `${Math.round(ms / 1000)}s`\n if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`\n return `${Math.round(ms / 3_600_000)}h`\n}\n\nfunction CatalogTab({\n entries,\n}: {\n entries: readonly QueueCatalogEntry[]\n}): React.ReactElement {\n if (entries.length === 0) {\n return (\n <EmptyState text=\"No jobs registered yet. Plugins declare jobs via defineJob/registerJob.\" />\n )\n }\n return (\n <div className=\"overflow-x-auto rounded-md border\">\n <table className=\"w-full text-sm\">\n <thead className=\"bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground\">\n <tr>\n <th className=\"px-3 py-2 text-left\">Job type</th>\n <th className=\"px-3 py-2 text-left\">Description</th>\n <th className=\"px-3 py-2 text-left\">Schedule</th>\n <th className=\"px-3 py-2 text-left\">Last run</th>\n <th className=\"px-3 py-2 text-left\">24 h volume</th>\n <th className=\"px-3 py-2 text-left\">24 h success</th>\n </tr>\n </thead>\n <tbody>\n {entries.map((entry) => (\n <tr key={entry.name} className=\"border-t align-top\">\n <td className=\"px-3 py-2 whitespace-nowrap\">\n <div className=\"font-mono text-xs\">{entry.name}</div>\n {!entry.registered && (\n <Badge variant=\"outline\" className=\"mt-1\">\n unregistered\n </Badge>\n )}\n {entry.scheduled && !entry.scheduled.enabled && (\n <Badge variant=\"outline\" className=\"mt-1 ml-1\">\n schedule disabled\n </Badge>\n )}\n </td>\n <td className=\"px-3 py-2 text-xs text-muted-foreground max-w-md\">\n {entry.description ?? <span className=\"italic\">—</span>}\n </td>\n <td className=\"px-3 py-2 font-mono text-xs whitespace-nowrap\">\n {entry.schedule ?? (\n <span className=\"text-muted-foreground italic\">event-driven</span>\n )}\n </td>\n <td className=\"px-3 py-2 whitespace-nowrap\">\n {entry.lastRunAt ? new Date(entry.lastRunAt).toLocaleString() : '—'}\n </td>\n <td className=\"px-3 py-2 whitespace-nowrap\">\n {entry.stats24h.total === 0 ? (\n '—'\n ) : (\n <span className=\"font-mono text-xs\">\n {entry.stats24h.total} total\n {entry.stats24h.dead > 0 && (\n <span className=\"ml-1 text-rose-600 dark:text-rose-400\">\n ({entry.stats24h.dead} dead)\n </span>\n )}\n </span>\n )}\n </td>\n <td className=\"px-3 py-2 whitespace-nowrap\">\n {entry.successRate24h === null\n ? '—'\n : `${Math.round(entry.successRate24h * 100)}%`}\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )\n}\n"],"mappings":"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"}
|
package/dist/pages.d.mts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { PageProps } from "@murumets-ee/core";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
//#region src/pages/queue-page.d.ts
|
|
5
|
+
declare function QueuePage({
|
|
6
|
+
params
|
|
7
|
+
}: PageProps<{
|
|
8
|
+
locale: string;
|
|
9
|
+
}>): Promise<ReactNode>;
|
|
10
|
+
//#endregion
|
|
11
|
+
export { QueuePage };
|
|
12
|
+
//# sourceMappingURL=pages.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pages.d.mts","names":[],"sources":["../src/pages/queue-page.tsx"],"mappings":";;;;iBA2JsB,SAAA,CAAA;EACpB;AAAA,GACC,SAAA;EAAY,MAAA;AAAA,KAAoB,OAAA,CAAQ,SAAA"}
|
package/dist/pages.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{getRegisteredAdminConfig as e}from"@murumets-ee/admin-ui/config-registry";import{getApp as t}from"@murumets-ee/core";import{buildCatalog as n,heartbeatsTable as r,jobsTable as i,scheduledJobsTable as a}from"@murumets-ee/queue/admin";import{getQueueConfig as o}from"@murumets-ee/queue/plugin";import{setRequestLocale as s}from"next-intl/server";import{QueuePageClient as c}from"@murumets-ee/queue-ui";import{jsx as l}from"react/jsx-runtime";async function u(){let e=t().db.readWrite,s=i.makeClient(e),c=a.makeClient(e),l=r.makeClient(e),[u,f,p,m,h,g]=await Promise.all([s.aggregate({select:{count:{fn:`count`}},groupBy:[`status`]}),s.findMany({where:{status:`dead`},orderBy:[{column:`failedAt`,dir:`desc`}],limit:50}),s.findMany({where:{status:`processing`},orderBy:[{column:`lockedAt`,dir:`desc`}],limit:50}),s.findMany({orderBy:[{column:`createdAt`,dir:`desc`}],limit:100}),c.findMany({orderBy:[{column:`name`,dir:`asc`}],limit:200}),l.findMany({orderBy:[{column:`lastSeenAt`,dir:`desc`}],limit:50})]),_={};for(let e of u)_[e.status]=Number(e.count);let v=(()=>{try{return o().heartbeatStaleAfter}catch{return 12e4}})(),y=Date.now(),b=g.map(e=>({workerId:e.workerId,startedAt:e.startedAt.toISOString(),lastSeenAt:e.lastSeenAt.toISOString(),concurrency:e.concurrency,activeJobs:e.activeJobs,stale:y-e.lastSeenAt.getTime()>v})),x=await n(s,c);return{counts:_,dead:f.map(e=>d(e)),processing:p.map(e=>d(e)),recent:m.map(e=>d(e)),scheduled:h.map(e=>({id:e.id,name:e.name,type:e.type,schedule:e.schedule,enabled:e.enabled,nextRunAt:e.nextRunAt?e.nextRunAt.toISOString():null,lastRunAt:e.lastRunAt?e.lastRunAt.toISOString():null})),heartbeats:b,healthyWorker:b.some(e=>!e.stale),catalog:x}}function d(e){return{id:e.id??``,type:e.type,status:e.status,attempts:e.attempts,maxRetries:e.maxRetries,runAt:e.runAt.toISOString(),lockedAt:e.lockedAt?.toISOString()??null,lockedBy:e.lockedBy,completedAt:e.completedAt?.toISOString()??null,failedAt:e.failedAt?.toISOString()??null,lastError:e.lastError?f(e.lastError,500):null,progress:e.progress,createdAt:e.createdAt.toISOString()}}function f(e,t){return e.length>t?`${e.slice(0,t)}…`:e}async function p({params:t}){let n=e(),{locale:r}=await t;s(r);let i=n.apiBasePath??`/api/admin`;return n.withAdminContext(r,async()=>l(c,{apiBasePath:i,initialData:await u()}))}export{p as QueuePage};
|
|
2
|
+
//# sourceMappingURL=pages.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pages.mjs","names":[],"sources":["../src/pages/queue-page.tsx"],"sourcesContent":["/**\n * Server-side render entry for the queue admin page.\n *\n * Fetches an initial snapshot directly from the queue tables — bypassing\n * the admin HTTP API for the first paint to keep the route a single\n * round-trip. Subsequent refreshes go through `/api/admin/queue/*` so the\n * authorization / audit / CSRF layers all run.\n *\n * Permissions: the route file is wrapped in `AdminGuardLayout`, so anyone\n * reaching this page is authenticated. The HTTP API re-checks\n * `queue:view` per request when the client polls.\n */\n\nimport { getRegisteredAdminConfig } from '@murumets-ee/admin-ui/config-registry'\nimport { getApp, type PageProps } from '@murumets-ee/core'\nimport {\n buildCatalog,\n heartbeatsTable,\n jobsTable,\n scheduledJobsTable,\n} from '@murumets-ee/queue/admin'\nimport { getQueueConfig } from '@murumets-ee/queue/plugin'\nimport { setRequestLocale } from 'next-intl/server'\nimport type { ReactNode } from 'react'\nimport { QueuePageClient } from '@murumets-ee/queue-ui'\nimport type { QueueInitialSnapshot } from '@murumets-ee/queue-ui'\n\nasync function loadSnapshot(): Promise<QueueInitialSnapshot> {\n const app = getApp()\n const db = app.db.readWrite\n\n const jobsClient = jobsTable.makeClient(db)\n const scheduledClient = scheduledJobsTable.makeClient(db)\n const heartbeatsClient = heartbeatsTable.makeClient(db)\n\n const [statusCounts, deadJobs, processingJobs, recentJobs, scheduled, heartbeats] =\n await Promise.all([\n jobsClient.aggregate<{ status: string; count: number }>({\n select: { count: { fn: 'count' } },\n groupBy: ['status'],\n }),\n jobsClient.findMany({\n where: { status: 'dead' },\n orderBy: [{ column: 'failedAt', dir: 'desc' }],\n limit: 50,\n }),\n jobsClient.findMany({\n where: { status: 'processing' },\n orderBy: [{ column: 'lockedAt', dir: 'desc' }],\n limit: 50,\n }),\n jobsClient.findMany({\n orderBy: [{ column: 'createdAt', dir: 'desc' }],\n limit: 100,\n }),\n scheduledClient.findMany({\n orderBy: [{ column: 'name', dir: 'asc' }],\n limit: 200,\n }),\n heartbeatsClient.findMany({\n orderBy: [{ column: 'lastSeenAt', dir: 'desc' }],\n limit: 50,\n }),\n ])\n\n const counts: Record<string, number> = {}\n for (const r of statusCounts) counts[r.status] = Number(r.count)\n\n // Read the configured stale window from the queue plugin config so a\n // consumer who raised `heartbeatStaleAfter` past the 2-minute default\n // doesn't see the dashboard mark workers stale based on a hardcoded\n // threshold. Falls back to the documented default if the plugin\n // hasn't been initialised (defensive — shouldn't happen at page render).\n const staleAfterMs = (() => {\n try {\n return getQueueConfig().heartbeatStaleAfter\n } catch {\n return 120_000\n }\n })()\n\n const now = Date.now()\n const annotatedHeartbeats = heartbeats.map((h) => ({\n workerId: h.workerId,\n startedAt: h.startedAt.toISOString(),\n lastSeenAt: h.lastSeenAt.toISOString(),\n concurrency: h.concurrency,\n activeJobs: h.activeJobs,\n stale: now - h.lastSeenAt.getTime() > staleAfterMs,\n }))\n\n // Catalog is rendered alongside the live tables here so the first paint\n // contains the full snapshot — same call shape as the polling refresh\n // (`/api/admin/queue/catalog`), driven by the shared `buildCatalog`\n // helper to keep server-page render and HTTP API in lockstep.\n const catalog = await buildCatalog(jobsClient, scheduledClient)\n\n return {\n counts,\n dead: deadJobs.map((j) => sanitizeJobRow(j)),\n processing: processingJobs.map((j) => sanitizeJobRow(j)),\n recent: recentJobs.map((j) => sanitizeJobRow(j)),\n scheduled: scheduled.map((s) => ({\n id: s.id,\n name: s.name,\n type: s.type,\n schedule: s.schedule,\n enabled: s.enabled,\n nextRunAt: s.nextRunAt ? s.nextRunAt.toISOString() : null,\n lastRunAt: s.lastRunAt ? s.lastRunAt.toISOString() : null,\n })),\n heartbeats: annotatedHeartbeats,\n healthyWorker: annotatedHeartbeats.some((h) => !h.stale),\n catalog,\n }\n}\n\nfunction sanitizeJobRow(row: {\n id: string | null\n type: string\n status: string\n attempts: number\n maxRetries: number\n runAt: Date\n lockedAt: Date | null\n lockedBy: string | null\n completedAt: Date | null\n failedAt: Date | null\n lastError: string | null\n progress: unknown\n createdAt: Date\n}) {\n // PK is `notNull` in postgres; the type widening lets `null` slip through —\n // skip if so (defensive).\n return {\n id: row.id ?? '',\n type: row.type,\n status: row.status,\n attempts: row.attempts,\n maxRetries: row.maxRetries,\n runAt: row.runAt.toISOString(),\n lockedAt: row.lockedAt?.toISOString() ?? null,\n lockedBy: row.lockedBy,\n completedAt: row.completedAt?.toISOString() ?? null,\n failedAt: row.failedAt?.toISOString() ?? null,\n lastError: row.lastError ? truncate(row.lastError, 500) : null,\n progress: row.progress,\n createdAt: row.createdAt.toISOString(),\n }\n}\n\nfunction truncate(s: string, max: number): string {\n return s.length > max ? `${s.slice(0, max)}…` : s\n}\n\nexport async function QueuePage({\n params,\n}: PageProps<{ locale: string }>): Promise<ReactNode> {\n const config = getRegisteredAdminConfig()\n const { locale } = await params\n setRequestLocale(locale)\n\n const apiBasePath = config.apiBasePath ?? '/api/admin'\n\n return config.withAdminContext(locale, async () => {\n const snapshot = await loadSnapshot()\n return <QueuePageClient apiBasePath={apiBasePath} initialData={snapshot} />\n })\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { QueueConfig, QueueConfig as QueueConfig$1 } from "@murumets-ee/queue/plugin";
|
|
2
|
+
import { Plugin } from "@murumets-ee/core";
|
|
3
|
+
|
|
4
|
+
//#region src/plugin.d.ts
|
|
5
|
+
declare function queue(config?: QueueConfig$1): Plugin;
|
|
6
|
+
//#endregion
|
|
7
|
+
export { type QueueConfig, queue };
|
|
8
|
+
//# sourceMappingURL=plugin.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;;;iBA2CgB,KAAA,CAAM,MAAA,GAAS,aAAA,GAAc,MAAA"}
|
package/dist/plugin.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{queue as e}from"@murumets-ee/queue/plugin";import{queueRoutes as t}from"@murumets-ee/queue/admin";import{QueueHealthWidget as n}from"@murumets-ee/queue-ui/widgets";const r=async e=>(await import(`@murumets-ee/queue-ui/pages`)).QueuePage(e);function i(i){let a=e(i).server??{};return{name:`@murumets-ee/queue`,server:{...a,routes:[...a.routes??[],t()]},shared:{pluginResources:[{name:`queue`,actions:[`view`,`update`]}]},adminUi:{pages:{QueuePage:r},sidebar:[{id:`queue:overview`,group:`System`,label:`Queue`,href:`/admin/system/queue`,iconName:`list-todo`}],dashboardWidgets:[{id:`queue-health`,component:n}],defaultRoutes:[{path:`system/queue`,factory:`QueuePage`,nav:{label:`Queue`,iconName:`list-todo`,group:`System`}}]}}}export{i as queue};
|
|
2
|
+
//# sourceMappingURL=plugin.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin.mjs","names":["serverQueue"],"sources":["../src/plugin.ts"],"sourcesContent":["/**\n * Queue plugin — assembles the server queue plugin (`@murumets-ee/queue`)\n * with the admin UI surface (page, dashboard widget, sidebar entry) so\n * consumers get the full observability story from a single entry:\n *\n * ```ts\n * import { defineLumiConfig } from '@murumets-ee/core'\n * import { queue } from '@murumets-ee/queue-ui/plugin'\n *\n * export default defineLumiConfig({\n * plugins: [\n * queue({\n * concurrency: 10,\n * alerts: { email: { to: ['ops@example.com'] } },\n * }),\n * ],\n * })\n * ```\n *\n * Location note: this factory lives in `@murumets-ee/queue-ui`, not in\n * `@murumets-ee/queue`, because the UI half depends on the server half\n * (one-directional). Hosting the assembled factory here keeps that.\n */\n\nimport type { PageFactory, Plugin } from '@murumets-ee/core'\nimport { queue as serverQueue } from '@murumets-ee/queue/plugin'\nimport { queueRoutes } from '@murumets-ee/queue/admin'\nimport type { QueueConfig } from '@murumets-ee/queue/plugin'\nimport { QueueHealthWidget } from '@murumets-ee/queue-ui/widgets'\n\n// Lazy page factory — see `@murumets-ee/ticketing-ui/src/plugin.ts` for the\n// rationale. Plugin factory is evaluated at `lumi.config.ts` load time\n// (jiti/tsx for CLI + worker), neither of which applies the `react-server`\n// export condition. Eager top-level imports of the page module pull in\n// `next-intl/server` etc. and throw under jiti. Wrapping as a thunk defers\n// the import until Next.js RSC render where the conditions resolve.\nconst QueuePage: PageFactory<{ locale: string }> = async (props) => {\n const mod = await import('@murumets-ee/queue-ui/pages')\n return mod.QueuePage(props)\n}\n\nexport type { QueueConfig } from '@murumets-ee/queue/plugin'\n\nexport function queue(config?: QueueConfig): Plugin {\n // Compose the server plugin first to inherit its `init` hook (which\n // wires up tables, alerter, digest, etc.). We then layer the admin UI\n // contributions on top.\n const serverPlugin = serverQueue(config)\n const serverContrib = serverPlugin.server ?? {}\n\n return {\n name: '@murumets-ee/queue',\n server: {\n ...serverContrib,\n // Splice the queue admin routes in alongside any existing routes the\n // server plugin already registered (today: none, but\n // forward-compatible).\n routes: [...(serverContrib.routes ?? []), queueRoutes()],\n },\n shared: {\n // Two distinct actions on a single resource so least-privilege\n // policies can grant read-only access without giving away the\n // ability to delete/requeue. The handler-level gate uses\n // `queue:view` for GET, `queue:update` for mutations.\n pluginResources: [{ name: 'queue', actions: ['view', 'update'] }],\n },\n adminUi: {\n pages: {\n QueuePage,\n },\n sidebar: [\n {\n id: 'queue:overview',\n group: 'System',\n label: 'Queue',\n href: '/admin/system/queue',\n iconName: 'list-todo',\n },\n ],\n dashboardWidgets: [\n {\n id: 'queue-health',\n component: QueueHealthWidget,\n },\n ],\n defaultRoutes: [\n {\n path: 'system/queue',\n factory: 'QueuePage',\n nav: { label: 'Queue', iconName: 'list-todo', group: 'System' },\n },\n ],\n },\n }\n}\n"],"mappings":"2KAoCA,MAAM,EAA6C,KAAO,KAEjD,MADW,OAAO,gCACd,UAAU,EAAM,CAK7B,SAAgB,EAAM,EAA8B,CAKlD,IAAM,EADeA,EAAY,EACC,CAAC,QAAU,EAAE,CAE/C,MAAO,CACL,KAAM,qBACN,OAAQ,CACN,GAAG,EAIH,OAAQ,CAAC,GAAI,EAAc,QAAU,EAAE,CAAG,GAAa,CAAC,CACzD,CACD,OAAQ,CAKN,gBAAiB,CAAC,CAAE,KAAM,QAAS,QAAS,CAAC,OAAQ,SAAS,CAAE,CAAC,CAClE,CACD,QAAS,CACP,MAAO,CACL,YACD,CACD,QAAS,CACP,CACE,GAAI,iBACJ,MAAO,SACP,MAAO,QACP,KAAM,sBACN,SAAU,YACX,CACF,CACD,iBAAkB,CAChB,CACE,GAAI,eACJ,UAAW,EACZ,CACF,CACD,cAAe,CACb,CACE,KAAM,eACN,QAAS,YACT,IAAK,CAAE,MAAO,QAAS,SAAU,YAAa,MAAO,SAAU,CAChE,CACF,CACF,CACF"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ReactElement } from "react";
|
|
2
|
+
|
|
3
|
+
//#region src/widgets/queue-health-widget.d.ts
|
|
4
|
+
interface QueueHealthWidgetProps {
|
|
5
|
+
config: {
|
|
6
|
+
apiBasePath?: string | undefined;
|
|
7
|
+
adminBasePath: string;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
declare function QueueHealthWidget({
|
|
11
|
+
config
|
|
12
|
+
}: QueueHealthWidgetProps): Promise<ReactElement>;
|
|
13
|
+
//#endregion
|
|
14
|
+
export { QueueHealthWidget, type QueueHealthWidgetProps };
|
|
15
|
+
//# sourceMappingURL=widgets.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"widgets.d.mts","names":[],"sources":["../src/widgets/queue-health-widget.tsx"],"mappings":";;;UAwBiB,sBAAA;EACf,MAAA;IACE,WAAA;IACA,aAAA;EAAA;AAAA;AAAA,iBAqEkB,iBAAA,CAAA;EACpB;AAAA,GACC,sBAAA,GAAyB,OAAA,CAAQ,YAAA"}
|
package/dist/widgets.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{Card as e,CardContent as t,CardHeader as n,CardTitle as r}from"@murumets-ee/ui";import{jsx as i,jsxs as a}from"react/jsx-runtime";async function o(){try{let{jobsTable:e,heartbeatsTable:t}=await import(`@murumets-ee/queue/admin`),{getApp:n}=await import(`@murumets-ee/core`),{getQueueConfig:r}=await import(`@murumets-ee/queue/plugin`),i=n().db.readWrite,a=e.makeClient(i),o=t.makeClient(i),s=r(),[c,l]=await Promise.all([a.aggregate({select:{count:{fn:`count`}},groupBy:[`status`]}),o.findMany({orderBy:[{column:`lastSeenAt`,dir:`desc`}],limit:50})]),u={};for(let e of c)u[e.status]=Number(e.count);let d=Date.now(),f=l.some(e=>d-e.lastSeenAt.getTime()<=s.heartbeatStaleAfter);return{pendingCount:u.pending??0,deadCount:u.dead??0,workerHealthy:f,error:null}}catch(e){return{pendingCount:0,deadCount:0,workerHealthy:!1,error:e instanceof Error?e.message:String(e)}}}const s={green:`bg-emerald-500`,yellow:`bg-amber-500`,red:`bg-rose-500`},c={green:`Healthy`,yellow:`Attention`,red:`Worker stale`};async function l({config:l}){let u=await o(),d=u.error?`red`:u.workerHealthy?u.deadCount>0?`yellow`:`green`:`red`,{getLocale:f}=await import(`next-intl/server`),p=await f(),m=`${l.adminBasePath.replace(`:locale`,p)}/system/queue`;return a(e,{children:[a(n,{className:`flex-row items-center justify-between pb-2`,children:[i(r,{className:`text-sm font-medium`,children:`Queue health`}),i(`span`,{className:`inline-block w-3 h-3 rounded-full ${s[d]}`,"aria-label":c[d]})]}),i(t,{children:u.error?i(`p`,{className:`text-sm text-rose-600 dark:text-rose-400`,children:u.error}):a(`div`,{className:`space-y-1`,children:[a(`p`,{className:`text-2xl font-mono`,children:[u.pendingCount,` `,i(`span`,{className:`text-sm text-muted-foreground`,children:`pending`})]}),u.deadCount>0&&a(`p`,{className:`text-sm text-rose-600 dark:text-rose-400`,children:[u.deadCount,` dead`]}),!u.workerHealthy&&i(`p`,{className:`text-sm text-rose-600 dark:text-rose-400`,children:`No fresh worker heartbeat — process may have crashed.`}),i(`a`,{href:m,className:`inline-block mt-2 text-xs text-foreground/70 underline hover:text-foreground`,children:`View queue →`})]})})]})}export{l as QueueHealthWidget};
|
|
2
|
+
//# sourceMappingURL=widgets.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"widgets.mjs","names":[],"sources":["../src/widgets/queue-health-widget.tsx"],"sourcesContent":["/**\n * Dashboard \"Queue health\" widget — traffic light + click-through.\n *\n * Server component. Mounted as a `'custom'` `AdminDashboardWidget` via\n * the `queue()` plugin factory. Reads queue stats + heartbeat from the\n * DB at render time, so first paint already shows the live status — no\n * client useEffect, no fetch waterfall.\n *\n * The widget references no DB types at import time — DB bindings come in\n * via dynamic import inside the async body. This keeps the widget module\n * safe to static-import from `@murumets-ee/queue-ui/plugin`, which the\n * CLI loads through jiti at `lumi.config.ts` evaluation time (jiti does\n * not apply Node's `react-server` condition, so any top-level\n * `'server-only'`-tagged import would throw).\n */\n\nimport { Card, CardContent, CardHeader, CardTitle } from '@murumets-ee/ui'\nimport type { ReactElement } from 'react'\n\n// `cn()` from `@murumets-ee/ui` lives in a `\"use client\"` module — calling\n// it from this server component crashes Next.js with \"cn is on the client\".\n// The widget only needs simple class concat (no tailwind-merge resolution),\n// so a template literal does the job.\n\nexport interface QueueHealthWidgetProps {\n config: {\n apiBasePath?: string | undefined\n adminBasePath: string\n }\n}\n\ninterface WidgetData {\n pendingCount: number\n deadCount: number\n workerHealthy: boolean\n error: string | null\n}\n\nasync function loadWidgetData(): Promise<WidgetData> {\n try {\n const { jobsTable, heartbeatsTable } = await import('@murumets-ee/queue/admin')\n const { getApp } = await import('@murumets-ee/core')\n const { getQueueConfig } = await import('@murumets-ee/queue/plugin')\n\n const db = getApp().db.readWrite\n const jobsClient = jobsTable.makeClient(db)\n const heartbeatsClient = heartbeatsTable.makeClient(db)\n const cfg = getQueueConfig()\n\n const [statusCounts, heartbeats] = await Promise.all([\n jobsClient.aggregate<{ status: string; count: number }>({\n select: { count: { fn: 'count' } },\n groupBy: ['status'],\n }),\n heartbeatsClient.findMany({\n orderBy: [{ column: 'lastSeenAt', dir: 'desc' }],\n limit: 50,\n }),\n ])\n\n const counts: Record<string, number> = {}\n for (const r of statusCounts) counts[r.status] = Number(r.count)\n\n const now = Date.now()\n const fresh = heartbeats.some(\n (h) => now - h.lastSeenAt.getTime() <= cfg.heartbeatStaleAfter,\n )\n\n return {\n pendingCount: counts.pending ?? 0,\n deadCount: counts.dead ?? 0,\n workerHealthy: fresh,\n error: null,\n }\n } catch (err) {\n return {\n pendingCount: 0,\n deadCount: 0,\n workerHealthy: false,\n error: err instanceof Error ? err.message : String(err),\n }\n }\n}\n\nconst STATUS_COLOR: Record<'green' | 'yellow' | 'red', string> = {\n green: 'bg-emerald-500',\n yellow: 'bg-amber-500',\n red: 'bg-rose-500',\n}\n\nconst STATUS_LABEL: Record<'green' | 'yellow' | 'red', string> = {\n green: 'Healthy',\n yellow: 'Attention',\n red: 'Worker stale',\n}\n\nexport async function QueueHealthWidget({\n config,\n}: QueueHealthWidgetProps): Promise<ReactElement> {\n const data = await loadWidgetData()\n\n const status: 'green' | 'yellow' | 'red' = data.error\n ? 'red'\n : !data.workerHealthy\n ? 'red'\n : data.deadCount > 0\n ? 'yellow'\n : 'green'\n\n // `adminBasePath` is a template like `/:locale/admin`. Resolve the\n // current locale via next-intl so the link points at the user's locale\n // (`/en/admin/...`). A naive `replace(':locale', '')` produces `//admin`\n // which the browser parses as a protocol-relative URL with host=admin.\n const { getLocale } = await import('next-intl/server')\n const locale = await getLocale()\n const queueHref = `${config.adminBasePath.replace(':locale', locale)}/system/queue`\n\n return (\n <Card>\n <CardHeader className=\"flex-row items-center justify-between pb-2\">\n <CardTitle className=\"text-sm font-medium\">Queue health</CardTitle>\n <span\n className={`inline-block w-3 h-3 rounded-full ${STATUS_COLOR[status]}`}\n aria-label={STATUS_LABEL[status]}\n />\n </CardHeader>\n <CardContent>\n {data.error ? (\n <p className=\"text-sm text-rose-600 dark:text-rose-400\">{data.error}</p>\n ) : (\n <div className=\"space-y-1\">\n <p className=\"text-2xl font-mono\">\n {data.pendingCount}{' '}\n <span className=\"text-sm text-muted-foreground\">pending</span>\n </p>\n {data.deadCount > 0 && (\n <p className=\"text-sm text-rose-600 dark:text-rose-400\">\n {data.deadCount} dead\n </p>\n )}\n {!data.workerHealthy && (\n <p className=\"text-sm text-rose-600 dark:text-rose-400\">\n No fresh worker heartbeat — process may have crashed.\n </p>\n )}\n <a\n href={queueHref}\n className=\"inline-block mt-2 text-xs text-foreground/70 underline hover:text-foreground\"\n >\n View queue →\n </a>\n </div>\n )}\n </CardContent>\n </Card>\n )\n}\n"],"mappings":"yIAsCA,eAAe,GAAsC,CACnD,GAAI,CACF,GAAM,CAAE,YAAW,mBAAoB,MAAM,OAAO,4BAC9C,CAAE,UAAW,MAAM,OAAO,qBAC1B,CAAE,kBAAmB,MAAM,OAAO,6BAElC,EAAK,GAAQ,CAAC,GAAG,UACjB,EAAa,EAAU,WAAW,EAAG,CACrC,EAAmB,EAAgB,WAAW,EAAG,CACjD,EAAM,GAAgB,CAEtB,CAAC,EAAc,GAAc,MAAM,QAAQ,IAAI,CACnD,EAAW,UAA6C,CACtD,OAAQ,CAAE,MAAO,CAAE,GAAI,QAAS,CAAE,CAClC,QAAS,CAAC,SAAS,CACpB,CAAC,CACF,EAAiB,SAAS,CACxB,QAAS,CAAC,CAAE,OAAQ,aAAc,IAAK,OAAQ,CAAC,CAChD,MAAO,GACR,CAAC,CACH,CAAC,CAEI,EAAiC,EAAE,CACzC,IAAK,IAAM,KAAK,EAAc,EAAO,EAAE,QAAU,OAAO,EAAE,MAAM,CAEhE,IAAM,EAAM,KAAK,KAAK,CAChB,EAAQ,EAAW,KACtB,GAAM,EAAM,EAAE,WAAW,SAAS,EAAI,EAAI,oBAC5C,CAED,MAAO,CACL,aAAc,EAAO,SAAW,EAChC,UAAW,EAAO,MAAQ,EAC1B,cAAe,EACf,MAAO,KACR,OACM,EAAK,CACZ,MAAO,CACL,aAAc,EACd,UAAW,EACX,cAAe,GACf,MAAO,aAAe,MAAQ,EAAI,QAAU,OAAO,EAAI,CACxD,EAIL,MAAM,EAA2D,CAC/D,MAAO,iBACP,OAAQ,eACR,IAAK,cACN,CAEK,EAA2D,CAC/D,MAAO,UACP,OAAQ,YACR,IAAK,eACN,CAED,eAAsB,EAAkB,CACtC,UACgD,CAChD,IAAM,EAAO,MAAM,GAAgB,CAE7B,EAAqC,EAAK,MAC5C,MACC,EAAK,cAEJ,EAAK,UAAY,EACf,SACA,QAHF,MASA,CAAE,aAAc,MAAM,OAAO,oBAC7B,EAAS,MAAM,GAAW,CAC1B,EAAY,GAAG,EAAO,cAAc,QAAQ,UAAW,EAAO,CAAC,eAErE,OACE,EAAC,EAAD,CAAA,SAAA,CACE,EAAC,EAAD,CAAY,UAAU,sDAAtB,CACE,EAAC,EAAD,CAAW,UAAU,+BAAsB,eAAwB,CAAA,CACnE,EAAC,OAAD,CACE,UAAW,qCAAqC,EAAa,KAC7D,aAAY,EAAa,GACzB,CAAA,CACS,GACb,EAAC,EAAD,CAAA,SACG,EAAK,MACJ,EAAC,IAAD,CAAG,UAAU,oDAA4C,EAAK,MAAU,CAAA,CAExE,EAAC,MAAD,CAAK,UAAU,qBAAf,CACE,EAAC,IAAD,CAAG,UAAU,8BAAb,CACG,EAAK,aAAc,IACpB,EAAC,OAAD,CAAM,UAAU,yCAAgC,UAAc,CAAA,CAC5D,GACH,EAAK,UAAY,GAChB,EAAC,IAAD,CAAG,UAAU,oDAAb,CACG,EAAK,UAAU,QACd,GAEL,CAAC,EAAK,eACL,EAAC,IAAD,CAAG,UAAU,oDAA2C,wDAEpD,CAAA,CAEN,EAAC,IAAD,CACE,KAAM,EACN,UAAU,wFACX,eAEG,CAAA,CACA,GAEI,CAAA,CACT,CAAA,CAAA"}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@murumets-ee/queue-ui",
|
|
3
|
+
"version": "0.12.0",
|
|
4
|
+
"license": "Elastic-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.mts",
|
|
9
|
+
"import": "./dist/index.mjs"
|
|
10
|
+
},
|
|
11
|
+
"./plugin": {
|
|
12
|
+
"types": "./dist/plugin.d.mts",
|
|
13
|
+
"import": "./dist/plugin.mjs"
|
|
14
|
+
},
|
|
15
|
+
"./pages": {
|
|
16
|
+
"types": "./dist/pages.d.mts",
|
|
17
|
+
"import": "./dist/pages.mjs"
|
|
18
|
+
},
|
|
19
|
+
"./widgets": {
|
|
20
|
+
"types": "./dist/widgets.d.mts",
|
|
21
|
+
"import": "./dist/widgets.mjs"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"clsx": "^2.1.0",
|
|
29
|
+
"tailwind-merge": "^2.6.0",
|
|
30
|
+
"@murumets-ee/admin-ui": "0.12.0",
|
|
31
|
+
"@murumets-ee/core": "0.12.0",
|
|
32
|
+
"@murumets-ee/queue": "0.12.0",
|
|
33
|
+
"@murumets-ee/ui": "0.12.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"lucide-react": ">=0.400.0",
|
|
37
|
+
"next": ">=15.0.0",
|
|
38
|
+
"next-intl": ">=4.0.0",
|
|
39
|
+
"react": ">=19.0.0",
|
|
40
|
+
"react-dom": ">=19.0.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependenciesMeta": {
|
|
43
|
+
"next": {
|
|
44
|
+
"optional": true
|
|
45
|
+
},
|
|
46
|
+
"next-intl": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/react": "^19",
|
|
52
|
+
"@types/react-dom": "^19",
|
|
53
|
+
"lucide-react": "^0.577.0",
|
|
54
|
+
"next": "16.2.4",
|
|
55
|
+
"next-intl": "^4.11.0",
|
|
56
|
+
"react": "19.2.5",
|
|
57
|
+
"react-dom": "19.2.5",
|
|
58
|
+
"tsdown": "^0.21.10",
|
|
59
|
+
"typescript": "^5.7.3",
|
|
60
|
+
"vitest": "^2.1.8",
|
|
61
|
+
"@murumets-ee/admin-ui": "0.12.0",
|
|
62
|
+
"@murumets-ee/queue": "0.12.0",
|
|
63
|
+
"@murumets-ee/ui": "0.12.0"
|
|
64
|
+
},
|
|
65
|
+
"typeCoverage": {
|
|
66
|
+
"atLeast": 99
|
|
67
|
+
},
|
|
68
|
+
"scripts": {
|
|
69
|
+
"build": "tsdown",
|
|
70
|
+
"dev": "tsdown --watch",
|
|
71
|
+
"test": "vitest"
|
|
72
|
+
}
|
|
73
|
+
}
|