@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,171 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
ActionGroup,
|
|
4
|
+
Badge,
|
|
5
|
+
type EthAction,
|
|
6
|
+
type EthSeverity,
|
|
7
|
+
type SurfaceComponentProps
|
|
8
|
+
} from "@echothink-ui/core";
|
|
9
|
+
import { DataTable, type DataColumn } from "@echothink-ui/data";
|
|
10
|
+
import type { JobQueue } from "./types";
|
|
11
|
+
|
|
12
|
+
export interface JobQueuePanelProps extends Omit<SurfaceComponentProps, "children" | "actions"> {
|
|
13
|
+
queues: JobQueue[];
|
|
14
|
+
actions?: EthAction[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type JobQueueRow = Record<string, unknown> & JobQueue;
|
|
18
|
+
|
|
19
|
+
export function JobQueuePanel({
|
|
20
|
+
queues,
|
|
21
|
+
actions,
|
|
22
|
+
title = "Job queues",
|
|
23
|
+
subtitle,
|
|
24
|
+
description,
|
|
25
|
+
density = "compact",
|
|
26
|
+
className,
|
|
27
|
+
"aria-labelledby": ariaLabelledBy,
|
|
28
|
+
eyebrow: _eyebrow,
|
|
29
|
+
status: _status,
|
|
30
|
+
severity: _severity,
|
|
31
|
+
loading: _loading,
|
|
32
|
+
empty: _empty,
|
|
33
|
+
error: _error,
|
|
34
|
+
items: _items,
|
|
35
|
+
metadata: _metadata,
|
|
36
|
+
footer: _footer,
|
|
37
|
+
...props
|
|
38
|
+
}: JobQueuePanelProps) {
|
|
39
|
+
const headingId = React.useId();
|
|
40
|
+
const rows: JobQueueRow[] = queues.map((queue) => ({ ...queue }));
|
|
41
|
+
const subtitleContent = subtitle ?? description;
|
|
42
|
+
const totals = queues.reduce(
|
|
43
|
+
(summary, queue) => ({
|
|
44
|
+
depth: summary.depth + queue.depth,
|
|
45
|
+
processing: summary.processing + queue.processing,
|
|
46
|
+
failed: summary.failed + queue.failed
|
|
47
|
+
}),
|
|
48
|
+
{ depth: 0, processing: 0, failed: 0 }
|
|
49
|
+
);
|
|
50
|
+
const columns: DataColumn<JobQueueRow>[] = [
|
|
51
|
+
{
|
|
52
|
+
key: "name",
|
|
53
|
+
header: "Queue",
|
|
54
|
+
width: "34%",
|
|
55
|
+
render: (row) => (
|
|
56
|
+
<div className="eth-admin-job-queue__queue">
|
|
57
|
+
<strong>{row.name}</strong>
|
|
58
|
+
<span>{queueStateLabel(row)}</span>
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
key: "depth",
|
|
64
|
+
header: "Depth",
|
|
65
|
+
align: "end",
|
|
66
|
+
width: "16%",
|
|
67
|
+
render: (row) => <span className="eth-admin-job-queue__number">{formatCount(row.depth)}</span>
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
key: "processing",
|
|
71
|
+
header: "Processing",
|
|
72
|
+
align: "end",
|
|
73
|
+
width: "18%",
|
|
74
|
+
render: (row) => (
|
|
75
|
+
<span className="eth-admin-job-queue__number">{formatCount(row.processing)}</span>
|
|
76
|
+
)
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
key: "failed",
|
|
80
|
+
header: "Failed",
|
|
81
|
+
align: "end",
|
|
82
|
+
width: "14%",
|
|
83
|
+
render: (row) => (
|
|
84
|
+
<Badge severity={row.failed > 0 ? "danger" : "success"}>{formatCount(row.failed)}</Badge>
|
|
85
|
+
)
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
key: "retryRate",
|
|
89
|
+
header: "Retry rate",
|
|
90
|
+
align: "end",
|
|
91
|
+
width: "18%",
|
|
92
|
+
render: (row) =>
|
|
93
|
+
row.retryRate === undefined ? (
|
|
94
|
+
<span className="eth-admin-job-queue__muted">n/a</span>
|
|
95
|
+
) : (
|
|
96
|
+
<Badge severity={retryRateSeverity(row.retryRate)}>
|
|
97
|
+
{formatRetryRate(row.retryRate)}
|
|
98
|
+
</Badge>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
];
|
|
102
|
+
const statusSeverity: EthSeverity =
|
|
103
|
+
queues.length === 0
|
|
104
|
+
? "neutral"
|
|
105
|
+
: totals.failed > 0
|
|
106
|
+
? "danger"
|
|
107
|
+
: totals.processing > 0
|
|
108
|
+
? "info"
|
|
109
|
+
: "success";
|
|
110
|
+
const statusLabel =
|
|
111
|
+
queues.length === 0 ? "No queues" : totals.failed > 0 ? "Action needed" : "Operational";
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<section
|
|
115
|
+
{...props}
|
|
116
|
+
className={["eth-admin-job-queue", className].filter(Boolean).join(" ")}
|
|
117
|
+
data-eth-component="JobQueuePanel"
|
|
118
|
+
aria-labelledby={ariaLabelledBy ?? headingId}
|
|
119
|
+
>
|
|
120
|
+
<header>
|
|
121
|
+
<div className="eth-admin-job-queue__heading">
|
|
122
|
+
<h3 id={headingId}>{title}</h3>
|
|
123
|
+
{subtitleContent ? (
|
|
124
|
+
<div className="eth-admin-job-queue__subtitle">{subtitleContent}</div>
|
|
125
|
+
) : null}
|
|
126
|
+
</div>
|
|
127
|
+
<div className="eth-admin-job-queue__header-actions">
|
|
128
|
+
<Badge severity={statusSeverity}>{statusLabel}</Badge>
|
|
129
|
+
<ActionGroup actions={actions} />
|
|
130
|
+
</div>
|
|
131
|
+
</header>
|
|
132
|
+
<dl className="eth-admin-job-queue__summary" aria-label="Queue totals">
|
|
133
|
+
<div>
|
|
134
|
+
<dt>Total depth</dt>
|
|
135
|
+
<dd>{formatCount(totals.depth)}</dd>
|
|
136
|
+
</div>
|
|
137
|
+
<div>
|
|
138
|
+
<dt>Processing</dt>
|
|
139
|
+
<dd>{formatCount(totals.processing)}</dd>
|
|
140
|
+
</div>
|
|
141
|
+
<div>
|
|
142
|
+
<dt>Failed</dt>
|
|
143
|
+
<dd>{formatCount(totals.failed)}</dd>
|
|
144
|
+
</div>
|
|
145
|
+
</dl>
|
|
146
|
+
<DataTable rows={rows} columns={columns} density={density} />
|
|
147
|
+
</section>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function queueStateLabel(queue: JobQueue) {
|
|
152
|
+
if (queue.failed > 0) return "Failure requires review";
|
|
153
|
+
if (queue.processing > 0) return "Workers active";
|
|
154
|
+
if (queue.depth > 0) return "Waiting for capacity";
|
|
155
|
+
return "Idle";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function retryRateSeverity(retryRate: number): EthSeverity {
|
|
159
|
+
if (retryRate >= 1) return "danger";
|
|
160
|
+
if (retryRate > 0.1) return "warning";
|
|
161
|
+
if (retryRate > 0) return "info";
|
|
162
|
+
return "success";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function formatCount(value: number) {
|
|
166
|
+
return new Intl.NumberFormat("en-US").format(value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function formatRetryRate(value: number) {
|
|
170
|
+
return `${value.toLocaleString("en-US", { maximumFractionDigits: 2 })}%`;
|
|
171
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { render, screen, within } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { PolicyConfigPanel } from "./PolicyConfigPanel";
|
|
4
|
+
|
|
5
|
+
describe("PolicyConfigPanel", () => {
|
|
6
|
+
it("renders policy metadata, scope summary, and an enabled save action", () => {
|
|
7
|
+
render(
|
|
8
|
+
<PolicyConfigPanel
|
|
9
|
+
policy={{
|
|
10
|
+
id: "external-send-review",
|
|
11
|
+
name: "External send review",
|
|
12
|
+
description: "External messages require legal review before delivery.",
|
|
13
|
+
variables: ["recipient_domain", "message_channel", "risk_score"],
|
|
14
|
+
rules: [
|
|
15
|
+
{
|
|
16
|
+
id: "external-domain",
|
|
17
|
+
when: { kind: "leaf", field: "recipient_domain", op: "eq", value: "external" },
|
|
18
|
+
then: { type: "require-approval", target: "legal-review" }
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}}
|
|
22
|
+
schema={{
|
|
23
|
+
description: "Approval gates for outbound communications.",
|
|
24
|
+
properties: {
|
|
25
|
+
recipient_domain: { type: "string", title: "Recipient domain" },
|
|
26
|
+
message_channel: { type: "string", title: "Message channel" },
|
|
27
|
+
risk_score: { type: "number", title: "Risk score" }
|
|
28
|
+
}
|
|
29
|
+
}}
|
|
30
|
+
onChange={() => undefined}
|
|
31
|
+
onSave={() => undefined}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
expect(screen.getByRole("heading", { name: "Policy configuration" })).toBeTruthy();
|
|
36
|
+
|
|
37
|
+
const summary = screen.getByRole("complementary", { name: "Policy summary" });
|
|
38
|
+
expect(within(summary).getByText("Configured")).toBeTruthy();
|
|
39
|
+
expect(within(summary).getByText("recipient_domain")).toBeTruthy();
|
|
40
|
+
expect(within(summary).getByText("message_channel")).toBeTruthy();
|
|
41
|
+
|
|
42
|
+
const saveButton = screen.getByRole("button", { name: "Save policy" });
|
|
43
|
+
expect(saveButton.hasAttribute("disabled")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { useId } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
FormField,
|
|
6
|
+
Surface,
|
|
7
|
+
Tag,
|
|
8
|
+
TextInput,
|
|
9
|
+
Textarea,
|
|
10
|
+
type SurfaceComponentProps
|
|
11
|
+
} from "@echothink-ui/core";
|
|
12
|
+
import { RuleBuilder } from "@echothink-ui/forms";
|
|
13
|
+
import type { AdminPolicyConfig, JsonSchema } from "./types";
|
|
14
|
+
|
|
15
|
+
export interface PolicyConfigPanelProps
|
|
16
|
+
extends Omit<SurfaceComponentProps, "children" | "onChange"> {
|
|
17
|
+
policy: AdminPolicyConfig;
|
|
18
|
+
schema: JsonSchema;
|
|
19
|
+
onChange?: (policy: AdminPolicyConfig) => void;
|
|
20
|
+
onSave?: (policy: AdminPolicyConfig) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function PolicyConfigPanel({
|
|
24
|
+
policy,
|
|
25
|
+
schema,
|
|
26
|
+
onChange,
|
|
27
|
+
onSave,
|
|
28
|
+
title,
|
|
29
|
+
description,
|
|
30
|
+
density = "default",
|
|
31
|
+
className,
|
|
32
|
+
...props
|
|
33
|
+
}: PolicyConfigPanelProps) {
|
|
34
|
+
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "");
|
|
35
|
+
const policyDomId = `eth-admin-policy-${String(policy.id ?? generatedId).replace(
|
|
36
|
+
/[^a-zA-Z0-9_-]/g,
|
|
37
|
+
"-"
|
|
38
|
+
)}`;
|
|
39
|
+
const update = (patch: Partial<AdminPolicyConfig>) => onChange?.({ ...policy, ...patch });
|
|
40
|
+
const schemaFields = Object.keys(schema.properties ?? {});
|
|
41
|
+
const variables = policy.variables?.length ? policy.variables : schemaFields;
|
|
42
|
+
const rules = policy.rules ?? [];
|
|
43
|
+
const visibleVariables = variables.slice(0, 5);
|
|
44
|
+
const hiddenVariableCount = Math.max(0, variables.length - visibleVariables.length);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Surface
|
|
48
|
+
{...props}
|
|
49
|
+
title={title ?? "Policy configuration"}
|
|
50
|
+
description={
|
|
51
|
+
description ??
|
|
52
|
+
schema.description ??
|
|
53
|
+
"Configure policy metadata and conditional rules for governed operations."
|
|
54
|
+
}
|
|
55
|
+
density={density}
|
|
56
|
+
className={["eth-admin-policy-config", className].filter(Boolean).join(" ")}
|
|
57
|
+
data-eth-component="PolicyConfigPanel"
|
|
58
|
+
>
|
|
59
|
+
<div className="eth-admin-policy-config__layout">
|
|
60
|
+
<div className="eth-admin-policy-config__fields">
|
|
61
|
+
<FormField id={`${policyDomId}-name`} label="Policy name" required>
|
|
62
|
+
<TextInput
|
|
63
|
+
id={`${policyDomId}-name`}
|
|
64
|
+
density={density}
|
|
65
|
+
value={policy.name ?? ""}
|
|
66
|
+
onChange={(event) => update({ name: event.currentTarget.value })}
|
|
67
|
+
/>
|
|
68
|
+
</FormField>
|
|
69
|
+
<FormField id={`${policyDomId}-description`} label="Description">
|
|
70
|
+
<Textarea
|
|
71
|
+
id={`${policyDomId}-description`}
|
|
72
|
+
rows={4}
|
|
73
|
+
value={policy.description ?? ""}
|
|
74
|
+
onChange={(event) => update({ description: event.currentTarget.value })}
|
|
75
|
+
/>
|
|
76
|
+
</FormField>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<aside className="eth-admin-policy-config__summary" aria-label="Policy summary">
|
|
80
|
+
<div className="eth-admin-policy-config__summary-header">
|
|
81
|
+
<h3>Policy scope</h3>
|
|
82
|
+
<Badge severity={rules.length ? "success" : "warning"}>
|
|
83
|
+
{rules.length ? "Configured" : "Needs rules"}
|
|
84
|
+
</Badge>
|
|
85
|
+
</div>
|
|
86
|
+
<dl className="eth-admin-policy-config__summary-grid">
|
|
87
|
+
<div>
|
|
88
|
+
<dt>Rules</dt>
|
|
89
|
+
<dd>{rules.length}</dd>
|
|
90
|
+
</div>
|
|
91
|
+
<div>
|
|
92
|
+
<dt>Variables</dt>
|
|
93
|
+
<dd>{variables.length}</dd>
|
|
94
|
+
</div>
|
|
95
|
+
<div>
|
|
96
|
+
<dt>Schema fields</dt>
|
|
97
|
+
<dd>{schemaFields.length}</dd>
|
|
98
|
+
</div>
|
|
99
|
+
</dl>
|
|
100
|
+
<div className="eth-admin-policy-config__variables">
|
|
101
|
+
<span>Rule variables</span>
|
|
102
|
+
{variables.length ? (
|
|
103
|
+
<div className="eth-admin-policy-config__variable-list">
|
|
104
|
+
{visibleVariables.map((variable) => (
|
|
105
|
+
<Tag key={variable}>{variable}</Tag>
|
|
106
|
+
))}
|
|
107
|
+
{hiddenVariableCount ? (
|
|
108
|
+
<Badge severity="neutral">+{hiddenVariableCount} more</Badge>
|
|
109
|
+
) : null}
|
|
110
|
+
</div>
|
|
111
|
+
) : (
|
|
112
|
+
<p>No variables available.</p>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
</aside>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<RuleBuilder
|
|
119
|
+
className="eth-admin-policy-config__rules"
|
|
120
|
+
rules={rules}
|
|
121
|
+
variables={variables}
|
|
122
|
+
onChange={(nextRules) => update({ rules: nextRules })}
|
|
123
|
+
/>
|
|
124
|
+
<div className="eth-admin-policy-config__actions">
|
|
125
|
+
<Button type="button" disabled={!onSave} onClick={() => onSave?.(policy)}>
|
|
126
|
+
Save policy
|
|
127
|
+
</Button>
|
|
128
|
+
</div>
|
|
129
|
+
</Surface>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ChartBlock } from "@echothink-ui/charts";
|
|
2
|
+
import type { SurfaceComponentProps } from "@echothink-ui/core";
|
|
3
|
+
import type { QueueDepthPoint } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface QueueDepthChartProps extends Omit<SurfaceComponentProps, "children"> {
|
|
6
|
+
points: QueueDepthPoint[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const depthFormatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 });
|
|
10
|
+
const queueDepthSeries = [{ key: "depth", label: "Queue depth", color: "#0f62fe" }];
|
|
11
|
+
|
|
12
|
+
export function QueueDepthChart({
|
|
13
|
+
points,
|
|
14
|
+
title,
|
|
15
|
+
metadata,
|
|
16
|
+
className,
|
|
17
|
+
...props
|
|
18
|
+
}: QueueDepthChartProps) {
|
|
19
|
+
const data = points.map((point, index) => {
|
|
20
|
+
const row = {
|
|
21
|
+
label: point.label ?? point.timestamp ?? String(index + 1),
|
|
22
|
+
depth: point.depth
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return point.timestamp ? { ...row, timestamp: point.timestamp } : row;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<ChartBlock
|
|
30
|
+
{...props}
|
|
31
|
+
className={["eth-admin-queue-depth-chart", className].filter(Boolean).join(" ")}
|
|
32
|
+
title={title ?? "Queue depth"}
|
|
33
|
+
metadata={metadata ?? queueDepthMetadata(points)}
|
|
34
|
+
chartType="line"
|
|
35
|
+
data={data}
|
|
36
|
+
valueKey="depth"
|
|
37
|
+
series={queueDepthSeries}
|
|
38
|
+
data-eth-component="QueueDepthChart"
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function queueDepthMetadata(points: QueueDepthPoint[]): SurfaceComponentProps["metadata"] {
|
|
44
|
+
const depths = points.map((point) => point.depth).filter(Number.isFinite);
|
|
45
|
+
if (!depths.length) return [];
|
|
46
|
+
|
|
47
|
+
const current = depths.at(-1) ?? 0;
|
|
48
|
+
const previous = depths.at(-2);
|
|
49
|
+
const peak = Math.max(...depths);
|
|
50
|
+
const average = depths.reduce((total, depth) => total + depth, 0) / depths.length;
|
|
51
|
+
|
|
52
|
+
return [
|
|
53
|
+
{ label: "Current depth", value: formatDepth(current) },
|
|
54
|
+
{ label: "Peak", value: formatDepth(peak) },
|
|
55
|
+
{ label: "Average", value: formatDepth(average) },
|
|
56
|
+
{
|
|
57
|
+
label: "Latest change",
|
|
58
|
+
value: previous === undefined ? "n/a" : formatDelta(current - previous)
|
|
59
|
+
}
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatDepth(value: number) {
|
|
64
|
+
return depthFormatter.format(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatDelta(value: number) {
|
|
68
|
+
if (value === 0) return "No change";
|
|
69
|
+
return `${value > 0 ? "+" : ""}${formatDepth(value)}`;
|
|
70
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { RateLimitPanel } from "./RateLimitPanel";
|
|
4
|
+
|
|
5
|
+
describe("RateLimitPanel", () => {
|
|
6
|
+
it("renders rate-limit usage, summary state, and non-editable configuration without an update handler", () => {
|
|
7
|
+
render(
|
|
8
|
+
<RateLimitPanel
|
|
9
|
+
limits={[
|
|
10
|
+
{ id: "send", name: "send.email", current: 80, limit: 100, windowSeconds: 60 },
|
|
11
|
+
{ id: "ingest", name: "ingest.docs", current: 200, limit: 1000, windowSeconds: 60 }
|
|
12
|
+
]}
|
|
13
|
+
/>
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
expect(screen.getByRole("heading", { name: "Rate limits" })).toBeTruthy();
|
|
17
|
+
expect(screen.getByText("1 near limit")).toBeTruthy();
|
|
18
|
+
expect(screen.getByText("send.email 80%")).toBeTruthy();
|
|
19
|
+
|
|
20
|
+
const usage = screen.getByRole("progressbar", { name: "send.email usage" });
|
|
21
|
+
expect(usage.getAttribute("aria-valuenow")).toBe("80");
|
|
22
|
+
expect(usage.getAttribute("aria-valuetext")).toBe("80% used, 80 of 100 requests");
|
|
23
|
+
|
|
24
|
+
const configuredLimit = screen.getByRole("spinbutton", {
|
|
25
|
+
name: "send.email configured limit"
|
|
26
|
+
});
|
|
27
|
+
expect(configuredLimit.hasAttribute("readonly")).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
ActionGroup,
|
|
4
|
+
Badge,
|
|
5
|
+
NumberInput,
|
|
6
|
+
type EthSeverity,
|
|
7
|
+
type SurfaceComponentProps
|
|
8
|
+
} from "@echothink-ui/core";
|
|
9
|
+
import { DataTable, type DataColumn } from "@echothink-ui/data";
|
|
10
|
+
import type { RateLimit } from "./types";
|
|
11
|
+
|
|
12
|
+
export interface RateLimitPanelProps extends Omit<SurfaceComponentProps, "children"> {
|
|
13
|
+
limits: RateLimit[];
|
|
14
|
+
onUpdate?: (id: string, update: Partial<RateLimit>) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type RateLimitTone = "normal" | "warning" | "danger" | "neutral";
|
|
18
|
+
|
|
19
|
+
interface RateLimitState {
|
|
20
|
+
label: string;
|
|
21
|
+
severity: EthSeverity;
|
|
22
|
+
tone: RateLimitTone;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type RateLimitMeterStyle = React.CSSProperties & {
|
|
26
|
+
"--eth-admin-rate-limit-usage": string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type RateLimitRow = Record<string, unknown> &
|
|
30
|
+
RateLimit & {
|
|
31
|
+
cappedPercent: number;
|
|
32
|
+
percent: number;
|
|
33
|
+
percentLabel: string;
|
|
34
|
+
state: RateLimitState;
|
|
35
|
+
usageLabel: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function RateLimitPanel({
|
|
39
|
+
limits,
|
|
40
|
+
onUpdate,
|
|
41
|
+
title = "Rate limits",
|
|
42
|
+
subtitle,
|
|
43
|
+
description,
|
|
44
|
+
actions,
|
|
45
|
+
density = "compact",
|
|
46
|
+
className,
|
|
47
|
+
"aria-label": ariaLabel,
|
|
48
|
+
"aria-labelledby": ariaLabelledBy,
|
|
49
|
+
eyebrow: _eyebrow,
|
|
50
|
+
status: _status,
|
|
51
|
+
severity: _severity,
|
|
52
|
+
loading: _loading,
|
|
53
|
+
empty: _empty,
|
|
54
|
+
error: _error,
|
|
55
|
+
items: _items,
|
|
56
|
+
metadata: _metadata,
|
|
57
|
+
footer: _footer,
|
|
58
|
+
...sectionProps
|
|
59
|
+
}: RateLimitPanelProps) {
|
|
60
|
+
const headingId = React.useId();
|
|
61
|
+
const rows: RateLimitRow[] = limits.map((limit) => {
|
|
62
|
+
const percent = limit.limit > 0 ? (limit.current / limit.limit) * 100 : 0;
|
|
63
|
+
const cappedPercent = Math.min(100, Math.max(0, percent));
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
...limit,
|
|
67
|
+
cappedPercent,
|
|
68
|
+
percent,
|
|
69
|
+
percentLabel: limit.limit > 0 ? formatPercent(percent) : "No limit",
|
|
70
|
+
state: rateLimitState(percent, limit.limit),
|
|
71
|
+
usageLabel:
|
|
72
|
+
limit.limit > 0
|
|
73
|
+
? `${formatNumber(limit.current)} / ${formatNumber(limit.limit)}`
|
|
74
|
+
: `${formatNumber(limit.current)} requests`
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
const highestUsage = rows.reduce<RateLimitRow | undefined>((highest, row) => {
|
|
78
|
+
if (row.limit <= 0) return highest;
|
|
79
|
+
if (!highest || row.percent > highest.percent) return row;
|
|
80
|
+
return highest;
|
|
81
|
+
}, undefined);
|
|
82
|
+
const exceededCount = rows.filter((row) => row.limit > 0 && row.percent >= 100).length;
|
|
83
|
+
const nearLimitCount = rows.filter(
|
|
84
|
+
(row) => row.limit > 0 && row.percent >= 80 && row.percent < 100
|
|
85
|
+
).length;
|
|
86
|
+
const statusSummary = rateLimitStatusSummary(limits.length, exceededCount, nearLimitCount);
|
|
87
|
+
const supportingText = subtitle ?? description;
|
|
88
|
+
const columns: DataColumn<RateLimitRow>[] = [
|
|
89
|
+
{
|
|
90
|
+
key: "name",
|
|
91
|
+
header: "Limit",
|
|
92
|
+
width: "28%",
|
|
93
|
+
render: (row) => (
|
|
94
|
+
<div className="eth-admin-rate-limit__limit">
|
|
95
|
+
<strong>{row.name}</strong>
|
|
96
|
+
<span>{row.state.label}</span>
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
key: "current",
|
|
102
|
+
header: "Usage",
|
|
103
|
+
width: "34%",
|
|
104
|
+
render: (row) => (
|
|
105
|
+
<div className="eth-admin-rate-limit__usage">
|
|
106
|
+
<div className="eth-admin-rate-limit__usage-copy">
|
|
107
|
+
<span>{row.usageLabel}</span>
|
|
108
|
+
<strong>{row.percentLabel}</strong>
|
|
109
|
+
</div>
|
|
110
|
+
<span
|
|
111
|
+
className="eth-admin-rate-limit__meter"
|
|
112
|
+
role="progressbar"
|
|
113
|
+
aria-label={`${row.name} usage`}
|
|
114
|
+
aria-valuemin={0}
|
|
115
|
+
aria-valuemax={100}
|
|
116
|
+
aria-valuenow={Math.round(row.cappedPercent)}
|
|
117
|
+
aria-valuetext={
|
|
118
|
+
row.limit > 0
|
|
119
|
+
? `${formatPercent(row.percent)} used, ${formatNumber(row.current)} of ${formatNumber(
|
|
120
|
+
row.limit
|
|
121
|
+
)} requests`
|
|
122
|
+
: "No configured limit"
|
|
123
|
+
}
|
|
124
|
+
style={
|
|
125
|
+
{
|
|
126
|
+
"--eth-admin-rate-limit-usage": `${row.cappedPercent}%`
|
|
127
|
+
} as RateLimitMeterStyle
|
|
128
|
+
}
|
|
129
|
+
>
|
|
130
|
+
<span
|
|
131
|
+
className={[
|
|
132
|
+
"eth-admin-rate-limit__meter-fill",
|
|
133
|
+
`eth-admin-rate-limit__meter-fill--${row.state.tone}`
|
|
134
|
+
].join(" ")}
|
|
135
|
+
/>
|
|
136
|
+
</span>
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
key: "windowSeconds",
|
|
142
|
+
header: "Window",
|
|
143
|
+
width: "8rem",
|
|
144
|
+
render: (row) => (
|
|
145
|
+
<span className="eth-admin-rate-limit__number">{formatWindow(row.windowSeconds)}</span>
|
|
146
|
+
)
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
key: "limit",
|
|
150
|
+
header: "Configured limit",
|
|
151
|
+
width: "13rem",
|
|
152
|
+
render: (row) => (
|
|
153
|
+
<NumberInput
|
|
154
|
+
aria-label={`${row.name} configured limit`}
|
|
155
|
+
className="eth-admin-rate-limit__limit-input"
|
|
156
|
+
density={density}
|
|
157
|
+
hideLabel
|
|
158
|
+
min={0}
|
|
159
|
+
step={1}
|
|
160
|
+
readOnly={!onUpdate}
|
|
161
|
+
value={row.limit}
|
|
162
|
+
onChange={(event) => {
|
|
163
|
+
const nextLimit = Number(event.currentTarget.value);
|
|
164
|
+
if (Number.isFinite(nextLimit)) onUpdate?.(row.id, { limit: nextLimit });
|
|
165
|
+
}}
|
|
166
|
+
/>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<section
|
|
173
|
+
{...sectionProps}
|
|
174
|
+
aria-label={ariaLabel}
|
|
175
|
+
aria-labelledby={ariaLabelledBy ?? (ariaLabel ? undefined : headingId)}
|
|
176
|
+
className={["eth-admin-rate-limit", className].filter(Boolean).join(" ")}
|
|
177
|
+
data-eth-component="RateLimitPanel"
|
|
178
|
+
>
|
|
179
|
+
<header>
|
|
180
|
+
<div className="eth-admin-rate-limit__heading">
|
|
181
|
+
<h3 id={headingId}>{title}</h3>
|
|
182
|
+
{supportingText ? <p>{supportingText}</p> : null}
|
|
183
|
+
</div>
|
|
184
|
+
<div className="eth-admin-rate-limit__header-actions">
|
|
185
|
+
<Badge severity={statusSummary.severity}>{statusSummary.label}</Badge>
|
|
186
|
+
<ActionGroup actions={actions} />
|
|
187
|
+
</div>
|
|
188
|
+
</header>
|
|
189
|
+
<dl className="eth-admin-rate-limit__summary" aria-label="Rate limit totals">
|
|
190
|
+
<div>
|
|
191
|
+
<dt>Tracked limits</dt>
|
|
192
|
+
<dd>{formatNumber(limits.length)}</dd>
|
|
193
|
+
</div>
|
|
194
|
+
<div>
|
|
195
|
+
<dt>Highest usage</dt>
|
|
196
|
+
<dd>
|
|
197
|
+
{highestUsage ? `${highestUsage.name} ${formatPercent(highestUsage.percent)}` : "n/a"}
|
|
198
|
+
</dd>
|
|
199
|
+
</div>
|
|
200
|
+
<div>
|
|
201
|
+
<dt>Needs review</dt>
|
|
202
|
+
<dd>{formatNumber(exceededCount + nearLimitCount)}</dd>
|
|
203
|
+
</div>
|
|
204
|
+
</dl>
|
|
205
|
+
<DataTable
|
|
206
|
+
rows={rows}
|
|
207
|
+
columns={columns}
|
|
208
|
+
rowKey="id"
|
|
209
|
+
density={density}
|
|
210
|
+
className="eth-admin-rate-limit__table"
|
|
211
|
+
emptyState={<p className="eth-admin-rate-limit__empty">No rate limits configured.</p>}
|
|
212
|
+
/>
|
|
213
|
+
</section>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function rateLimitState(percent: number, limit: number): RateLimitState {
|
|
218
|
+
if (limit <= 0) return { label: "No limit", severity: "neutral", tone: "neutral" };
|
|
219
|
+
if (percent >= 100) return { label: "Exceeded", severity: "danger", tone: "danger" };
|
|
220
|
+
if (percent >= 80) return { label: "Near limit", severity: "warning", tone: "warning" };
|
|
221
|
+
return { label: "Within limit", severity: "success", tone: "normal" };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function rateLimitStatusSummary(
|
|
225
|
+
totalCount: number,
|
|
226
|
+
exceededCount: number,
|
|
227
|
+
nearLimitCount: number
|
|
228
|
+
): Pick<RateLimitState, "label" | "severity"> {
|
|
229
|
+
if (totalCount === 0) return { label: "No limits", severity: "neutral" };
|
|
230
|
+
if (exceededCount > 0) {
|
|
231
|
+
return { label: `${formatNumber(exceededCount)} exceeded`, severity: "danger" };
|
|
232
|
+
}
|
|
233
|
+
if (nearLimitCount > 0) {
|
|
234
|
+
return { label: `${formatNumber(nearLimitCount)} near limit`, severity: "warning" };
|
|
235
|
+
}
|
|
236
|
+
return { label: "Within limits", severity: "success" };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function formatWindow(seconds: number) {
|
|
240
|
+
return `${formatNumber(seconds)}s`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function formatPercent(value: number) {
|
|
244
|
+
return `${Math.round(value).toLocaleString("en-US")}%`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function formatNumber(value: number) {
|
|
248
|
+
return new Intl.NumberFormat("en-US").format(value);
|
|
249
|
+
}
|