@bpmnkit/casen-report 0.1.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/.turbo/turbo-build.log +5 -0
- package/.turbo/turbo-check.log +6 -0
- package/.turbo/turbo-test.log +6 -0
- package/.turbo/turbo-typecheck.log +5 -0
- package/README.md +111 -0
- package/dist/commands/incidents.d.ts +3 -0
- package/dist/commands/incidents.d.ts.map +1 -0
- package/dist/commands/incidents.js +120 -0
- package/dist/commands/incidents.js.map +1 -0
- package/dist/commands/sla.d.ts +3 -0
- package/dist/commands/sla.d.ts.map +1 -0
- package/dist/commands/sla.js +188 -0
- package/dist/commands/sla.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/report.d.ts +19 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +118 -0
- package/dist/report.js.map +1 -0
- package/package.json +50 -0
- package/src/commands/incidents.ts +138 -0
- package/src/commands/sla.ts +210 -0
- package/src/index.ts +18 -0
- package/src/report.ts +137 -0
- package/tsconfig.json +10 -0
- package/tsconfig.tsbuildinfo +1 -0
package/dist/report.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** Generates a self-contained dark-theme HTML report. */
|
|
2
|
+
const CSS = `
|
|
3
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
4
|
+
body {
|
|
5
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
6
|
+
background: #0d0d16;
|
|
7
|
+
color: #cdd6f4;
|
|
8
|
+
padding: 2rem;
|
|
9
|
+
line-height: 1.5;
|
|
10
|
+
}
|
|
11
|
+
header { margin-bottom: 2rem; border-bottom: 1px solid #2a2a42; padding-bottom: 1rem; }
|
|
12
|
+
h1 { font-size: 1.5rem; font-weight: 600; color: #6b9df7; }
|
|
13
|
+
.meta { font-size: 0.85rem; color: #8888a8; margin-top: 0.25rem; }
|
|
14
|
+
.summary {
|
|
15
|
+
display: flex; gap: 1rem; flex-wrap: wrap;
|
|
16
|
+
margin-bottom: 2rem;
|
|
17
|
+
}
|
|
18
|
+
.stat {
|
|
19
|
+
background: #161626;
|
|
20
|
+
border: 1px solid #2a2a42;
|
|
21
|
+
border-radius: 6px;
|
|
22
|
+
padding: 0.75rem 1.25rem;
|
|
23
|
+
min-width: 140px;
|
|
24
|
+
}
|
|
25
|
+
.stat-label { font-size: 0.75rem; color: #8888a8; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
26
|
+
.stat-value { font-size: 1.75rem; font-weight: 700; margin-top: 0.2rem; }
|
|
27
|
+
table {
|
|
28
|
+
width: 100%;
|
|
29
|
+
border-collapse: collapse;
|
|
30
|
+
font-size: 0.875rem;
|
|
31
|
+
}
|
|
32
|
+
thead th {
|
|
33
|
+
background: #1e1e2e;
|
|
34
|
+
color: #8888a8;
|
|
35
|
+
font-weight: 600;
|
|
36
|
+
font-size: 0.75rem;
|
|
37
|
+
text-transform: uppercase;
|
|
38
|
+
letter-spacing: 0.05em;
|
|
39
|
+
padding: 0.6rem 0.75rem;
|
|
40
|
+
text-align: left;
|
|
41
|
+
border-bottom: 1px solid #2a2a42;
|
|
42
|
+
}
|
|
43
|
+
tbody tr { border-bottom: 1px solid #2a2a42; }
|
|
44
|
+
tbody tr:last-child { border-bottom: none; }
|
|
45
|
+
tbody tr:hover { background: #161626; }
|
|
46
|
+
tbody td { padding: 0.6rem 0.75rem; vertical-align: top; }
|
|
47
|
+
.badge {
|
|
48
|
+
display: inline-block;
|
|
49
|
+
padding: 0.15rem 0.5rem;
|
|
50
|
+
border-radius: 4px;
|
|
51
|
+
font-size: 0.75rem;
|
|
52
|
+
font-weight: 600;
|
|
53
|
+
}
|
|
54
|
+
.badge-active { background: rgba(248,113,113,0.15); color: #f87171; }
|
|
55
|
+
.badge-resolved { background: rgba(34,197,94,0.15); color: #22c55e; }
|
|
56
|
+
.badge-running { background: rgba(107,157,247,0.15); color: #6b9df7; }
|
|
57
|
+
.badge-completed { background: rgba(34,197,94,0.15); color: #22c55e; }
|
|
58
|
+
.badge-canceled { background: rgba(136,136,168,0.15); color: #8888a8; }
|
|
59
|
+
.badge-breached { background: rgba(245,158,11,0.15); color: #f59e0b; }
|
|
60
|
+
.badge-ok { background: rgba(34,197,94,0.15); color: #22c55e; }
|
|
61
|
+
.key { font-family: ui-monospace, "Cascadia Code", monospace; font-size: 0.8rem; color: #8888a8; }
|
|
62
|
+
footer { margin-top: 2rem; font-size: 0.8rem; color: #8888a8; border-top: 1px solid #2a2a42; padding-top: 1rem; }
|
|
63
|
+
`;
|
|
64
|
+
export function buildReport(opts) {
|
|
65
|
+
const now = new Date().toLocaleString();
|
|
66
|
+
const statCards = opts.stats
|
|
67
|
+
.map((s) => {
|
|
68
|
+
const color = s.color ? `color: ${s.color};` : "";
|
|
69
|
+
return `<div class="stat"><div class="stat-label">${s.label}</div><div class="stat-value" style="${color}">${s.value}</div></div>`;
|
|
70
|
+
})
|
|
71
|
+
.join("\n");
|
|
72
|
+
const thead = opts.columns.map((c) => `<th>${c.header}</th>`).join("");
|
|
73
|
+
const tbody = opts.rows
|
|
74
|
+
.map((row) => {
|
|
75
|
+
const cells = opts.columns
|
|
76
|
+
.map((c) => `<td>${c.isHtml ? c.render(row) : esc(c.render(row))}</td>`)
|
|
77
|
+
.join("");
|
|
78
|
+
return `<tr>${cells}</tr>`;
|
|
79
|
+
})
|
|
80
|
+
.join("\n");
|
|
81
|
+
return `<!DOCTYPE html>
|
|
82
|
+
<html lang="en">
|
|
83
|
+
<head>
|
|
84
|
+
<meta charset="UTF-8">
|
|
85
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
86
|
+
<title>${esc(opts.title)}</title>
|
|
87
|
+
<style>${CSS}</style>
|
|
88
|
+
</head>
|
|
89
|
+
<body>
|
|
90
|
+
<header>
|
|
91
|
+
<h1>${esc(opts.title)}</h1>
|
|
92
|
+
${opts.subtitle ? `<div class="meta">${esc(opts.subtitle)}</div>` : ""}
|
|
93
|
+
<div class="meta">Generated ${now}</div>
|
|
94
|
+
</header>
|
|
95
|
+
<div class="summary">
|
|
96
|
+
${statCards}
|
|
97
|
+
</div>
|
|
98
|
+
<table>
|
|
99
|
+
<thead><tr>${thead}</tr></thead>
|
|
100
|
+
<tbody>
|
|
101
|
+
${tbody}
|
|
102
|
+
</tbody>
|
|
103
|
+
</table>
|
|
104
|
+
<footer>Generated by casen-report • bpmnkit.com</footer>
|
|
105
|
+
</body>
|
|
106
|
+
</html>`;
|
|
107
|
+
}
|
|
108
|
+
function esc(s) {
|
|
109
|
+
return s
|
|
110
|
+
.replace(/&/g, "&")
|
|
111
|
+
.replace(/</g, "<")
|
|
112
|
+
.replace(/>/g, ">")
|
|
113
|
+
.replace(/"/g, """);
|
|
114
|
+
}
|
|
115
|
+
export function badge(text, cls) {
|
|
116
|
+
return `<span class="badge badge-${cls}">${text}</span>`;
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=report.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"report.js","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AAAA,yDAAyD;AAEzD,MAAM,GAAG,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6DX,CAAA;AAQD,MAAM,UAAU,WAAW,CAAC,IAM3B;IACA,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC,cAAc,EAAE,CAAA;IAEvC,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK;SAC1B,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;QACV,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,EAAE,CAAA;QACjD,OAAO,6CAA6C,CAAC,CAAC,KAAK,wCAAwC,KAAK,KAAK,CAAC,CAAC,KAAK,cAAc,CAAA;IACnI,CAAC,CAAC;SACD,IAAI,CAAC,IAAI,CAAC,CAAA;IAEZ,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,MAAM,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IAEtE,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI;SACrB,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE;QACZ,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO;aACxB,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC;aACvE,IAAI,CAAC,EAAE,CAAC,CAAA;QACV,OAAO,OAAO,KAAK,OAAO,CAAA;IAC3B,CAAC,CAAC;SACD,IAAI,CAAC,IAAI,CAAC,CAAA;IAEZ,OAAO;;;;;SAKC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC;SACf,GAAG;;;;QAIJ,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC;IACnB,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,qBAAqB,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE;gCACxC,GAAG;;;EAGjC,SAAS;;;eAGI,KAAK;;EAElB,KAAK;;;;;QAKC,CAAA;AACR,CAAC;AAED,SAAS,GAAG,CAAC,CAAS;IACrB,OAAO,CAAC;SACN,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAA;AAC1B,CAAC;AAED,MAAM,UAAU,KAAK,CAAC,IAAY,EAAE,GAAW;IAC9C,OAAO,4BAA4B,GAAG,KAAK,IAAI,SAAS,CAAA;AACzD,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bpmnkit/casen-report",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Render HTML reports from Camunda 8 incident and SLA data",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"keywords": ["casen-plugin", "camunda", "report", "incidents", "sla", "bpmnkit"],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/bpmnkit/monorepo"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://bpmnkit.com",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/bpmnkit/monorepo/issues"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"casen": {
|
|
28
|
+
"group": "report",
|
|
29
|
+
"commands": [
|
|
30
|
+
{
|
|
31
|
+
"name": "incidents",
|
|
32
|
+
"description": "Generate an HTML report of current incidents grouped by process"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "sla",
|
|
36
|
+
"description": "Generate an SLA compliance report for process instances"
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"check": "biome check .",
|
|
44
|
+
"test": "echo \"no tests\""
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@bpmnkit/api": "workspace:*",
|
|
48
|
+
"@bpmnkit/cli-sdk": "workspace:*"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs"
|
|
2
|
+
import type { CamundaClient } from "@bpmnkit/api"
|
|
3
|
+
import type { Command, RunContext } from "@bpmnkit/cli-sdk"
|
|
4
|
+
import { badge, buildReport } from "../report.js"
|
|
5
|
+
|
|
6
|
+
interface IncidentItem {
|
|
7
|
+
incidentKey: string
|
|
8
|
+
processInstanceKey: string
|
|
9
|
+
processDefinitionId?: string
|
|
10
|
+
processDefinitionKey?: string
|
|
11
|
+
type?: string
|
|
12
|
+
state?: string
|
|
13
|
+
message?: string
|
|
14
|
+
creationTime?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function asIncident(raw: unknown): IncidentItem {
|
|
18
|
+
return raw as IncidentItem
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const incidentsCommand: Command = {
|
|
22
|
+
name: "incidents",
|
|
23
|
+
description: "Generate an HTML report of current incidents",
|
|
24
|
+
flags: [
|
|
25
|
+
{
|
|
26
|
+
name: "process-id",
|
|
27
|
+
short: "p",
|
|
28
|
+
description: "Filter by process definition ID",
|
|
29
|
+
type: "string",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "limit",
|
|
33
|
+
short: "l",
|
|
34
|
+
description: "Maximum number of incidents to fetch",
|
|
35
|
+
type: "number",
|
|
36
|
+
default: 200,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "out",
|
|
40
|
+
short: "o",
|
|
41
|
+
description: "Write HTML report to this file path",
|
|
42
|
+
type: "string",
|
|
43
|
+
placeholder: "FILE",
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
examples: [
|
|
47
|
+
{ description: "Report all active incidents", command: "casen report incidents" },
|
|
48
|
+
{
|
|
49
|
+
description: "Report incidents for a specific process",
|
|
50
|
+
command: "casen report incidents --process-id order-process",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
description: "Save HTML report to file",
|
|
54
|
+
command: "casen report incidents --out incidents.html",
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
async run(ctx: RunContext): Promise<void> {
|
|
58
|
+
const client = (await ctx.getClient()) as CamundaClient
|
|
59
|
+
const limit = Number(ctx.flags.limit ?? 200)
|
|
60
|
+
const processId = ctx.flags["process-id"] as string | undefined
|
|
61
|
+
const outFile = ctx.flags.out as string | undefined
|
|
62
|
+
|
|
63
|
+
const filter: Record<string, unknown> = {}
|
|
64
|
+
if (processId) filter.processDefinitionId = processId
|
|
65
|
+
|
|
66
|
+
const result = await client.incident.searchIncidents({
|
|
67
|
+
filter,
|
|
68
|
+
page: { from: 0, limit },
|
|
69
|
+
} as never)
|
|
70
|
+
const raw = result as { items?: unknown[]; page?: { totalItems?: number } }
|
|
71
|
+
const items = (raw.items ?? []).map(asIncident)
|
|
72
|
+
const total = raw.page?.totalItems ?? items.length
|
|
73
|
+
|
|
74
|
+
if (outFile) {
|
|
75
|
+
const activeCount = items.filter((i) => i.state?.toUpperCase() !== "RESOLVED").length
|
|
76
|
+
const resolvedCount = items.length - activeCount
|
|
77
|
+
|
|
78
|
+
// Group by process definition
|
|
79
|
+
const byProcess = new Map<string, number>()
|
|
80
|
+
for (const item of items) {
|
|
81
|
+
const key = item.processDefinitionId ?? "unknown"
|
|
82
|
+
byProcess.set(key, (byProcess.get(key) ?? 0) + 1)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const html = buildReport({
|
|
86
|
+
title: "Incident Report",
|
|
87
|
+
subtitle: processId ? `Process: ${processId}` : "All processes",
|
|
88
|
+
stats: [
|
|
89
|
+
{ label: "Total incidents", value: total },
|
|
90
|
+
{ label: "Active", value: activeCount, color: "#f87171" },
|
|
91
|
+
{ label: "Resolved", value: resolvedCount, color: "#22c55e" },
|
|
92
|
+
{ label: "Processes affected", value: byProcess.size },
|
|
93
|
+
],
|
|
94
|
+
columns: [
|
|
95
|
+
{ header: "Incident Key", render: (r) => asIncident(r).incidentKey ?? "", isHtml: false },
|
|
96
|
+
{
|
|
97
|
+
header: "Process Instance",
|
|
98
|
+
render: (r) => `<span class="key">${asIncident(r).processInstanceKey ?? ""}</span>`,
|
|
99
|
+
isHtml: true,
|
|
100
|
+
},
|
|
101
|
+
{ header: "Process", render: (r) => asIncident(r).processDefinitionId ?? "" },
|
|
102
|
+
{
|
|
103
|
+
header: "State",
|
|
104
|
+
render: (r) => {
|
|
105
|
+
const state = asIncident(r).state ?? ""
|
|
106
|
+
const cls = state.toUpperCase() === "RESOLVED" ? "resolved" : "active"
|
|
107
|
+
return badge(state || "ACTIVE", cls)
|
|
108
|
+
},
|
|
109
|
+
isHtml: true,
|
|
110
|
+
},
|
|
111
|
+
{ header: "Type", render: (r) => asIncident(r).type ?? "" },
|
|
112
|
+
{ header: "Message", render: (r) => (asIncident(r).message ?? "").slice(0, 120) },
|
|
113
|
+
{
|
|
114
|
+
header: "Created",
|
|
115
|
+
render: (r) => {
|
|
116
|
+
const ts = asIncident(r).creationTime
|
|
117
|
+
return ts ? new Date(ts).toLocaleString() : ""
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
rows: items,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
writeFileSync(outFile, html, "utf8")
|
|
125
|
+
ctx.output.ok(`Report written to ${outFile} (${items.length} incidents)`)
|
|
126
|
+
} else {
|
|
127
|
+
ctx.output.printList({ items }, [
|
|
128
|
+
{ key: "incidentKey", header: "INCIDENT KEY", maxWidth: 22 },
|
|
129
|
+
{ key: "processInstanceKey", header: "PROCESS INSTANCE", maxWidth: 22 },
|
|
130
|
+
{ key: "processDefinitionId", header: "PROCESS", maxWidth: 30 },
|
|
131
|
+
{ key: "state", header: "STATE", maxWidth: 10 },
|
|
132
|
+
{ key: "type", header: "TYPE", maxWidth: 28 },
|
|
133
|
+
{ key: "message", header: "MESSAGE", maxWidth: 60 },
|
|
134
|
+
])
|
|
135
|
+
ctx.output.info(`Showing ${items.length} of ${total} incidents`)
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs"
|
|
2
|
+
import type { CamundaClient } from "@bpmnkit/api"
|
|
3
|
+
import type { Command, RunContext } from "@bpmnkit/cli-sdk"
|
|
4
|
+
import { badge, buildReport } from "../report.js"
|
|
5
|
+
|
|
6
|
+
interface ProcessInstanceItem {
|
|
7
|
+
processInstanceKey: string
|
|
8
|
+
processDefinitionId?: string
|
|
9
|
+
processDefinitionName?: string
|
|
10
|
+
state?: string
|
|
11
|
+
startDate?: string
|
|
12
|
+
endDate?: string
|
|
13
|
+
hasIncident?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function asInstance(raw: unknown): ProcessInstanceItem {
|
|
17
|
+
return raw as ProcessInstanceItem
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function durationMs(item: ProcessInstanceItem): number {
|
|
21
|
+
if (!item.startDate) return 0
|
|
22
|
+
const start = new Date(item.startDate).getTime()
|
|
23
|
+
const end = item.endDate ? new Date(item.endDate).getTime() : Date.now()
|
|
24
|
+
return end - start
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fmtDuration(ms: number): string {
|
|
28
|
+
const totalSeconds = Math.floor(ms / 1000)
|
|
29
|
+
const hours = Math.floor(totalSeconds / 3600)
|
|
30
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
|
31
|
+
const seconds = totalSeconds % 60
|
|
32
|
+
if (hours > 0) return `${hours}h ${minutes}m`
|
|
33
|
+
if (minutes > 0) return `${minutes}m ${seconds}s`
|
|
34
|
+
return `${seconds}s`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const slaCommand: Command = {
|
|
38
|
+
name: "sla",
|
|
39
|
+
description: "Generate an SLA compliance report for process instances",
|
|
40
|
+
flags: [
|
|
41
|
+
{
|
|
42
|
+
name: "threshold",
|
|
43
|
+
short: "t",
|
|
44
|
+
description: "SLA threshold in minutes",
|
|
45
|
+
type: "number",
|
|
46
|
+
required: true,
|
|
47
|
+
placeholder: "MINUTES",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "process-id",
|
|
51
|
+
short: "p",
|
|
52
|
+
description: "Filter by process definition ID",
|
|
53
|
+
type: "string",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "limit",
|
|
57
|
+
short: "l",
|
|
58
|
+
description: "Maximum number of instances to fetch",
|
|
59
|
+
type: "number",
|
|
60
|
+
default: 200,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "out",
|
|
64
|
+
short: "o",
|
|
65
|
+
description: "Write HTML report to this file path",
|
|
66
|
+
type: "string",
|
|
67
|
+
placeholder: "FILE",
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
examples: [
|
|
71
|
+
{
|
|
72
|
+
description: "Check SLA with 30-minute threshold",
|
|
73
|
+
command: "casen report sla --threshold 30",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
description: "SLA report for a specific process",
|
|
77
|
+
command: "casen report sla --threshold 60 --process-id order-process",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
description: "Save SLA report to file",
|
|
81
|
+
command: "casen report sla --threshold 30 --out sla.html",
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
async run(ctx: RunContext): Promise<void> {
|
|
85
|
+
const client = (await ctx.getClient()) as CamundaClient
|
|
86
|
+
const thresholdMinutes = Number(ctx.flags.threshold)
|
|
87
|
+
const limit = Number(ctx.flags.limit ?? 200)
|
|
88
|
+
const processId = ctx.flags["process-id"] as string | undefined
|
|
89
|
+
const outFile = ctx.flags.out as string | undefined
|
|
90
|
+
|
|
91
|
+
if (!thresholdMinutes || thresholdMinutes <= 0) {
|
|
92
|
+
throw new Error("--threshold must be a positive number of minutes")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const thresholdMs = thresholdMinutes * 60 * 1000
|
|
96
|
+
|
|
97
|
+
const filter: Record<string, unknown> = {}
|
|
98
|
+
if (processId) filter.processDefinitionId = processId
|
|
99
|
+
|
|
100
|
+
const result = await client.processInstance.searchProcessInstances({
|
|
101
|
+
filter,
|
|
102
|
+
page: { from: 0, limit },
|
|
103
|
+
} as never)
|
|
104
|
+
const raw = result as { items?: unknown[]; page?: { totalItems?: number } }
|
|
105
|
+
const items = (raw.items ?? []).map(asInstance)
|
|
106
|
+
const total = raw.page?.totalItems ?? items.length
|
|
107
|
+
|
|
108
|
+
type Row = ProcessInstanceItem & { durationMs: number; breached: boolean }
|
|
109
|
+
|
|
110
|
+
const rows: Row[] = items.map((item) => {
|
|
111
|
+
const ms = durationMs(item)
|
|
112
|
+
return { ...item, durationMs: ms, breached: ms > thresholdMs }
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const breachedCount = rows.filter((r) => r.breached).length
|
|
116
|
+
const compliantCount = rows.length - breachedCount
|
|
117
|
+
const complianceRate = rows.length > 0 ? Math.round((compliantCount / rows.length) * 100) : 100
|
|
118
|
+
|
|
119
|
+
if (outFile) {
|
|
120
|
+
const html = buildReport({
|
|
121
|
+
title: "SLA Compliance Report",
|
|
122
|
+
subtitle: processId
|
|
123
|
+
? `Process: ${processId} — SLA: ${thresholdMinutes}m`
|
|
124
|
+
: `All processes — SLA: ${thresholdMinutes}m`,
|
|
125
|
+
stats: [
|
|
126
|
+
{ label: "Total instances", value: total },
|
|
127
|
+
{ label: "Compliant", value: compliantCount, color: "#22c55e" },
|
|
128
|
+
{
|
|
129
|
+
label: "Breached",
|
|
130
|
+
value: breachedCount,
|
|
131
|
+
color: breachedCount > 0 ? "#f59e0b" : "#22c55e",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
label: "Compliance rate",
|
|
135
|
+
value: `${complianceRate}%`,
|
|
136
|
+
color: complianceRate >= 90 ? "#22c55e" : complianceRate >= 70 ? "#f59e0b" : "#f87171",
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
columns: [
|
|
140
|
+
{
|
|
141
|
+
header: "Process Instance",
|
|
142
|
+
render: (r) => `<span class="key">${(r as Row).processInstanceKey ?? ""}</span>`,
|
|
143
|
+
isHtml: true,
|
|
144
|
+
},
|
|
145
|
+
{ header: "Process", render: (r) => (r as Row).processDefinitionId ?? "" },
|
|
146
|
+
{
|
|
147
|
+
header: "State",
|
|
148
|
+
render: (r) => {
|
|
149
|
+
const state = (r as Row).state ?? ""
|
|
150
|
+
const cls = state.toLowerCase()
|
|
151
|
+
return badge(state || "UNKNOWN", cls)
|
|
152
|
+
},
|
|
153
|
+
isHtml: true,
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
header: "Duration",
|
|
157
|
+
render: (r) => fmtDuration((r as Row).durationMs),
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
header: `SLA (${thresholdMinutes}m)`,
|
|
161
|
+
render: (r) => {
|
|
162
|
+
const row = r as Row
|
|
163
|
+
return badge(row.breached ? "BREACHED" : "OK", row.breached ? "breached" : "ok")
|
|
164
|
+
},
|
|
165
|
+
isHtml: true,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
header: "Started",
|
|
169
|
+
render: (r) => {
|
|
170
|
+
const ts = (r as Row).startDate
|
|
171
|
+
return ts ? new Date(ts).toLocaleString() : ""
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
header: "Ended",
|
|
176
|
+
render: (r) => {
|
|
177
|
+
const ts = (r as Row).endDate
|
|
178
|
+
return ts ? new Date(ts).toLocaleString() : "—"
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
rows,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
writeFileSync(outFile, html, "utf8")
|
|
186
|
+
ctx.output.ok(
|
|
187
|
+
`Report written to ${outFile} (${rows.length} instances, ${complianceRate}% compliant)`,
|
|
188
|
+
)
|
|
189
|
+
} else {
|
|
190
|
+
ctx.output.printList({ items: rows }, [
|
|
191
|
+
{ key: "processInstanceKey", header: "PROCESS INSTANCE", maxWidth: 22 },
|
|
192
|
+
{ key: "processDefinitionId", header: "PROCESS", maxWidth: 30 },
|
|
193
|
+
{ key: "state", header: "STATE", maxWidth: 12 },
|
|
194
|
+
{
|
|
195
|
+
key: "durationMs",
|
|
196
|
+
header: "DURATION",
|
|
197
|
+
maxWidth: 12,
|
|
198
|
+
transform: (v) => fmtDuration(Number(v)),
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
key: "breached",
|
|
202
|
+
header: `SLA (${thresholdMinutes}m)`,
|
|
203
|
+
maxWidth: 10,
|
|
204
|
+
transform: (v) => (v ? "BREACHED" : "OK"),
|
|
205
|
+
},
|
|
206
|
+
])
|
|
207
|
+
ctx.output.info(`Showing ${rows.length} of ${total} instances — ${complianceRate}% compliant`)
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CasenPlugin } from "@bpmnkit/cli-sdk"
|
|
2
|
+
import { incidentsCommand } from "./commands/incidents.js"
|
|
3
|
+
import { slaCommand } from "./commands/sla.js"
|
|
4
|
+
|
|
5
|
+
const plugin: CasenPlugin = {
|
|
6
|
+
id: "com.bpmnkit.casen-report",
|
|
7
|
+
name: "Report",
|
|
8
|
+
version: "0.1.0",
|
|
9
|
+
groups: [
|
|
10
|
+
{
|
|
11
|
+
name: "report",
|
|
12
|
+
description: "Render HTML reports from incident and SLA data",
|
|
13
|
+
commands: [incidentsCommand, slaCommand],
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default plugin
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/** Generates a self-contained dark-theme HTML report. */
|
|
2
|
+
|
|
3
|
+
const CSS = `
|
|
4
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
5
|
+
body {
|
|
6
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
7
|
+
background: #0d0d16;
|
|
8
|
+
color: #cdd6f4;
|
|
9
|
+
padding: 2rem;
|
|
10
|
+
line-height: 1.5;
|
|
11
|
+
}
|
|
12
|
+
header { margin-bottom: 2rem; border-bottom: 1px solid #2a2a42; padding-bottom: 1rem; }
|
|
13
|
+
h1 { font-size: 1.5rem; font-weight: 600; color: #6b9df7; }
|
|
14
|
+
.meta { font-size: 0.85rem; color: #8888a8; margin-top: 0.25rem; }
|
|
15
|
+
.summary {
|
|
16
|
+
display: flex; gap: 1rem; flex-wrap: wrap;
|
|
17
|
+
margin-bottom: 2rem;
|
|
18
|
+
}
|
|
19
|
+
.stat {
|
|
20
|
+
background: #161626;
|
|
21
|
+
border: 1px solid #2a2a42;
|
|
22
|
+
border-radius: 6px;
|
|
23
|
+
padding: 0.75rem 1.25rem;
|
|
24
|
+
min-width: 140px;
|
|
25
|
+
}
|
|
26
|
+
.stat-label { font-size: 0.75rem; color: #8888a8; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
27
|
+
.stat-value { font-size: 1.75rem; font-weight: 700; margin-top: 0.2rem; }
|
|
28
|
+
table {
|
|
29
|
+
width: 100%;
|
|
30
|
+
border-collapse: collapse;
|
|
31
|
+
font-size: 0.875rem;
|
|
32
|
+
}
|
|
33
|
+
thead th {
|
|
34
|
+
background: #1e1e2e;
|
|
35
|
+
color: #8888a8;
|
|
36
|
+
font-weight: 600;
|
|
37
|
+
font-size: 0.75rem;
|
|
38
|
+
text-transform: uppercase;
|
|
39
|
+
letter-spacing: 0.05em;
|
|
40
|
+
padding: 0.6rem 0.75rem;
|
|
41
|
+
text-align: left;
|
|
42
|
+
border-bottom: 1px solid #2a2a42;
|
|
43
|
+
}
|
|
44
|
+
tbody tr { border-bottom: 1px solid #2a2a42; }
|
|
45
|
+
tbody tr:last-child { border-bottom: none; }
|
|
46
|
+
tbody tr:hover { background: #161626; }
|
|
47
|
+
tbody td { padding: 0.6rem 0.75rem; vertical-align: top; }
|
|
48
|
+
.badge {
|
|
49
|
+
display: inline-block;
|
|
50
|
+
padding: 0.15rem 0.5rem;
|
|
51
|
+
border-radius: 4px;
|
|
52
|
+
font-size: 0.75rem;
|
|
53
|
+
font-weight: 600;
|
|
54
|
+
}
|
|
55
|
+
.badge-active { background: rgba(248,113,113,0.15); color: #f87171; }
|
|
56
|
+
.badge-resolved { background: rgba(34,197,94,0.15); color: #22c55e; }
|
|
57
|
+
.badge-running { background: rgba(107,157,247,0.15); color: #6b9df7; }
|
|
58
|
+
.badge-completed { background: rgba(34,197,94,0.15); color: #22c55e; }
|
|
59
|
+
.badge-canceled { background: rgba(136,136,168,0.15); color: #8888a8; }
|
|
60
|
+
.badge-breached { background: rgba(245,158,11,0.15); color: #f59e0b; }
|
|
61
|
+
.badge-ok { background: rgba(34,197,94,0.15); color: #22c55e; }
|
|
62
|
+
.key { font-family: ui-monospace, "Cascadia Code", monospace; font-size: 0.8rem; color: #8888a8; }
|
|
63
|
+
footer { margin-top: 2rem; font-size: 0.8rem; color: #8888a8; border-top: 1px solid #2a2a42; padding-top: 1rem; }
|
|
64
|
+
`
|
|
65
|
+
|
|
66
|
+
export interface ReportColumn {
|
|
67
|
+
header: string
|
|
68
|
+
render: (row: unknown) => string
|
|
69
|
+
isHtml?: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildReport(opts: {
|
|
73
|
+
title: string
|
|
74
|
+
subtitle?: string
|
|
75
|
+
stats: { label: string; value: string | number; color?: string }[]
|
|
76
|
+
columns: ReportColumn[]
|
|
77
|
+
rows: unknown[]
|
|
78
|
+
}): string {
|
|
79
|
+
const now = new Date().toLocaleString()
|
|
80
|
+
|
|
81
|
+
const statCards = opts.stats
|
|
82
|
+
.map((s) => {
|
|
83
|
+
const color = s.color ? `color: ${s.color};` : ""
|
|
84
|
+
return `<div class="stat"><div class="stat-label">${s.label}</div><div class="stat-value" style="${color}">${s.value}</div></div>`
|
|
85
|
+
})
|
|
86
|
+
.join("\n")
|
|
87
|
+
|
|
88
|
+
const thead = opts.columns.map((c) => `<th>${c.header}</th>`).join("")
|
|
89
|
+
|
|
90
|
+
const tbody = opts.rows
|
|
91
|
+
.map((row) => {
|
|
92
|
+
const cells = opts.columns
|
|
93
|
+
.map((c) => `<td>${c.isHtml ? c.render(row) : esc(c.render(row))}</td>`)
|
|
94
|
+
.join("")
|
|
95
|
+
return `<tr>${cells}</tr>`
|
|
96
|
+
})
|
|
97
|
+
.join("\n")
|
|
98
|
+
|
|
99
|
+
return `<!DOCTYPE html>
|
|
100
|
+
<html lang="en">
|
|
101
|
+
<head>
|
|
102
|
+
<meta charset="UTF-8">
|
|
103
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
104
|
+
<title>${esc(opts.title)}</title>
|
|
105
|
+
<style>${CSS}</style>
|
|
106
|
+
</head>
|
|
107
|
+
<body>
|
|
108
|
+
<header>
|
|
109
|
+
<h1>${esc(opts.title)}</h1>
|
|
110
|
+
${opts.subtitle ? `<div class="meta">${esc(opts.subtitle)}</div>` : ""}
|
|
111
|
+
<div class="meta">Generated ${now}</div>
|
|
112
|
+
</header>
|
|
113
|
+
<div class="summary">
|
|
114
|
+
${statCards}
|
|
115
|
+
</div>
|
|
116
|
+
<table>
|
|
117
|
+
<thead><tr>${thead}</tr></thead>
|
|
118
|
+
<tbody>
|
|
119
|
+
${tbody}
|
|
120
|
+
</tbody>
|
|
121
|
+
</table>
|
|
122
|
+
<footer>Generated by casen-report • bpmnkit.com</footer>
|
|
123
|
+
</body>
|
|
124
|
+
</html>`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function esc(s: string): string {
|
|
128
|
+
return s
|
|
129
|
+
.replace(/&/g, "&")
|
|
130
|
+
.replace(/</g, "<")
|
|
131
|
+
.replace(/>/g, ">")
|
|
132
|
+
.replace(/"/g, """)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function badge(text: string, cls: string): string {
|
|
136
|
+
return `<span class="badge badge-${cls}">${text}</span>`
|
|
137
|
+
}
|