@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/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 &bull; bpmnkit.com</footer>
105
+ </body>
106
+ </html>`;
107
+ }
108
+ function esc(s) {
109
+ return s
110
+ .replace(/&/g, "&amp;")
111
+ .replace(/</g, "&lt;")
112
+ .replace(/>/g, "&gt;")
113
+ .replace(/"/g, "&quot;");
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 &bull; bpmnkit.com</footer>
123
+ </body>
124
+ </html>`
125
+ }
126
+
127
+ function esc(s: string): string {
128
+ return s
129
+ .replace(/&/g, "&amp;")
130
+ .replace(/</g, "&lt;")
131
+ .replace(/>/g, "&gt;")
132
+ .replace(/"/g, "&quot;")
133
+ }
134
+
135
+ export function badge(text: string, cls: string): string {
136
+ return `<span class="badge badge-${cls}">${text}</span>`
137
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "outDir": "dist",
6
+ "rootDir": "src",
7
+ "types": ["node"]
8
+ },
9
+ "include": ["src"]
10
+ }