@echothink-ui/admin 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/README.md +5 -0
- package/dist/components/AdminHealthTemplate.d.ts +5 -0
- package/dist/components/AdminShell.d.ts +8 -0
- package/dist/components/AuditLogViewer.d.ts +9 -0
- package/dist/components/BillingUsagePanel.d.ts +7 -0
- package/dist/components/FeatureFlagPanel.d.ts +8 -0
- package/dist/components/IntegrationHealthTable.d.ts +6 -0
- package/dist/components/JobQueuePanel.d.ts +7 -0
- package/dist/components/PolicyConfigPanel.d.ts +9 -0
- package/dist/components/QueueDepthChart.d.ts +6 -0
- package/dist/components/RateLimitPanel.d.ts +7 -0
- package/dist/components/RuntimeLogViewer.d.ts +10 -0
- package/dist/components/ServiceStatusCard.d.ts +7 -0
- package/dist/components/SystemHealthDashboard.d.ts +8 -0
- package/dist/components/TenantSettingsPanel.d.ts +11 -0
- package/dist/components/WorkerPoolPanel.d.ts +7 -0
- package/dist/components/types.d.ts +114 -0
- package/dist/index.cjs +1835 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +1711 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +1812 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
- package/src/components/AdminHealthTemplate.tsx +25 -0
- package/src/components/AdminShell.tsx +48 -0
- package/src/components/AuditLogViewer.tsx +96 -0
- package/src/components/BillingUsagePanel.tsx +171 -0
- package/src/components/FeatureFlagPanel.tsx +178 -0
- package/src/components/IntegrationHealthTable.tsx +196 -0
- package/src/components/JobQueuePanel.tsx +171 -0
- package/src/components/PolicyConfigPanel.test.tsx +45 -0
- package/src/components/PolicyConfigPanel.tsx +131 -0
- package/src/components/QueueDepthChart.tsx +70 -0
- package/src/components/RateLimitPanel.test.tsx +29 -0
- package/src/components/RateLimitPanel.tsx +249 -0
- package/src/components/RuntimeLogViewer.test.tsx +60 -0
- package/src/components/RuntimeLogViewer.tsx +185 -0
- package/src/components/ServiceStatusCard.tsx +91 -0
- package/src/components/SystemHealthDashboard.tsx +214 -0
- package/src/components/TenantSettingsPanel.test.tsx +38 -0
- package/src/components/TenantSettingsPanel.tsx +44 -0
- package/src/components/WorkerPoolPanel.test.tsx +32 -0
- package/src/components/WorkerPoolPanel.tsx +281 -0
- package/src/components/types.ts +131 -0
- package/src/index.tsx +37 -0
- package/src/styles.css +2024 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { RuntimeLogViewer } from "./RuntimeLogViewer";
|
|
4
|
+
|
|
5
|
+
const entries = [
|
|
6
|
+
{
|
|
7
|
+
id: "1",
|
|
8
|
+
timestamp: "10:42:01",
|
|
9
|
+
level: "info" as const,
|
|
10
|
+
source: "ingest",
|
|
11
|
+
message: "Started batch ingest."
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: "2",
|
|
15
|
+
timestamp: "10:42:04",
|
|
16
|
+
level: "warning" as const,
|
|
17
|
+
source: "render",
|
|
18
|
+
message: "Latency 1.2s above threshold."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: "3",
|
|
22
|
+
timestamp: "10:42:08",
|
|
23
|
+
level: "error" as const,
|
|
24
|
+
source: "billing",
|
|
25
|
+
message: "5xx from upstream."
|
|
26
|
+
}
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
describe("RuntimeLogViewer", () => {
|
|
30
|
+
it("renders streaming log controls, status, filters, and summary counts", () => {
|
|
31
|
+
const onPause = vi.fn();
|
|
32
|
+
|
|
33
|
+
render(
|
|
34
|
+
<RuntimeLogViewer
|
|
35
|
+
entries={entries}
|
|
36
|
+
streaming
|
|
37
|
+
onPause={onPause}
|
|
38
|
+
filters={<span>Source: billing</span>}
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const viewer = screen.getByRole("region", { name: "Runtime logs" });
|
|
43
|
+
|
|
44
|
+
expect(within(viewer).getByText("Streaming")).toBeTruthy();
|
|
45
|
+
expect(within(viewer).getByText("3 entries")).toBeTruthy();
|
|
46
|
+
expect(within(viewer).getByText("1 warning")).toBeTruthy();
|
|
47
|
+
expect(within(viewer).getAllByText("1 error")).toHaveLength(2);
|
|
48
|
+
expect(within(viewer).getByText("Source: billing")).toBeTruthy();
|
|
49
|
+
|
|
50
|
+
fireEvent.click(within(viewer).getByRole("button", { name: "Pause runtime stream" }));
|
|
51
|
+
|
|
52
|
+
expect(onPause).toHaveBeenCalledTimes(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("does not render an inert pause action without a handler", () => {
|
|
56
|
+
render(<RuntimeLogViewer entries={entries} streaming />);
|
|
57
|
+
|
|
58
|
+
expect(screen.queryByRole("button", { name: "Pause runtime stream" })).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Pause } from "@carbon/icons-react";
|
|
3
|
+
import { Badge, Button, type EthSeverity, type SurfaceComponentProps } from "@echothink-ui/core";
|
|
4
|
+
import { DataTable, type DataColumn } from "@echothink-ui/data";
|
|
5
|
+
import type { LogEntry } from "./types";
|
|
6
|
+
|
|
7
|
+
export interface RuntimeLogViewerProps extends Omit<SurfaceComponentProps, "children"> {
|
|
8
|
+
entries: LogEntry[];
|
|
9
|
+
filters?: React.ReactNode;
|
|
10
|
+
onPause?: () => void;
|
|
11
|
+
streaming?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type LogRow = Record<string, unknown> & LogEntry;
|
|
15
|
+
|
|
16
|
+
export function RuntimeLogViewer({
|
|
17
|
+
entries,
|
|
18
|
+
filters,
|
|
19
|
+
onPause,
|
|
20
|
+
streaming = false,
|
|
21
|
+
className,
|
|
22
|
+
title = "Runtime logs",
|
|
23
|
+
subtitle,
|
|
24
|
+
description,
|
|
25
|
+
density = "compact",
|
|
26
|
+
"aria-label": ariaLabel,
|
|
27
|
+
"aria-labelledby": ariaLabelledBy
|
|
28
|
+
}: RuntimeLogViewerProps) {
|
|
29
|
+
const headingId = React.useId();
|
|
30
|
+
const warningCount = countLevel(entries, "warning");
|
|
31
|
+
const errorCount = countLevel(entries, "error");
|
|
32
|
+
const latestTimestamp = entries[entries.length - 1]?.timestamp ?? "No activity";
|
|
33
|
+
const supportingText =
|
|
34
|
+
subtitle ?? description ?? `${formatEntryCount(entries.length)} buffered from runtime stream.`;
|
|
35
|
+
const hasToolbar = Boolean(filters || (streaming && onPause));
|
|
36
|
+
const rows: LogRow[] = entries.map((entry) => ({ ...entry }));
|
|
37
|
+
const columns: DataColumn<LogRow>[] = [
|
|
38
|
+
{
|
|
39
|
+
key: "timestamp",
|
|
40
|
+
header: "Time",
|
|
41
|
+
width: "7rem",
|
|
42
|
+
render: (row) => (
|
|
43
|
+
<time className="eth-admin-runtime-log__timestamp" dateTime={row.timestamp}>
|
|
44
|
+
{row.timestamp}
|
|
45
|
+
</time>
|
|
46
|
+
)
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
key: "level",
|
|
50
|
+
header: "Level",
|
|
51
|
+
width: "7rem",
|
|
52
|
+
render: (row) => (
|
|
53
|
+
<Badge severity={levelSeverity(row.level)}>{formatLevel(row.level)}</Badge>
|
|
54
|
+
)
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
key: "source",
|
|
58
|
+
header: "Source",
|
|
59
|
+
width: "11rem",
|
|
60
|
+
render: (row) => (
|
|
61
|
+
<span className="eth-admin-runtime-log__source">{row.source ?? "system"}</span>
|
|
62
|
+
)
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
key: "message",
|
|
66
|
+
header: "Message",
|
|
67
|
+
render: (row) => (
|
|
68
|
+
<span
|
|
69
|
+
className={[
|
|
70
|
+
"eth-admin-runtime-log__message",
|
|
71
|
+
`eth-admin-runtime-log__message--${row.level ?? "info"}`
|
|
72
|
+
].join(" ")}
|
|
73
|
+
>
|
|
74
|
+
{row.message}
|
|
75
|
+
</span>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<section
|
|
82
|
+
role="region"
|
|
83
|
+
aria-label={ariaLabel}
|
|
84
|
+
aria-labelledby={ariaLabelledBy ?? (ariaLabel ? undefined : headingId)}
|
|
85
|
+
className={[
|
|
86
|
+
"eth-admin-runtime-log",
|
|
87
|
+
streaming ? "eth-admin-runtime-log--streaming" : "eth-admin-runtime-log--paused",
|
|
88
|
+
className
|
|
89
|
+
]
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.join(" ")}
|
|
92
|
+
data-eth-component="RuntimeLogViewer"
|
|
93
|
+
>
|
|
94
|
+
<header className="eth-admin-runtime-log__header">
|
|
95
|
+
<div className="eth-admin-runtime-log__heading">
|
|
96
|
+
<h3 id={headingId}>{title}</h3>
|
|
97
|
+
<p>{supportingText}</p>
|
|
98
|
+
</div>
|
|
99
|
+
<div className="eth-admin-runtime-log__status" aria-live="polite">
|
|
100
|
+
<Badge severity={streaming ? "success" : "neutral"}>
|
|
101
|
+
{streaming ? "Streaming" : "Paused"}
|
|
102
|
+
</Badge>
|
|
103
|
+
<span>{errorCount ? formatLevelCount(errorCount, "error") : "No errors"}</span>
|
|
104
|
+
</div>
|
|
105
|
+
</header>
|
|
106
|
+
{hasToolbar ? (
|
|
107
|
+
<div
|
|
108
|
+
className="eth-admin-runtime-log__toolbar"
|
|
109
|
+
role="toolbar"
|
|
110
|
+
aria-label="Runtime log controls"
|
|
111
|
+
>
|
|
112
|
+
{filters ? <div className="eth-admin-runtime-log__filters">{filters}</div> : null}
|
|
113
|
+
{streaming && onPause ? (
|
|
114
|
+
<Button
|
|
115
|
+
type="button"
|
|
116
|
+
intent="secondary"
|
|
117
|
+
density="compact"
|
|
118
|
+
icon={<Pause size={16} />}
|
|
119
|
+
aria-label="Pause runtime stream"
|
|
120
|
+
onClick={onPause}
|
|
121
|
+
>
|
|
122
|
+
Pause stream
|
|
123
|
+
</Button>
|
|
124
|
+
) : null}
|
|
125
|
+
</div>
|
|
126
|
+
) : null}
|
|
127
|
+
<dl className="eth-admin-runtime-log__summary" aria-label="Runtime log summary">
|
|
128
|
+
<div>
|
|
129
|
+
<dt>Entries</dt>
|
|
130
|
+
<dd>{formatEntryCount(entries.length)}</dd>
|
|
131
|
+
</div>
|
|
132
|
+
<div>
|
|
133
|
+
<dt>Warnings</dt>
|
|
134
|
+
<dd>{formatLevelCount(warningCount, "warning")}</dd>
|
|
135
|
+
</div>
|
|
136
|
+
<div>
|
|
137
|
+
<dt>Errors</dt>
|
|
138
|
+
<dd>{formatLevelCount(errorCount, "error")}</dd>
|
|
139
|
+
</div>
|
|
140
|
+
<div>
|
|
141
|
+
<dt>Latest</dt>
|
|
142
|
+
<dd>{latestTimestamp}</dd>
|
|
143
|
+
</div>
|
|
144
|
+
</dl>
|
|
145
|
+
<div className="eth-admin-runtime-log__table-shell" aria-live={streaming ? "polite" : "off"}>
|
|
146
|
+
<DataTable
|
|
147
|
+
rows={rows}
|
|
148
|
+
columns={columns}
|
|
149
|
+
density={density}
|
|
150
|
+
rowKey="id"
|
|
151
|
+
className="eth-admin-runtime-log__table"
|
|
152
|
+
emptyState={
|
|
153
|
+
<div className="eth-admin-runtime-log__empty" role="status">
|
|
154
|
+
<h4>No runtime logs</h4>
|
|
155
|
+
<p>Events will appear here when the stream receives output.</p>
|
|
156
|
+
</div>
|
|
157
|
+
}
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
</section>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function countLevel(entries: LogEntry[], level: NonNullable<LogEntry["level"]>) {
|
|
165
|
+
return entries.filter((entry) => entry.level === level).length;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function formatEntryCount(count: number) {
|
|
169
|
+
return `${count} ${count === 1 ? "entry" : "entries"}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function formatLevelCount(count: number, label: string) {
|
|
173
|
+
return `${count} ${count === 1 ? label : `${label}s`}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function formatLevel(level: LogEntry["level"]) {
|
|
177
|
+
return (level ?? "info").toUpperCase();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function levelSeverity(level: LogEntry["level"]): EthSeverity {
|
|
181
|
+
if (level === "error") return "danger";
|
|
182
|
+
if (level === "warning") return "warning";
|
|
183
|
+
if (level === "debug") return "neutral";
|
|
184
|
+
return "info";
|
|
185
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Badge, Surface, type EthSeverity, type SurfaceComponentProps } from "@echothink-ui/core";
|
|
2
|
+
import type { ServiceHealthStatus, ServiceStatusSummary } from "./types";
|
|
3
|
+
|
|
4
|
+
const percentFormatter = new Intl.NumberFormat("en", { maximumFractionDigits: 2 });
|
|
5
|
+
|
|
6
|
+
export interface ServiceStatusCardProps extends Omit<SurfaceComponentProps, "children"> {
|
|
7
|
+
service: ServiceStatusSummary;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ServiceStatusCard({ service, className, ...props }: ServiceStatusCardProps) {
|
|
11
|
+
const statusSeverity = severityForStatus(service.status);
|
|
12
|
+
const statusText = statusLabel(service.status);
|
|
13
|
+
const metrics = [
|
|
14
|
+
{
|
|
15
|
+
label: "Latency",
|
|
16
|
+
value: service.latencyMs !== undefined ? `${service.latencyMs} ms` : "Unavailable",
|
|
17
|
+
muted: service.latencyMs === undefined
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
label: "Uptime",
|
|
21
|
+
value: service.uptimePercent !== undefined ? formatPercent(service.uptimePercent) : "Not reported",
|
|
22
|
+
muted: service.uptimePercent === undefined
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
label: service.incidentsCount !== undefined ? "Open incidents" : "Last incident",
|
|
26
|
+
value:
|
|
27
|
+
service.incidentsCount !== undefined
|
|
28
|
+
? service.incidentsCount
|
|
29
|
+
: service.lastIncidentAt ?? "None reported",
|
|
30
|
+
muted:
|
|
31
|
+
service.incidentsCount !== undefined ? service.incidentsCount === 0 : !service.lastIncidentAt
|
|
32
|
+
}
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Surface
|
|
37
|
+
{...props}
|
|
38
|
+
title={
|
|
39
|
+
<span className="eth-admin-service-status-card__title">
|
|
40
|
+
<span>{service.name}</span>
|
|
41
|
+
<Badge aria-label={`Service status: ${statusText}`} severity={statusSeverity}>
|
|
42
|
+
{statusText}
|
|
43
|
+
</Badge>
|
|
44
|
+
</span>
|
|
45
|
+
}
|
|
46
|
+
description={statusDescription(service.status)}
|
|
47
|
+
className={[
|
|
48
|
+
"eth-admin-service-status-card",
|
|
49
|
+
`eth-admin-service-status-card--${service.status}`,
|
|
50
|
+
className
|
|
51
|
+
]
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.join(" ")}
|
|
54
|
+
data-eth-component="ServiceStatusCard"
|
|
55
|
+
>
|
|
56
|
+
<dl className="eth-admin-service-status-card__metrics">
|
|
57
|
+
{metrics.map((metric) => (
|
|
58
|
+
<div
|
|
59
|
+
key={metric.label}
|
|
60
|
+
className={metric.muted ? "eth-admin-service-status-card__metric--muted" : undefined}
|
|
61
|
+
>
|
|
62
|
+
<dt>{metric.label}</dt>
|
|
63
|
+
<dd>{metric.value}</dd>
|
|
64
|
+
</div>
|
|
65
|
+
))}
|
|
66
|
+
</dl>
|
|
67
|
+
</Surface>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function severityForStatus(status: ServiceHealthStatus): EthSeverity {
|
|
72
|
+
if (status === "healthy") return "success";
|
|
73
|
+
if (status === "degraded") return "warning";
|
|
74
|
+
return "danger";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function statusLabel(status: ServiceHealthStatus) {
|
|
78
|
+
if (status === "healthy") return "Healthy";
|
|
79
|
+
if (status === "degraded") return "Degraded";
|
|
80
|
+
return "Down";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function statusDescription(status: ServiceHealthStatus) {
|
|
84
|
+
if (status === "healthy") return "Operational within expected thresholds";
|
|
85
|
+
if (status === "degraded") return "Elevated latency or partial failures detected";
|
|
86
|
+
return "Service unavailable or actively failing";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function formatPercent(value: number) {
|
|
90
|
+
return `${percentFormatter.format(value)}%`;
|
|
91
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Badge,
|
|
3
|
+
Surface,
|
|
4
|
+
type EthOperationalStatus,
|
|
5
|
+
type EthSeverity,
|
|
6
|
+
type SurfaceComponentProps
|
|
7
|
+
} from "@echothink-ui/core";
|
|
8
|
+
import { KPIBlock } from "@echothink-ui/charts";
|
|
9
|
+
import { ServiceStatusCard } from "./ServiceStatusCard";
|
|
10
|
+
import type { AdminIncident, AdminMetric, HealthService } from "./types";
|
|
11
|
+
|
|
12
|
+
export interface SystemHealthDashboardProps extends Omit<SurfaceComponentProps, "children"> {
|
|
13
|
+
services: HealthService[];
|
|
14
|
+
incidents?: AdminIncident[];
|
|
15
|
+
metrics?: AdminMetric[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function SystemHealthDashboard({
|
|
19
|
+
services,
|
|
20
|
+
incidents = [],
|
|
21
|
+
metrics = [],
|
|
22
|
+
title,
|
|
23
|
+
description,
|
|
24
|
+
severity,
|
|
25
|
+
status,
|
|
26
|
+
className,
|
|
27
|
+
...props
|
|
28
|
+
}: SystemHealthDashboardProps) {
|
|
29
|
+
const serviceCount = services.length;
|
|
30
|
+
const healthyCount = services.filter((service) => service.status === "healthy").length;
|
|
31
|
+
const degradedCount = services.filter((service) => service.status === "degraded").length;
|
|
32
|
+
const downCount = services.filter((service) => service.status === "down").length;
|
|
33
|
+
const criticalIncidentCount = incidents.filter(
|
|
34
|
+
(incident) => incident.severity === "critical" || incident.severity === "error"
|
|
35
|
+
).length;
|
|
36
|
+
const openIncidentCount = incidents.length;
|
|
37
|
+
const defaultSeverity = severityForHealth(
|
|
38
|
+
downCount,
|
|
39
|
+
degradedCount,
|
|
40
|
+
criticalIncidentCount,
|
|
41
|
+
openIncidentCount
|
|
42
|
+
);
|
|
43
|
+
const defaultStatus = statusForHealth(serviceCount, downCount, degradedCount);
|
|
44
|
+
const computedMetrics = metrics.length
|
|
45
|
+
? metrics
|
|
46
|
+
: [
|
|
47
|
+
{
|
|
48
|
+
label: "Healthy services",
|
|
49
|
+
value: serviceCount ? `${healthyCount} / ${serviceCount}` : "0",
|
|
50
|
+
trend: serviceHealthTrend(healthyCount, degradedCount, downCount)
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
label: "Open incidents",
|
|
54
|
+
value: openIncidentCount,
|
|
55
|
+
trend: incidentTrend(criticalIncidentCount, openIncidentCount)
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
label: "Avg latency",
|
|
59
|
+
value: average(
|
|
60
|
+
services
|
|
61
|
+
.map((service) => service.latencyMs)
|
|
62
|
+
.filter((value): value is number => value !== undefined)
|
|
63
|
+
),
|
|
64
|
+
trend: serviceCount ? `${serviceCount} monitored` : undefined
|
|
65
|
+
}
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Surface
|
|
70
|
+
{...props}
|
|
71
|
+
title={title ?? "System health"}
|
|
72
|
+
description={
|
|
73
|
+
description ??
|
|
74
|
+
healthSummary(serviceCount, healthyCount, degradedCount, downCount, openIncidentCount)
|
|
75
|
+
}
|
|
76
|
+
severity={severity ?? defaultSeverity}
|
|
77
|
+
status={status ?? defaultStatus}
|
|
78
|
+
className={["eth-admin-system-health", className].filter(Boolean).join(" ")}
|
|
79
|
+
data-eth-component="SystemHealthDashboard"
|
|
80
|
+
>
|
|
81
|
+
<div className="eth-admin-system-health__metrics">
|
|
82
|
+
{computedMetrics.map((metric) => (
|
|
83
|
+
<KPIBlock
|
|
84
|
+
key={String(metric.label)}
|
|
85
|
+
title={metric.label}
|
|
86
|
+
value={metric.value}
|
|
87
|
+
status={metric.trend}
|
|
88
|
+
/>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
<div className="eth-admin-system-health__services">
|
|
92
|
+
{services.length ? (
|
|
93
|
+
services.map((service) => {
|
|
94
|
+
const serviceIncidents = incidents.filter((incident) =>
|
|
95
|
+
incident.title.toLowerCase().includes(service.name.toLowerCase())
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<ServiceStatusCard
|
|
100
|
+
key={service.id}
|
|
101
|
+
service={{
|
|
102
|
+
name: service.name,
|
|
103
|
+
status: service.status,
|
|
104
|
+
latencyMs: service.latencyMs,
|
|
105
|
+
uptimePercent: service.status === "healthy" ? 99.9 : undefined,
|
|
106
|
+
incidentsCount: service.incidentsCount ?? serviceIncidents.length,
|
|
107
|
+
lastIncidentAt: serviceIncidents[0]?.startedAt
|
|
108
|
+
}}
|
|
109
|
+
/>
|
|
110
|
+
);
|
|
111
|
+
})
|
|
112
|
+
) : (
|
|
113
|
+
<p className="eth-admin-system-health__empty">No services are currently registered.</p>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
{incidents.length ? (
|
|
117
|
+
<section className="eth-admin-system-health__incidents">
|
|
118
|
+
<h3>Incidents</h3>
|
|
119
|
+
<ul>
|
|
120
|
+
{incidents.map((incident) => (
|
|
121
|
+
<li key={incident.id}>
|
|
122
|
+
<Badge severity={severityForIncident(incident.severity)}>{incident.severity}</Badge>
|
|
123
|
+
<span>{incident.title}</span>
|
|
124
|
+
<time>{incident.startedAt}</time>
|
|
125
|
+
</li>
|
|
126
|
+
))}
|
|
127
|
+
</ul>
|
|
128
|
+
</section>
|
|
129
|
+
) : null}
|
|
130
|
+
</Surface>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function average(values: number[]) {
|
|
135
|
+
if (!values.length) return "0 ms";
|
|
136
|
+
return `${Math.round(values.reduce((sum, value) => sum + value, 0) / values.length)} ms`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function healthSummary(
|
|
140
|
+
serviceCount: number,
|
|
141
|
+
healthyCount: number,
|
|
142
|
+
degradedCount: number,
|
|
143
|
+
downCount: number,
|
|
144
|
+
openIncidentCount: number
|
|
145
|
+
) {
|
|
146
|
+
if (!serviceCount) return "No services registered for this tenant.";
|
|
147
|
+
|
|
148
|
+
const exceptions: string[] = [];
|
|
149
|
+
if (degradedCount) exceptions.push(`${degradedCount} degraded`);
|
|
150
|
+
if (downCount) exceptions.push(`${downCount} down`);
|
|
151
|
+
if (openIncidentCount) {
|
|
152
|
+
exceptions.push(`${openIncidentCount} open ${pluralize(openIncidentCount, "incident")}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const serviceSummary = `${healthyCount} of ${serviceCount} ${pluralize(serviceCount, "service")} healthy`;
|
|
156
|
+
return exceptions.length
|
|
157
|
+
? `${serviceSummary}; ${exceptions.join(", ")}.`
|
|
158
|
+
: `${serviceSummary}; no open incidents.`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function serviceHealthTrend(healthyCount: number, degradedCount: number, downCount: number) {
|
|
162
|
+
if (downCount) {
|
|
163
|
+
return <Badge severity="danger">{`${downCount} down`}</Badge>;
|
|
164
|
+
}
|
|
165
|
+
if (degradedCount) {
|
|
166
|
+
return <Badge severity="warning">{`${degradedCount} degraded`}</Badge>;
|
|
167
|
+
}
|
|
168
|
+
if (healthyCount) {
|
|
169
|
+
return <Badge severity="success">Nominal</Badge>;
|
|
170
|
+
}
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function incidentTrend(criticalIncidentCount: number, openIncidentCount: number) {
|
|
175
|
+
if (criticalIncidentCount) {
|
|
176
|
+
return <Badge severity="danger">{`${criticalIncidentCount} critical`}</Badge>;
|
|
177
|
+
}
|
|
178
|
+
if (openIncidentCount) {
|
|
179
|
+
return <Badge severity="warning">Investigating</Badge>;
|
|
180
|
+
}
|
|
181
|
+
return <Badge severity="success">Clear</Badge>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function severityForHealth(
|
|
185
|
+
downCount: number,
|
|
186
|
+
degradedCount: number,
|
|
187
|
+
criticalIncidentCount: number,
|
|
188
|
+
openIncidentCount: number
|
|
189
|
+
): EthSeverity | undefined {
|
|
190
|
+
if (downCount || criticalIncidentCount) return "danger";
|
|
191
|
+
if (degradedCount || openIncidentCount) return "warning";
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function statusForHealth(
|
|
196
|
+
serviceCount: number,
|
|
197
|
+
downCount: number,
|
|
198
|
+
degradedCount: number
|
|
199
|
+
): EthOperationalStatus {
|
|
200
|
+
if (!serviceCount) return "inactive";
|
|
201
|
+
if (downCount) return "failed";
|
|
202
|
+
if (degradedCount) return "warning";
|
|
203
|
+
return "active";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function pluralize(count: number, singular: string) {
|
|
207
|
+
return count === 1 ? singular : `${singular}s`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function severityForIncident(severity: AdminIncident["severity"]): EthSeverity {
|
|
211
|
+
if (severity === "critical" || severity === "error") return "danger";
|
|
212
|
+
if (severity === "warning") return "warning";
|
|
213
|
+
return "info";
|
|
214
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { TenantSettingsPanel } from "./TenantSettingsPanel";
|
|
4
|
+
|
|
5
|
+
describe("TenantSettingsPanel", () => {
|
|
6
|
+
it("renders tenant settings with an admin styling hook and explicit save action", () => {
|
|
7
|
+
const { container } = render(
|
|
8
|
+
<TenantSettingsPanel
|
|
9
|
+
submitLabel="Save settings"
|
|
10
|
+
values={{ tenantName: "EchoThink, Inc.", region: "us-west" }}
|
|
11
|
+
sections={[
|
|
12
|
+
{
|
|
13
|
+
id: "general",
|
|
14
|
+
title: "General",
|
|
15
|
+
description: "Tenant identity and default region.",
|
|
16
|
+
fields: [
|
|
17
|
+
{ name: "tenantName", type: "text", label: "Tenant name", required: true },
|
|
18
|
+
{
|
|
19
|
+
name: "region",
|
|
20
|
+
type: "select",
|
|
21
|
+
label: "Region",
|
|
22
|
+
options: [
|
|
23
|
+
{ value: "us-west", label: "US West" },
|
|
24
|
+
{ value: "eu-west", label: "EU West" }
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
]}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
expect(container.querySelector(".eth-admin-tenant-settings")).toBeTruthy();
|
|
34
|
+
expect(screen.getByRole("heading", { name: "Tenant settings" })).toBeTruthy();
|
|
35
|
+
expect(screen.getByText("Tenant identity and default region.")).toBeTruthy();
|
|
36
|
+
expect(screen.getByRole("button", { name: "Save settings" })).toBeTruthy();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ConfigForm, type FormValues } from "@echothink-ui/forms";
|
|
2
|
+
import type { SurfaceComponentProps } from "@echothink-ui/core";
|
|
3
|
+
import type { TenantSettingsSection } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface TenantSettingsPanelProps
|
|
6
|
+
extends Omit<SurfaceComponentProps, "children" | "onChange" | "onSubmit"> {
|
|
7
|
+
sections: TenantSettingsSection[];
|
|
8
|
+
values: FormValues;
|
|
9
|
+
onChange?: (values: FormValues) => void;
|
|
10
|
+
onSubmit?: (values: FormValues) => void;
|
|
11
|
+
submitLabel?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function TenantSettingsPanel({
|
|
15
|
+
sections,
|
|
16
|
+
values,
|
|
17
|
+
onChange,
|
|
18
|
+
onSubmit,
|
|
19
|
+
submitLabel = "Save settings",
|
|
20
|
+
title,
|
|
21
|
+
description,
|
|
22
|
+
className,
|
|
23
|
+
...props
|
|
24
|
+
}: TenantSettingsPanelProps) {
|
|
25
|
+
const effectiveDescription =
|
|
26
|
+
description === undefined
|
|
27
|
+
? "Manage tenant identity, regional placement, and governance defaults."
|
|
28
|
+
: description;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<ConfigForm
|
|
32
|
+
{...props}
|
|
33
|
+
title={title ?? "Tenant settings"}
|
|
34
|
+
description={effectiveDescription}
|
|
35
|
+
className={["eth-admin-tenant-settings", className].filter(Boolean).join(" ")}
|
|
36
|
+
sections={sections}
|
|
37
|
+
values={values}
|
|
38
|
+
onChange={onChange}
|
|
39
|
+
onSubmit={onSubmit}
|
|
40
|
+
submitLabel={submitLabel}
|
|
41
|
+
data-eth-component="TenantSettingsPanel"
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { WorkerPoolPanel } from "./WorkerPoolPanel";
|
|
4
|
+
|
|
5
|
+
describe("WorkerPoolPanel", () => {
|
|
6
|
+
it("renders worker capacity, health summary, and scale controls", () => {
|
|
7
|
+
const onScale = vi.fn();
|
|
8
|
+
|
|
9
|
+
render(
|
|
10
|
+
<WorkerPoolPanel
|
|
11
|
+
pools={[
|
|
12
|
+
{ id: "render", name: "render", active: 12, total: 16, idle: 4, failed: 0 },
|
|
13
|
+
{ id: "send", name: "send", active: 5, total: 8, idle: 3, failed: 1 }
|
|
14
|
+
]}
|
|
15
|
+
onScale={onScale}
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
expect(screen.getByRole("heading", { name: "Worker pools" })).toBeTruthy();
|
|
20
|
+
expect(screen.getByText("2 pools")).toBeTruthy();
|
|
21
|
+
expect(screen.getByText("17 / 24")).toBeTruthy();
|
|
22
|
+
expect(screen.getByText("1 failed")).toBeTruthy();
|
|
23
|
+
expect(screen.getByText("Failure in pool")).toBeTruthy();
|
|
24
|
+
|
|
25
|
+
const capacity = screen.getByRole("progressbar", { name: "render capacity" });
|
|
26
|
+
expect(capacity.getAttribute("aria-valuenow")).toBe("75");
|
|
27
|
+
expect(capacity.getAttribute("aria-valuetext")).toBe("12 of 16 workers active");
|
|
28
|
+
|
|
29
|
+
fireEvent.click(screen.getByRole("button", { name: "Scale render up by 1" }));
|
|
30
|
+
expect(onScale).toHaveBeenCalledWith("render", 1);
|
|
31
|
+
});
|
|
32
|
+
});
|