@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 type { CSSProperties } from "react";
|
|
2
|
+
import { Badge, Surface, type EthSeverity, type SurfaceComponentProps } from "@echothink-ui/core";
|
|
3
|
+
import type { BillingUsageMetric } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface BillingUsagePanelProps extends Omit<SurfaceComponentProps, "children"> {
|
|
6
|
+
metrics: BillingUsageMetric[];
|
|
7
|
+
period: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type UsageTone = "normal" | "warning" | "danger" | "neutral";
|
|
11
|
+
|
|
12
|
+
interface UsageState {
|
|
13
|
+
label: string;
|
|
14
|
+
severity: EthSeverity;
|
|
15
|
+
tone: UsageTone;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type UsageMeterStyle = CSSProperties & {
|
|
19
|
+
"--eth-admin-billing-usage-percent": string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function BillingUsagePanel({ metrics, period, title, className, ...props }: BillingUsagePanelProps) {
|
|
23
|
+
const entries = metrics.map((metric) => {
|
|
24
|
+
const percent = metric.limit > 0 ? (metric.current / metric.limit) * 100 : 0;
|
|
25
|
+
const roundedPercent = Math.round(percent);
|
|
26
|
+
const cappedPercent = Math.min(100, Math.max(0, percent));
|
|
27
|
+
const state = usageState(percent, metric.limit);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
metric,
|
|
31
|
+
percent,
|
|
32
|
+
roundedPercent,
|
|
33
|
+
cappedPercent,
|
|
34
|
+
state,
|
|
35
|
+
trendLabel: formatTrend(metric.trend),
|
|
36
|
+
usageLabel:
|
|
37
|
+
metric.limit > 0
|
|
38
|
+
? `${formatNumber(metric.current)} / ${formatNumber(metric.limit)} ${formatUnit(metric.unit, metric.limit)}`
|
|
39
|
+
: `${formatNumber(metric.current)} ${formatUnit(metric.unit, metric.current)}`
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const highestUsage = entries.reduce<(typeof entries)[number] | undefined>((highest, entry) => {
|
|
44
|
+
if (!highest || entry.percent > highest.percent) return entry;
|
|
45
|
+
return highest;
|
|
46
|
+
}, undefined);
|
|
47
|
+
const overLimitCount = entries.filter((entry) => entry.percent >= 100).length;
|
|
48
|
+
const watchedCount = entries.filter((entry) => entry.percent >= 80 && entry.percent < 100).length;
|
|
49
|
+
const quotaStatus = getQuotaStatus(overLimitCount, watchedCount);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Surface
|
|
53
|
+
{...props}
|
|
54
|
+
title={title ?? "Billing usage"}
|
|
55
|
+
subtitle={period}
|
|
56
|
+
className={["eth-admin-billing-usage", className].filter(Boolean).join(" ")}
|
|
57
|
+
data-eth-component="BillingUsagePanel"
|
|
58
|
+
>
|
|
59
|
+
{entries.length ? (
|
|
60
|
+
<>
|
|
61
|
+
<dl className="eth-admin-billing-usage__summary" aria-label="Billing usage summary">
|
|
62
|
+
<div>
|
|
63
|
+
<dt>Tracked quotas</dt>
|
|
64
|
+
<dd>{entries.length}</dd>
|
|
65
|
+
</div>
|
|
66
|
+
<div>
|
|
67
|
+
<dt>Highest usage</dt>
|
|
68
|
+
<dd>
|
|
69
|
+
{highestUsage ? `${highestUsage.metric.label} ${highestUsage.roundedPercent}%` : "n/a"}
|
|
70
|
+
</dd>
|
|
71
|
+
</div>
|
|
72
|
+
<div>
|
|
73
|
+
<dt>Quota status</dt>
|
|
74
|
+
<dd>
|
|
75
|
+
<Badge severity={quotaStatus.severity}>{quotaStatus.label}</Badge>
|
|
76
|
+
</dd>
|
|
77
|
+
</div>
|
|
78
|
+
</dl>
|
|
79
|
+
|
|
80
|
+
<div className="eth-admin-billing-usage__metrics">
|
|
81
|
+
{entries.map(({ metric, roundedPercent, cappedPercent, state, trendLabel, usageLabel }) => (
|
|
82
|
+
<section key={`${metric.label}-${metric.unit}`} className="eth-admin-billing-usage__metric">
|
|
83
|
+
<div className="eth-admin-billing-usage__metric-header">
|
|
84
|
+
<div>
|
|
85
|
+
<h3>{metric.label}</h3>
|
|
86
|
+
<p>{usageLabel}</p>
|
|
87
|
+
</div>
|
|
88
|
+
<Badge severity={state.severity}>{state.label}</Badge>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div
|
|
92
|
+
className="eth-admin-billing-usage__meter"
|
|
93
|
+
role="progressbar"
|
|
94
|
+
aria-label={`${metric.label} quota usage`}
|
|
95
|
+
aria-valuemin={0}
|
|
96
|
+
aria-valuemax={100}
|
|
97
|
+
aria-valuenow={Math.round(cappedPercent)}
|
|
98
|
+
aria-valuetext={
|
|
99
|
+
metric.limit > 0 ? `${roundedPercent}% of quota used` : "No quota limit configured"
|
|
100
|
+
}
|
|
101
|
+
>
|
|
102
|
+
<span
|
|
103
|
+
className={`eth-admin-billing-usage__meter-fill eth-admin-billing-usage__meter-fill--${state.tone}`}
|
|
104
|
+
style={
|
|
105
|
+
{
|
|
106
|
+
"--eth-admin-billing-usage-percent": `${cappedPercent}%`
|
|
107
|
+
} as UsageMeterStyle
|
|
108
|
+
}
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<dl className="eth-admin-billing-usage__metric-details">
|
|
113
|
+
<div>
|
|
114
|
+
<dt>Utilization</dt>
|
|
115
|
+
<dd>{metric.limit > 0 ? `${roundedPercent}%` : "No limit"}</dd>
|
|
116
|
+
</div>
|
|
117
|
+
<div>
|
|
118
|
+
<dt>Limit</dt>
|
|
119
|
+
<dd>
|
|
120
|
+
{metric.limit > 0
|
|
121
|
+
? `${formatNumber(metric.limit)} ${formatUnit(metric.unit, metric.limit)}`
|
|
122
|
+
: "Unlimited"}
|
|
123
|
+
</dd>
|
|
124
|
+
</div>
|
|
125
|
+
{trendLabel ? (
|
|
126
|
+
<div>
|
|
127
|
+
<dt>Change</dt>
|
|
128
|
+
<dd>{trendLabel}</dd>
|
|
129
|
+
</div>
|
|
130
|
+
) : null}
|
|
131
|
+
</dl>
|
|
132
|
+
</section>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
</>
|
|
136
|
+
) : (
|
|
137
|
+
<p className="eth-admin-billing-usage__empty">No usage metrics are available for this period.</p>
|
|
138
|
+
)}
|
|
139
|
+
</Surface>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function usageState(percent: number, limit: number): UsageState {
|
|
144
|
+
if (limit <= 0) return { label: "No limit", severity: "neutral", tone: "neutral" };
|
|
145
|
+
if (percent >= 100) return { label: "Over limit", severity: "danger", tone: "danger" };
|
|
146
|
+
if (percent >= 80) return { label: "Watch", severity: "warning", tone: "warning" };
|
|
147
|
+
return { label: "Within limit", severity: "success", tone: "normal" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getQuotaStatus(overLimitCount: number, watchedCount: number): Pick<UsageState, "label" | "severity"> {
|
|
151
|
+
if (overLimitCount > 0) return { label: `${overLimitCount} over limit`, severity: "danger" };
|
|
152
|
+
if (watchedCount > 0) return { label: `${watchedCount} nearing limit`, severity: "warning" };
|
|
153
|
+
return { label: "Within limits", severity: "success" };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function formatNumber(value: number) {
|
|
157
|
+
return new Intl.NumberFormat("en-US", {
|
|
158
|
+
maximumFractionDigits: value % 1 === 0 ? 0 : 2
|
|
159
|
+
}).format(value);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function formatUnit(unit: string, value: number) {
|
|
163
|
+
if (unit.toLowerCase() === "user") return value === 1 ? "user" : "users";
|
|
164
|
+
return unit;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function formatTrend(trend?: number) {
|
|
168
|
+
if (trend === undefined) return undefined;
|
|
169
|
+
if (trend === 0) return "Unchanged";
|
|
170
|
+
return `${trend > 0 ? "+" : ""}${trend}% vs previous period`;
|
|
171
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
ActionGroup,
|
|
4
|
+
Badge,
|
|
5
|
+
NumberInput,
|
|
6
|
+
Toggle,
|
|
7
|
+
type SurfaceComponentProps
|
|
8
|
+
} from "@echothink-ui/core";
|
|
9
|
+
import { DataTable, type DataColumn } from "@echothink-ui/data";
|
|
10
|
+
import type { FeatureFlag } from "./types";
|
|
11
|
+
|
|
12
|
+
export interface FeatureFlagPanelProps extends Omit<
|
|
13
|
+
SurfaceComponentProps,
|
|
14
|
+
"children" | "onToggle"
|
|
15
|
+
> {
|
|
16
|
+
flags: FeatureFlag[];
|
|
17
|
+
onToggle?: (id: string) => void;
|
|
18
|
+
onUpdate?: (id: string, update: Partial<FeatureFlag>) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type FeatureFlagRow = Record<string, unknown> & FeatureFlag;
|
|
22
|
+
type RolloutMeterStyle = React.CSSProperties & {
|
|
23
|
+
"--eth-admin-feature-flag-rollout": string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function FeatureFlagPanel({
|
|
27
|
+
flags,
|
|
28
|
+
onToggle,
|
|
29
|
+
onUpdate,
|
|
30
|
+
className,
|
|
31
|
+
title = "Feature flags",
|
|
32
|
+
subtitle,
|
|
33
|
+
description,
|
|
34
|
+
actions,
|
|
35
|
+
density = "compact",
|
|
36
|
+
"aria-label": ariaLabel,
|
|
37
|
+
"aria-labelledby": ariaLabelledBy,
|
|
38
|
+
eyebrow: _eyebrow,
|
|
39
|
+
status: _status,
|
|
40
|
+
severity: _severity,
|
|
41
|
+
loading: _loading,
|
|
42
|
+
empty: _empty,
|
|
43
|
+
error: _error,
|
|
44
|
+
items: _items,
|
|
45
|
+
metadata: _metadata,
|
|
46
|
+
footer: _footer,
|
|
47
|
+
...sectionProps
|
|
48
|
+
}: FeatureFlagPanelProps) {
|
|
49
|
+
const headingId = React.useId();
|
|
50
|
+
const enabledCount = flags.filter((flag) => flag.enabled).length;
|
|
51
|
+
const disabledCount = flags.length - enabledCount;
|
|
52
|
+
const rows: FeatureFlagRow[] = flags.map((flag) => ({ ...flag }));
|
|
53
|
+
const columns: DataColumn<FeatureFlagRow>[] = [
|
|
54
|
+
{
|
|
55
|
+
key: "name",
|
|
56
|
+
header: "Flag",
|
|
57
|
+
width: "34%",
|
|
58
|
+
render: (row) => <span className="eth-admin-feature-flag__name">{row.name}</span>
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: "enabled",
|
|
62
|
+
header: "Status",
|
|
63
|
+
width: "13rem",
|
|
64
|
+
render: (row) => (
|
|
65
|
+
<div className="eth-admin-feature-flag__status">
|
|
66
|
+
<Toggle
|
|
67
|
+
label={`${row.name} is ${row.enabled ? "enabled" : "disabled"}`}
|
|
68
|
+
hideLabel
|
|
69
|
+
onLabel=""
|
|
70
|
+
offLabel=""
|
|
71
|
+
checked={row.enabled}
|
|
72
|
+
readOnly={!onToggle}
|
|
73
|
+
density={density}
|
|
74
|
+
onChange={() => onToggle?.(row.id)}
|
|
75
|
+
/>
|
|
76
|
+
<Badge severity={row.enabled ? "success" : "neutral"}>
|
|
77
|
+
{row.enabled ? "Enabled" : "Disabled"}
|
|
78
|
+
</Badge>
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
key: "rolloutPercent",
|
|
84
|
+
header: "Rollout",
|
|
85
|
+
width: "18rem",
|
|
86
|
+
render: (row) => (
|
|
87
|
+
<div className="eth-admin-feature-flag__rollout">
|
|
88
|
+
<NumberInput
|
|
89
|
+
aria-label={`${row.name} rollout percent`}
|
|
90
|
+
className="eth-admin-feature-flag__rollout-input"
|
|
91
|
+
density={density}
|
|
92
|
+
hideLabel
|
|
93
|
+
min={0}
|
|
94
|
+
max={100}
|
|
95
|
+
step={1}
|
|
96
|
+
readOnly={!onUpdate}
|
|
97
|
+
value={normalizeRolloutPercent(row.rolloutPercent)}
|
|
98
|
+
onChange={(event) =>
|
|
99
|
+
onUpdate?.(row.id, {
|
|
100
|
+
rolloutPercent: normalizeRolloutPercent(Number(event.currentTarget.value))
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
/>
|
|
104
|
+
<span className="eth-admin-feature-flag__rollout-unit" aria-hidden="true">
|
|
105
|
+
%
|
|
106
|
+
</span>
|
|
107
|
+
<span className="eth-admin-feature-flag__meter" aria-hidden="true">
|
|
108
|
+
<span
|
|
109
|
+
style={
|
|
110
|
+
{
|
|
111
|
+
"--eth-admin-feature-flag-rollout": `${normalizeRolloutPercent(row.rolloutPercent)}%`
|
|
112
|
+
} as RolloutMeterStyle
|
|
113
|
+
}
|
|
114
|
+
/>
|
|
115
|
+
</span>
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
key: "environments",
|
|
121
|
+
header: "Environments",
|
|
122
|
+
render: (row) =>
|
|
123
|
+
row.environments?.length ? (
|
|
124
|
+
<div className="eth-admin-feature-flag__environments">
|
|
125
|
+
{row.environments.map((environment) => (
|
|
126
|
+
<Badge key={environment} severity="neutral">
|
|
127
|
+
{environment}
|
|
128
|
+
</Badge>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
) : (
|
|
132
|
+
<Badge severity="neutral">All environments</Badge>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
];
|
|
136
|
+
const supportingText = subtitle ?? description;
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<section
|
|
140
|
+
{...sectionProps}
|
|
141
|
+
aria-label={ariaLabel}
|
|
142
|
+
aria-labelledby={ariaLabelledBy ?? (ariaLabel ? undefined : headingId)}
|
|
143
|
+
className={["eth-admin-feature-flag", className].filter(Boolean).join(" ")}
|
|
144
|
+
data-eth-component="FeatureFlagPanel"
|
|
145
|
+
>
|
|
146
|
+
<header>
|
|
147
|
+
<div className="eth-admin-feature-flag__heading">
|
|
148
|
+
<h3 id={headingId}>{title}</h3>
|
|
149
|
+
{supportingText ? <p>{supportingText}</p> : null}
|
|
150
|
+
</div>
|
|
151
|
+
<div className="eth-admin-feature-flag__header-actions">
|
|
152
|
+
<div
|
|
153
|
+
className="eth-admin-feature-flag__summary"
|
|
154
|
+
aria-label={`${flags.length} feature flags`}
|
|
155
|
+
>
|
|
156
|
+
<Badge severity="success">{enabledCount} enabled</Badge>
|
|
157
|
+
<Badge severity="neutral">{disabledCount} disabled</Badge>
|
|
158
|
+
</div>
|
|
159
|
+
<ActionGroup actions={actions} />
|
|
160
|
+
</div>
|
|
161
|
+
</header>
|
|
162
|
+
<DataTable
|
|
163
|
+
rows={rows}
|
|
164
|
+
columns={columns}
|
|
165
|
+
density={density}
|
|
166
|
+
rowKey="id"
|
|
167
|
+
className="eth-admin-feature-flag__table"
|
|
168
|
+
emptyState={<p className="eth-admin-feature-flag__empty">No feature flags configured.</p>}
|
|
169
|
+
/>
|
|
170
|
+
</section>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeRolloutPercent(value: number | undefined) {
|
|
175
|
+
const numericValue = value ?? 0;
|
|
176
|
+
if (!Number.isFinite(numericValue)) return 0;
|
|
177
|
+
return Math.min(100, Math.max(0, Math.round(numericValue)));
|
|
178
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
ActionGroup,
|
|
4
|
+
Badge,
|
|
5
|
+
type EthSeverity,
|
|
6
|
+
type SurfaceComponentProps
|
|
7
|
+
} from "@echothink-ui/core";
|
|
8
|
+
import { DataTable, type DataColumn } from "@echothink-ui/data";
|
|
9
|
+
import type { IntegrationHealth } from "./types";
|
|
10
|
+
|
|
11
|
+
export interface IntegrationHealthTableProps extends Omit<SurfaceComponentProps, "children"> {
|
|
12
|
+
integrations: IntegrationHealth[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type IntegrationHealthRow = Record<string, unknown> & IntegrationHealth;
|
|
16
|
+
|
|
17
|
+
export function IntegrationHealthTable({
|
|
18
|
+
integrations,
|
|
19
|
+
className,
|
|
20
|
+
title = "Integration health",
|
|
21
|
+
subtitle,
|
|
22
|
+
description,
|
|
23
|
+
actions,
|
|
24
|
+
density = "compact",
|
|
25
|
+
"aria-label": ariaLabel,
|
|
26
|
+
"aria-labelledby": ariaLabelledBy,
|
|
27
|
+
eyebrow: _eyebrow,
|
|
28
|
+
status: _status,
|
|
29
|
+
severity: _severity,
|
|
30
|
+
loading: _loading,
|
|
31
|
+
empty: _empty,
|
|
32
|
+
error: _error,
|
|
33
|
+
items: _items,
|
|
34
|
+
metadata: _metadata,
|
|
35
|
+
footer: _footer,
|
|
36
|
+
...sectionProps
|
|
37
|
+
}: IntegrationHealthTableProps) {
|
|
38
|
+
const headingId = React.useId();
|
|
39
|
+
const incidentCount = integrations.reduce(
|
|
40
|
+
(sum, integration) => sum + (integration.incidentsCount ?? 0),
|
|
41
|
+
0
|
|
42
|
+
);
|
|
43
|
+
const impairedCount = integrations.filter(
|
|
44
|
+
(integration) => integration.status !== "healthy"
|
|
45
|
+
).length;
|
|
46
|
+
const healthyCount = integrations.length - impairedCount;
|
|
47
|
+
const rows: IntegrationHealthRow[] = integrations.map((integration) => ({ ...integration }));
|
|
48
|
+
const columns: DataColumn<IntegrationHealthRow>[] = [
|
|
49
|
+
{
|
|
50
|
+
key: "name",
|
|
51
|
+
header: "Integration",
|
|
52
|
+
width: "32%",
|
|
53
|
+
render: (row) => (
|
|
54
|
+
<span className="eth-admin-integration-health__name">
|
|
55
|
+
<strong>{row.name}</strong>
|
|
56
|
+
<span>{row.id}</span>
|
|
57
|
+
</span>
|
|
58
|
+
)
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: "status",
|
|
62
|
+
header: "Status",
|
|
63
|
+
width: "9rem",
|
|
64
|
+
render: (row) => (
|
|
65
|
+
<Badge severity={statusSeverity(row.status)}>{statusLabel(row.status)}</Badge>
|
|
66
|
+
)
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: "latencyMs",
|
|
70
|
+
header: "Latency",
|
|
71
|
+
width: "8rem",
|
|
72
|
+
align: "end",
|
|
73
|
+
render: (row) => (
|
|
74
|
+
<span className={latencyMetricClassName(row.latencyMs)}>
|
|
75
|
+
{formatLatency(row.latencyMs)}
|
|
76
|
+
</span>
|
|
77
|
+
)
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
key: "incidentsCount",
|
|
81
|
+
header: "Incidents",
|
|
82
|
+
width: "8rem",
|
|
83
|
+
align: "end",
|
|
84
|
+
render: (row) => (
|
|
85
|
+
<Badge severity={incidentSeverity(row.incidentsCount)}>
|
|
86
|
+
{formatIncidentCount(row.incidentsCount)}
|
|
87
|
+
</Badge>
|
|
88
|
+
)
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
key: "lastCheckedAt",
|
|
92
|
+
header: "Last checked",
|
|
93
|
+
width: "10rem",
|
|
94
|
+
render: (row) => (
|
|
95
|
+
<span className="eth-admin-integration-health__checked">
|
|
96
|
+
{row.lastCheckedAt ?? "Not checked"}
|
|
97
|
+
</span>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
];
|
|
101
|
+
const supportingText = subtitle ?? description;
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<section
|
|
105
|
+
{...sectionProps}
|
|
106
|
+
aria-label={ariaLabel}
|
|
107
|
+
aria-labelledby={ariaLabelledBy ?? (ariaLabel ? undefined : headingId)}
|
|
108
|
+
className={["eth-admin-integration-health", className].filter(Boolean).join(" ")}
|
|
109
|
+
data-eth-component="IntegrationHealthTable"
|
|
110
|
+
>
|
|
111
|
+
<header>
|
|
112
|
+
<div className="eth-admin-integration-health__heading">
|
|
113
|
+
<h3 id={headingId}>{title}</h3>
|
|
114
|
+
{supportingText ? <p>{supportingText}</p> : null}
|
|
115
|
+
</div>
|
|
116
|
+
<div className="eth-admin-integration-health__header-actions">
|
|
117
|
+
<div
|
|
118
|
+
className="eth-admin-integration-health__summary"
|
|
119
|
+
aria-label={summaryLabel(rows.length, incidentCount, healthyCount)}
|
|
120
|
+
>
|
|
121
|
+
<Badge severity={!rows.length ? "neutral" : impairedCount ? "warning" : "success"}>
|
|
122
|
+
{formatHealthySummary(rows.length, healthyCount)}
|
|
123
|
+
</Badge>
|
|
124
|
+
<Badge severity={incidentCount ? "danger" : "neutral"}>
|
|
125
|
+
{incidentCount} {incidentCount === 1 ? "incident" : "incidents"}
|
|
126
|
+
</Badge>
|
|
127
|
+
</div>
|
|
128
|
+
<ActionGroup actions={actions} />
|
|
129
|
+
</div>
|
|
130
|
+
</header>
|
|
131
|
+
<DataTable
|
|
132
|
+
rows={rows}
|
|
133
|
+
columns={columns}
|
|
134
|
+
density={density}
|
|
135
|
+
rowKey="id"
|
|
136
|
+
className="eth-admin-integration-health__table"
|
|
137
|
+
emptyState={
|
|
138
|
+
<p className="eth-admin-integration-health__empty">No integrations are reporting.</p>
|
|
139
|
+
}
|
|
140
|
+
/>
|
|
141
|
+
</section>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function statusSeverity(status: IntegrationHealth["status"]): EthSeverity {
|
|
146
|
+
if (status === "healthy") return "success";
|
|
147
|
+
if (status === "degraded") return "warning";
|
|
148
|
+
return "danger";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function statusLabel(status: IntegrationHealth["status"]) {
|
|
152
|
+
if (status === "down") return "Down";
|
|
153
|
+
if (status === "degraded") return "Degraded";
|
|
154
|
+
return "Healthy";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function latencySeverity(latencyMs: IntegrationHealth["latencyMs"]): EthSeverity | null {
|
|
158
|
+
if (latencyMs === undefined) return null;
|
|
159
|
+
if (latencyMs >= 1000) return "danger";
|
|
160
|
+
if (latencyMs >= 500) return "warning";
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function latencyMetricClassName(latencyMs: IntegrationHealth["latencyMs"]) {
|
|
165
|
+
const severity = latencySeverity(latencyMs);
|
|
166
|
+
return [
|
|
167
|
+
"eth-admin-integration-health__metric",
|
|
168
|
+
severity ? `eth-admin-integration-health__metric--${severity}` : undefined
|
|
169
|
+
]
|
|
170
|
+
.filter(Boolean)
|
|
171
|
+
.join(" ");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function incidentSeverity(count: IntegrationHealth["incidentsCount"]): EthSeverity {
|
|
175
|
+
if (!count) return "success";
|
|
176
|
+
return count > 1 ? "danger" : "warning";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function formatLatency(latencyMs: IntegrationHealth["latencyMs"]) {
|
|
180
|
+
return latencyMs === undefined ? "n/a" : `${latencyMs} ms`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function formatIncidentCount(count: IntegrationHealth["incidentsCount"]) {
|
|
184
|
+
return String(count ?? 0);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function formatHealthySummary(total: number, healthyCount: number) {
|
|
188
|
+
return total ? `${healthyCount}/${total} healthy` : "0 integrations";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function summaryLabel(total: number, incidentCount: number, healthyCount: number) {
|
|
192
|
+
if (!total) return `No integrations reporting, ${incidentCount} open incidents`;
|
|
193
|
+
return `${healthyCount} of ${total} integrations healthy, ${incidentCount} open ${
|
|
194
|
+
incidentCount === 1 ? "incident" : "incidents"
|
|
195
|
+
}`;
|
|
196
|
+
}
|