@flanksource/clicky-ui 0.2.5 → 0.2.6

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.
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const jsxRuntime = require("react/jsx-runtime");
4
+ require("react");
5
+ const utils = require("../lib/utils.cjs");
6
+ const button = require("../components/button.cjs");
7
+ function segment(key, label, count, className) {
8
+ return { key, label, count: Math.max(0, count || 0), className };
9
+ }
10
+ const defaultRenderLink = ({ to, className, title, children }) => /* @__PURE__ */ jsxRuntime.jsx("a", { href: to, className, title, children });
11
+ function StackedStatusBar({
12
+ segments,
13
+ ariaLabel
14
+ }) {
15
+ const visible = segments.filter((s) => s.count > 0);
16
+ const total = visible.reduce((sum, s) => sum + s.count, 0);
17
+ if (visible.length === 0 || total <= 0) {
18
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rounded border border-dashed p-3 text-xs text-muted-foreground", children: "No status data" });
19
+ }
20
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex h-2 overflow-hidden rounded bg-muted", "aria-label": ariaLabel, children: visible.map((s) => /* @__PURE__ */ jsxRuntime.jsx(
21
+ "div",
22
+ {
23
+ className: s.className,
24
+ title: `${s.label}: ${s.count}`,
25
+ style: { width: `${Math.max(1, s.count / total * 100)}%` }
26
+ },
27
+ s.key
28
+ )) });
29
+ }
30
+ function StatusRows({
31
+ segments,
32
+ ariaLabel,
33
+ onRetry,
34
+ retryingKey,
35
+ isRetryable,
36
+ renderLink = defaultRenderLink
37
+ }) {
38
+ const visible = segments.filter((s) => s.count > 0);
39
+ const total = visible.reduce((sum, s) => sum + s.count, 0);
40
+ if (visible.length === 0 || total <= 0) {
41
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rounded border border-dashed p-3 text-xs text-muted-foreground", children: "No status data" });
42
+ }
43
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "space-y-2", "aria-label": ariaLabel, children: visible.map((s) => {
44
+ const retryable = !!onRetry && ((isRetryable == null ? void 0 : isRetryable(s)) ?? false);
45
+ const meta = /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-w-0 flex-1 items-center gap-2", children: [
46
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: utils.cn("h-2.5 w-2.5 shrink-0 rounded-sm", s.className), "aria-hidden": "true" }),
47
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate text-xs", children: s.label }),
48
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "ml-auto shrink-0 font-mono text-xs text-muted-foreground", children: s.count })
49
+ ] });
50
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-3", children: [
51
+ s.href ? renderLink({
52
+ to: s.href,
53
+ className: "flex min-w-0 flex-1 rounded hover:bg-accent/40",
54
+ title: `View ${s.label} records`,
55
+ children: meta
56
+ }) : meta,
57
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "h-2 w-32 shrink-0 overflow-hidden rounded bg-muted sm:w-48", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: utils.cn("h-full", s.className), style: { width: `${Math.max(1, s.count / total * 100)}%` } }) }),
58
+ retryable ? /* @__PURE__ */ jsxRuntime.jsx(
59
+ button.Button,
60
+ {
61
+ type: "button",
62
+ variant: "outline",
63
+ size: "sm",
64
+ className: "h-6 shrink-0 px-2 text-[11px]",
65
+ disabled: retryingKey === s.key,
66
+ onClick: (e) => {
67
+ e.preventDefault();
68
+ e.stopPropagation();
69
+ onRetry == null ? void 0 : onRetry(s);
70
+ },
71
+ children: retryingKey === s.key ? "Retrying…" : "Retry"
72
+ }
73
+ ) : null
74
+ ] }, s.key);
75
+ }) });
76
+ }
77
+ exports.StackedStatusBar = StackedStatusBar;
78
+ exports.StatusRows = StatusRows;
79
+ exports.segment = segment;
80
+ //# sourceMappingURL=StatusBreakdown.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"StatusBreakdown.cjs","sources":["../../src/data/StatusBreakdown.tsx"],"sourcesContent":["import { type ReactElement, type ReactNode } from \"react\";\nimport { cn } from \"../lib/utils\";\nimport { Button } from \"../components/button\";\n\n// A status-breakdown family: one StatusSegment list rendered either as a single\n// horizontal StackedStatusBar (scan a mix at a glance) or as vertical StatusRows\n// (each status on its own line with a proportional bar, optional drill-down link\n// and retry). Shared by monitor/intake/cycle breakdowns and any panel that shows\n// a count mix.\n\nexport interface StatusSegment {\n key: string;\n label: string;\n count: number;\n /** Tailwind background class for the swatch/bar fill, e.g. \"bg-green-500\". */\n className: string;\n /**\n * When set, the row links here (the breakdown points each status at its\n * filtered records/members view). Rendered via the `renderLink` prop so the\n * component stays router-agnostic.\n */\n href?: string;\n}\n\n/** Builds a StatusSegment, clamping the count to a non-negative integer. */\nexport function segment(key: string, label: string, count: number, className: string): StatusSegment {\n return { key, label, count: Math.max(0, count || 0), className };\n}\n\n/** Render-prop for a status row's link, so callers supply their router's Link (or a plain <a>). */\nexport type StatusRenderLink = (args: {\n to: string;\n className?: string;\n title?: string;\n children: ReactNode;\n}) => ReactElement;\n\nconst defaultRenderLink: StatusRenderLink = ({ to, className, title, children }) => (\n <a href={to} className={className} title={title}>\n {children}\n </a>\n);\n\n// StackedStatusBar renders the segments as a single horizontal multi-color bar —\n// segments share one track sized proportionally to the visible-segment total.\nexport function StackedStatusBar({\n segments,\n ariaLabel,\n}: {\n segments: StatusSegment[];\n ariaLabel?: string;\n}) {\n const visible = segments.filter((s) => s.count > 0);\n const total = visible.reduce((sum, s) => sum + s.count, 0);\n if (visible.length === 0 || total <= 0) {\n return <div className=\"rounded border border-dashed p-3 text-xs text-muted-foreground\">No status data</div>;\n }\n return (\n <div className=\"flex h-2 overflow-hidden rounded bg-muted\" aria-label={ariaLabel}>\n {visible.map((s) => (\n <div\n key={s.key}\n className={s.className}\n title={`${s.label}: ${s.count}`}\n style={{ width: `${Math.max(1, (s.count / total) * 100)}%` }}\n />\n ))}\n </div>\n );\n}\n\n// StatusRows renders each status as its own row — a colored swatch + label, the\n// count, and a horizontal bar sized proportionally to the visible-segment total.\n// The vertical counterpart to StackedStatusBar. An optional onRetry surfaces a\n// Retry button per row; callers that omit it get no button. When a segment\n// carries an href the whole row links there via renderLink (defaults to a plain\n// <a>); the Retry button stops propagation so it stays independently clickable.\nexport function StatusRows({\n segments,\n ariaLabel,\n onRetry,\n retryingKey,\n isRetryable,\n renderLink = defaultRenderLink,\n}: {\n segments: StatusSegment[];\n ariaLabel?: string;\n onRetry?: (segment: StatusSegment) => void;\n retryingKey?: string;\n isRetryable?: (segment: StatusSegment) => boolean;\n renderLink?: StatusRenderLink;\n}) {\n const visible = segments.filter((s) => s.count > 0);\n const total = visible.reduce((sum, s) => sum + s.count, 0);\n if (visible.length === 0 || total <= 0) {\n return <div className=\"rounded border border-dashed p-3 text-xs text-muted-foreground\">No status data</div>;\n }\n return (\n <div className=\"space-y-2\" aria-label={ariaLabel}>\n {visible.map((s) => {\n const retryable = !!onRetry && (isRetryable?.(s) ?? false);\n const meta = (\n <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n <span className={cn(\"h-2.5 w-2.5 shrink-0 rounded-sm\", s.className)} aria-hidden=\"true\" />\n <span className=\"truncate text-xs\">{s.label}</span>\n <span className=\"ml-auto shrink-0 font-mono text-xs text-muted-foreground\">{s.count}</span>\n </div>\n );\n return (\n <div key={s.key} className=\"flex items-center gap-3\">\n {s.href\n ? renderLink({\n to: s.href,\n className: \"flex min-w-0 flex-1 rounded hover:bg-accent/40\",\n title: `View ${s.label} records`,\n children: meta,\n })\n : meta}\n <div className=\"h-2 w-32 shrink-0 overflow-hidden rounded bg-muted sm:w-48\">\n <div className={cn(\"h-full\", s.className)} style={{ width: `${Math.max(1, (s.count / total) * 100)}%` }} />\n </div>\n {retryable ? (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n className=\"h-6 shrink-0 px-2 text-[11px]\"\n disabled={retryingKey === s.key}\n onClick={(e) => {\n e.preventDefault();\n e.stopPropagation();\n onRetry?.(s);\n }}\n >\n {retryingKey === s.key ? \"Retrying…\" : \"Retry\"}\n </Button>\n ) : null}\n </div>\n );\n })}\n </div>\n );\n}\n"],"names":["jsx","jsxs","cn","Button"],"mappings":"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"}
@@ -0,0 +1,36 @@
1
+ import { ReactElement, ReactNode } from 'react';
2
+ export interface StatusSegment {
3
+ key: string;
4
+ label: string;
5
+ count: number;
6
+ /** Tailwind background class for the swatch/bar fill, e.g. "bg-green-500". */
7
+ className: string;
8
+ /**
9
+ * When set, the row links here (the breakdown points each status at its
10
+ * filtered records/members view). Rendered via the `renderLink` prop so the
11
+ * component stays router-agnostic.
12
+ */
13
+ href?: string;
14
+ }
15
+ /** Builds a StatusSegment, clamping the count to a non-negative integer. */
16
+ export declare function segment(key: string, label: string, count: number, className: string): StatusSegment;
17
+ /** Render-prop for a status row's link, so callers supply their router's Link (or a plain <a>). */
18
+ export type StatusRenderLink = (args: {
19
+ to: string;
20
+ className?: string;
21
+ title?: string;
22
+ children: ReactNode;
23
+ }) => ReactElement;
24
+ export declare function StackedStatusBar({ segments, ariaLabel, }: {
25
+ segments: StatusSegment[];
26
+ ariaLabel?: string;
27
+ }): import("react/jsx-runtime").JSX.Element;
28
+ export declare function StatusRows({ segments, ariaLabel, onRetry, retryingKey, isRetryable, renderLink, }: {
29
+ segments: StatusSegment[];
30
+ ariaLabel?: string;
31
+ onRetry?: (segment: StatusSegment) => void;
32
+ retryingKey?: string;
33
+ isRetryable?: (segment: StatusSegment) => boolean;
34
+ renderLink?: StatusRenderLink;
35
+ }): import("react/jsx-runtime").JSX.Element;
36
+ //# sourceMappingURL=StatusBreakdown.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"StatusBreakdown.d.ts","sourceRoot":"","sources":["../../src/data/StatusBreakdown.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAU1D,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,8EAA8E;IAC9E,SAAS,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,4EAA4E;AAC5E,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,aAAa,CAEnG;AAED,mGAAmG;AACnG,MAAM,MAAM,gBAAgB,GAAG,CAAC,IAAI,EAAE;IACpC,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,SAAS,CAAC;CACrB,KAAK,YAAY,CAAC;AAUnB,wBAAgB,gBAAgB,CAAC,EAC/B,QAAQ,EACR,SAAS,GACV,EAAE;IACD,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,2CAkBA;AAQD,wBAAgB,UAAU,CAAC,EACzB,QAAQ,EACR,SAAS,EACT,OAAO,EACP,WAAW,EACX,WAAW,EACX,UAA8B,GAC/B,EAAE;IACD,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,IAAI,CAAC;IAC3C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,OAAO,CAAC;IAClD,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC/B,2CAmDA"}
@@ -0,0 +1,80 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import "react";
3
+ import { cn } from "../lib/utils.js";
4
+ import { Button } from "../components/button.js";
5
+ function segment(key, label, count, className) {
6
+ return { key, label, count: Math.max(0, count || 0), className };
7
+ }
8
+ const defaultRenderLink = ({ to, className, title, children }) => /* @__PURE__ */ jsx("a", { href: to, className, title, children });
9
+ function StackedStatusBar({
10
+ segments,
11
+ ariaLabel
12
+ }) {
13
+ const visible = segments.filter((s) => s.count > 0);
14
+ const total = visible.reduce((sum, s) => sum + s.count, 0);
15
+ if (visible.length === 0 || total <= 0) {
16
+ return /* @__PURE__ */ jsx("div", { className: "rounded border border-dashed p-3 text-xs text-muted-foreground", children: "No status data" });
17
+ }
18
+ return /* @__PURE__ */ jsx("div", { className: "flex h-2 overflow-hidden rounded bg-muted", "aria-label": ariaLabel, children: visible.map((s) => /* @__PURE__ */ jsx(
19
+ "div",
20
+ {
21
+ className: s.className,
22
+ title: `${s.label}: ${s.count}`,
23
+ style: { width: `${Math.max(1, s.count / total * 100)}%` }
24
+ },
25
+ s.key
26
+ )) });
27
+ }
28
+ function StatusRows({
29
+ segments,
30
+ ariaLabel,
31
+ onRetry,
32
+ retryingKey,
33
+ isRetryable,
34
+ renderLink = defaultRenderLink
35
+ }) {
36
+ const visible = segments.filter((s) => s.count > 0);
37
+ const total = visible.reduce((sum, s) => sum + s.count, 0);
38
+ if (visible.length === 0 || total <= 0) {
39
+ return /* @__PURE__ */ jsx("div", { className: "rounded border border-dashed p-3 text-xs text-muted-foreground", children: "No status data" });
40
+ }
41
+ return /* @__PURE__ */ jsx("div", { className: "space-y-2", "aria-label": ariaLabel, children: visible.map((s) => {
42
+ const retryable = !!onRetry && ((isRetryable == null ? void 0 : isRetryable(s)) ?? false);
43
+ const meta = /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-1 items-center gap-2", children: [
44
+ /* @__PURE__ */ jsx("span", { className: cn("h-2.5 w-2.5 shrink-0 rounded-sm", s.className), "aria-hidden": "true" }),
45
+ /* @__PURE__ */ jsx("span", { className: "truncate text-xs", children: s.label }),
46
+ /* @__PURE__ */ jsx("span", { className: "ml-auto shrink-0 font-mono text-xs text-muted-foreground", children: s.count })
47
+ ] });
48
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
49
+ s.href ? renderLink({
50
+ to: s.href,
51
+ className: "flex min-w-0 flex-1 rounded hover:bg-accent/40",
52
+ title: `View ${s.label} records`,
53
+ children: meta
54
+ }) : meta,
55
+ /* @__PURE__ */ jsx("div", { className: "h-2 w-32 shrink-0 overflow-hidden rounded bg-muted sm:w-48", children: /* @__PURE__ */ jsx("div", { className: cn("h-full", s.className), style: { width: `${Math.max(1, s.count / total * 100)}%` } }) }),
56
+ retryable ? /* @__PURE__ */ jsx(
57
+ Button,
58
+ {
59
+ type: "button",
60
+ variant: "outline",
61
+ size: "sm",
62
+ className: "h-6 shrink-0 px-2 text-[11px]",
63
+ disabled: retryingKey === s.key,
64
+ onClick: (e) => {
65
+ e.preventDefault();
66
+ e.stopPropagation();
67
+ onRetry == null ? void 0 : onRetry(s);
68
+ },
69
+ children: retryingKey === s.key ? "Retrying…" : "Retry"
70
+ }
71
+ ) : null
72
+ ] }, s.key);
73
+ }) });
74
+ }
75
+ export {
76
+ StackedStatusBar,
77
+ StatusRows,
78
+ segment
79
+ };
80
+ //# sourceMappingURL=StatusBreakdown.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"StatusBreakdown.js","sources":["../../src/data/StatusBreakdown.tsx"],"sourcesContent":["import { type ReactElement, type ReactNode } from \"react\";\nimport { cn } from \"../lib/utils\";\nimport { Button } from \"../components/button\";\n\n// A status-breakdown family: one StatusSegment list rendered either as a single\n// horizontal StackedStatusBar (scan a mix at a glance) or as vertical StatusRows\n// (each status on its own line with a proportional bar, optional drill-down link\n// and retry). Shared by monitor/intake/cycle breakdowns and any panel that shows\n// a count mix.\n\nexport interface StatusSegment {\n key: string;\n label: string;\n count: number;\n /** Tailwind background class for the swatch/bar fill, e.g. \"bg-green-500\". */\n className: string;\n /**\n * When set, the row links here (the breakdown points each status at its\n * filtered records/members view). Rendered via the `renderLink` prop so the\n * component stays router-agnostic.\n */\n href?: string;\n}\n\n/** Builds a StatusSegment, clamping the count to a non-negative integer. */\nexport function segment(key: string, label: string, count: number, className: string): StatusSegment {\n return { key, label, count: Math.max(0, count || 0), className };\n}\n\n/** Render-prop for a status row's link, so callers supply their router's Link (or a plain <a>). */\nexport type StatusRenderLink = (args: {\n to: string;\n className?: string;\n title?: string;\n children: ReactNode;\n}) => ReactElement;\n\nconst defaultRenderLink: StatusRenderLink = ({ to, className, title, children }) => (\n <a href={to} className={className} title={title}>\n {children}\n </a>\n);\n\n// StackedStatusBar renders the segments as a single horizontal multi-color bar —\n// segments share one track sized proportionally to the visible-segment total.\nexport function StackedStatusBar({\n segments,\n ariaLabel,\n}: {\n segments: StatusSegment[];\n ariaLabel?: string;\n}) {\n const visible = segments.filter((s) => s.count > 0);\n const total = visible.reduce((sum, s) => sum + s.count, 0);\n if (visible.length === 0 || total <= 0) {\n return <div className=\"rounded border border-dashed p-3 text-xs text-muted-foreground\">No status data</div>;\n }\n return (\n <div className=\"flex h-2 overflow-hidden rounded bg-muted\" aria-label={ariaLabel}>\n {visible.map((s) => (\n <div\n key={s.key}\n className={s.className}\n title={`${s.label}: ${s.count}`}\n style={{ width: `${Math.max(1, (s.count / total) * 100)}%` }}\n />\n ))}\n </div>\n );\n}\n\n// StatusRows renders each status as its own row — a colored swatch + label, the\n// count, and a horizontal bar sized proportionally to the visible-segment total.\n// The vertical counterpart to StackedStatusBar. An optional onRetry surfaces a\n// Retry button per row; callers that omit it get no button. When a segment\n// carries an href the whole row links there via renderLink (defaults to a plain\n// <a>); the Retry button stops propagation so it stays independently clickable.\nexport function StatusRows({\n segments,\n ariaLabel,\n onRetry,\n retryingKey,\n isRetryable,\n renderLink = defaultRenderLink,\n}: {\n segments: StatusSegment[];\n ariaLabel?: string;\n onRetry?: (segment: StatusSegment) => void;\n retryingKey?: string;\n isRetryable?: (segment: StatusSegment) => boolean;\n renderLink?: StatusRenderLink;\n}) {\n const visible = segments.filter((s) => s.count > 0);\n const total = visible.reduce((sum, s) => sum + s.count, 0);\n if (visible.length === 0 || total <= 0) {\n return <div className=\"rounded border border-dashed p-3 text-xs text-muted-foreground\">No status data</div>;\n }\n return (\n <div className=\"space-y-2\" aria-label={ariaLabel}>\n {visible.map((s) => {\n const retryable = !!onRetry && (isRetryable?.(s) ?? false);\n const meta = (\n <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n <span className={cn(\"h-2.5 w-2.5 shrink-0 rounded-sm\", s.className)} aria-hidden=\"true\" />\n <span className=\"truncate text-xs\">{s.label}</span>\n <span className=\"ml-auto shrink-0 font-mono text-xs text-muted-foreground\">{s.count}</span>\n </div>\n );\n return (\n <div key={s.key} className=\"flex items-center gap-3\">\n {s.href\n ? renderLink({\n to: s.href,\n className: \"flex min-w-0 flex-1 rounded hover:bg-accent/40\",\n title: `View ${s.label} records`,\n children: meta,\n })\n : meta}\n <div className=\"h-2 w-32 shrink-0 overflow-hidden rounded bg-muted sm:w-48\">\n <div className={cn(\"h-full\", s.className)} style={{ width: `${Math.max(1, (s.count / total) * 100)}%` }} />\n </div>\n {retryable ? (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n className=\"h-6 shrink-0 px-2 text-[11px]\"\n disabled={retryingKey === s.key}\n onClick={(e) => {\n e.preventDefault();\n e.stopPropagation();\n onRetry?.(s);\n }}\n >\n {retryingKey === s.key ? \"Retrying…\" : \"Retry\"}\n </Button>\n ) : null}\n </div>\n );\n })}\n </div>\n );\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const jsxRuntime = require("react/jsx-runtime");
4
+ const reactQuery = require("@tanstack/react-query");
5
+ const react = require("react");
6
+ const utils = require("../lib/utils.cjs");
7
+ const format = require("../lib/format.cjs");
8
+ const Modal = require("../overlay/Modal.cjs");
9
+ const Icon = require("./Icon.cjs");
10
+ const TimeseriesPanel = require("./TimeseriesPanel.cjs");
11
+ const UiFullscreen = require("../icons/components/UiFullscreen.cjs");
12
+ const defaultFetcher = async (url) => {
13
+ const res = await fetch(url);
14
+ if (!res.ok) throw new Error(`metrics request failed: ${res.status}`);
15
+ return res.json();
16
+ };
17
+ const GAUGE_RADIUS = 40;
18
+ const GAUGE_ARC = Math.PI * GAUGE_RADIUS;
19
+ const GAUGE_PATH = `M 10 50 A ${GAUGE_RADIUS} ${GAUGE_RADIUS} 0 0 1 90 50`;
20
+ function toneClass(pct, [warn, danger]) {
21
+ if (pct >= danger) return "text-red-500";
22
+ if (pct >= warn) return "text-amber-500";
23
+ return "text-emerald-500";
24
+ }
25
+ function latestValue(resp) {
26
+ var _a;
27
+ const points = resp == null ? void 0 : resp.points;
28
+ if (!points || points.length === 0) return void 0;
29
+ return (_a = points[points.length - 1]) == null ? void 0 : _a.value;
30
+ }
31
+ function GaugeIcon({ icon }) {
32
+ if (typeof icon === "string") {
33
+ return /* @__PURE__ */ jsxRuntime.jsx(Icon.Icon, { name: icon, width: 14, height: 14, className: "text-muted-foreground" });
34
+ }
35
+ return /* @__PURE__ */ jsxRuntime.jsx(Icon.Icon, { icon, width: 14, height: 14, className: "text-muted-foreground" });
36
+ }
37
+ function TimeseriesGauge({
38
+ baseUrl = "",
39
+ value,
40
+ max,
41
+ title,
42
+ icon,
43
+ unit,
44
+ range = "1h",
45
+ refreshMs = 5e3,
46
+ expandable = true,
47
+ thresholds = [75, 90],
48
+ centerDisplay = "value",
49
+ fetcher = defaultFetcher,
50
+ className
51
+ }) {
52
+ var _a, _b;
53
+ const [expanded, setExpanded] = react.useState(false);
54
+ const maxIsSeries = typeof max === "object";
55
+ const maxSeries = maxIsSeries ? max : void 0;
56
+ const ids = react.useMemo(() => {
57
+ const list = [value.id];
58
+ if (maxSeries) list.push(maxSeries.id);
59
+ return list;
60
+ }, [value.id, maxSeries]);
61
+ const results = reactQuery.useQueries({
62
+ queries: ids.map((id) => {
63
+ const u = new URL(baseUrl + id, window.location.origin);
64
+ if (range) u.searchParams.set("since", range);
65
+ const requestUrl = u.pathname + u.search;
66
+ return {
67
+ queryKey: ["timeseries", requestUrl],
68
+ queryFn: () => fetcher(requestUrl),
69
+ refetchInterval: refreshMs > 0 ? refreshMs : false,
70
+ staleTime: 0,
71
+ retry: 0
72
+ };
73
+ })
74
+ });
75
+ const rawValue = latestValue((_a = results[0]) == null ? void 0 : _a.data);
76
+ const hasValue = rawValue !== void 0;
77
+ const usage = value.transform ? value.transform(rawValue ?? 0) : rawValue ?? 0;
78
+ let limit;
79
+ if (typeof max === "number") {
80
+ limit = max;
81
+ } else if (maxSeries) {
82
+ const rawMax = latestValue((_b = results[1]) == null ? void 0 : _b.data);
83
+ limit = rawMax === void 0 ? void 0 : maxSeries.transform ? maxSeries.transform(rawMax) : rawMax;
84
+ }
85
+ const bounded = hasValue && limit !== void 0 && limit > 0;
86
+ const pct = bounded ? Math.min(100, Math.round(usage / limit * 100)) : 0;
87
+ const tone = toneClass(pct, thresholds);
88
+ const chartSeries = react.useMemo(() => {
89
+ const s = [{ id: value.id, label: "value", ...value.transform ? { transform: value.transform } : {} }];
90
+ if (maxSeries) s.push({ id: maxSeries.id, label: "max", ...maxSeries.transform ? { transform: maxSeries.transform } : {} });
91
+ return s;
92
+ }, [value.id, value.transform, maxSeries]);
93
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: utils.cn("flex flex-col items-center gap-1", className), children: [
94
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative h-10 w-20", children: [
95
+ /* @__PURE__ */ jsxRuntime.jsxs("svg", { viewBox: "0 0 100 50", className: "h-full w-full overflow-visible", children: [
96
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: GAUGE_PATH, fill: "none", stroke: "currentColor", strokeWidth: 9, strokeLinecap: "round", className: "text-muted" }),
97
+ /* @__PURE__ */ jsxRuntime.jsx(
98
+ "path",
99
+ {
100
+ d: GAUGE_PATH,
101
+ fill: "none",
102
+ stroke: "currentColor",
103
+ strokeWidth: 9,
104
+ strokeLinecap: "round",
105
+ className: tone,
106
+ strokeDasharray: GAUGE_ARC,
107
+ strokeDashoffset: GAUGE_ARC * (1 - pct / 100)
108
+ }
109
+ )
110
+ ] }),
111
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "absolute inset-x-0 bottom-0 text-center text-sm font-semibold text-foreground", children: centerDisplay === "percent" ? bounded ? `${pct}%` : hasValue ? "n/a" : "—" : hasValue ? format.formatUnit(usage, unit) : "—" }),
112
+ expandable && /* @__PURE__ */ jsxRuntime.jsx(
113
+ "button",
114
+ {
115
+ type: "button",
116
+ "aria-label": "Expand chart",
117
+ onClick: () => setExpanded(true),
118
+ className: "absolute right-0 top-0 text-muted-foreground hover:text-foreground",
119
+ children: /* @__PURE__ */ jsxRuntime.jsx(Icon.Icon, { icon: UiFullscreen.UiFullscreen, width: 12, height: 12 })
120
+ }
121
+ )
122
+ ] }),
123
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-1 text-xs text-muted-foreground", children: [
124
+ icon ? /* @__PURE__ */ jsxRuntime.jsx(GaugeIcon, { icon }) : null,
125
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: title })
126
+ ] }),
127
+ expandable && /* @__PURE__ */ jsxRuntime.jsx(Modal.Modal, { open: expanded, onClose: () => setExpanded(false), title, size: "xl", children: /* @__PURE__ */ jsxRuntime.jsx(
128
+ TimeseriesPanel.TimeseriesPanel,
129
+ {
130
+ title,
131
+ ...icon ? { icon } : {},
132
+ ...unit ? { unit } : {},
133
+ baseUrl,
134
+ series: chartSeries,
135
+ range,
136
+ refreshMs,
137
+ expandable: false,
138
+ fetcher
139
+ }
140
+ ) })
141
+ ] });
142
+ }
143
+ exports.TimeseriesGauge = TimeseriesGauge;
144
+ //# sourceMappingURL=TimeseriesGauge.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TimeseriesGauge.cjs","sources":["../../src/data/TimeseriesGauge.tsx"],"sourcesContent":["import { useQueries } from \"@tanstack/react-query\";\nimport { useMemo, useState } from \"react\";\nimport { cn } from \"../lib/utils\";\nimport { formatUnit } from \"../lib/format\";\nimport { Modal } from \"../overlay/Modal\";\nimport { UiFullscreen } from \"../icons\";\nimport { Icon, type StaticIconComponent } from \"./Icon\";\nimport {\n TimeseriesPanel,\n type TimeseriesResponse,\n type TimeseriesSeries,\n} from \"./TimeseriesPanel\";\n\n/** A metric series whose latest value drives the gauge fill or its maximum. */\nexport interface GaugeSeries {\n /** Metric id appended to the gauge's baseUrl, e.g. \"k8s.statefulset.cycle.cpu.usage\". */\n id: string;\n /** Maps the latest value before display (e.g. unit scaling). Identity when omitted. */\n transform?: (value: number) => number;\n}\n\nexport interface TimeseriesGaugeProps {\n /** Common prefix; the value/max requests are `baseUrl + id`. */\n baseUrl?: string;\n /** The metric whose latest value fills the gauge. */\n value: GaugeSeries;\n /**\n * The gauge maximum. Either a metric series (its latest value, e.g. a `.limit`\n * metric) or a fixed number. When omitted, or when the resolved max is 0, the\n * gauge shows the raw value with no fill (an unbounded reading).\n */\n max?: GaugeSeries | number;\n title: string;\n /** Iconify name or static icon component shown beside the label. */\n icon?: string | StaticIconComponent;\n /** Grafana-style unit key for the readout (e.g. \"bytes\", \"percent\", \"short\", \"ms\"). */\n unit?: string;\n /** Look-back window for the expand chart, passed as ?since=; defaults to \"1h\". */\n range?: string;\n /** Poll interval in ms; defaults to 5000. Pass 0 to disable polling. */\n refreshMs?: number;\n /** Show an expand button that opens the value/max time-series chart in a modal. Default true. */\n expandable?: boolean;\n /**\n * Utilisation thresholds (percent of max) at which the arc turns amber then\n * red. Defaults to [75, 90].\n */\n thresholds?: [warning: number, danger: number];\n /**\n * What to print in the centre: the formatted value (default) or the\n * utilisation percentage of max (e.g. CPU usage out of its millicore limit).\n */\n centerDisplay?: \"value\" | \"percent\";\n /** Override the default fetch (e.g. to route through an app's API client). */\n fetcher?: (url: string) => Promise<TimeseriesResponse>;\n className?: string;\n}\n\nconst defaultFetcher = async (url: string): Promise<TimeseriesResponse> => {\n const res = await fetch(url);\n if (!res.ok) throw new Error(`metrics request failed: ${res.status}`);\n return res.json();\n};\n\n// Half-gauge SVG geometry: a 100×50 viewBox semicircle (radius 40, centre 50,50)\n// swept 180°. The arc length is π·r; stroke-dashoffset reveals value/max of it.\nconst GAUGE_RADIUS = 40;\nconst GAUGE_ARC = Math.PI * GAUGE_RADIUS;\nconst GAUGE_PATH = `M 10 50 A ${GAUGE_RADIUS} ${GAUGE_RADIUS} 0 0 1 90 50`;\n\nfunction toneClass(pct: number, [warn, danger]: [number, number]): string {\n if (pct >= danger) return \"text-red-500\";\n if (pct >= warn) return \"text-amber-500\";\n return \"text-emerald-500\";\n}\n\nfunction latestValue(resp: TimeseriesResponse | undefined): number | undefined {\n const points = resp?.points;\n if (!points || points.length === 0) return undefined;\n return points[points.length - 1]?.value;\n}\n\nfunction GaugeIcon({ icon }: { icon: string | StaticIconComponent }) {\n if (typeof icon === \"string\") {\n return <Icon name={icon} width={14} height={14} className=\"text-muted-foreground\" />;\n }\n return <Icon icon={icon} width={14} height={14} className=\"text-muted-foreground\" />;\n}\n\n/**\n * TimeseriesGauge renders a half (180°) radial gauge whose fill is the latest\n * value of a metric over its maximum (a `.limit`-style metric or a fixed number),\n * both read live from the timeseries store. The centre shows the utilisation\n * percentage, with the value/max caption below; the arc colour crosses warning\n * and danger thresholds. An expand button opens the full value/max time-series\n * chart in a modal (a `TimeseriesPanel`), so the gauge gives the at-a-glance\n * reading and the chart the trend.\n */\nexport function TimeseriesGauge({\n baseUrl = \"\",\n value,\n max,\n title,\n icon,\n unit,\n range = \"1h\",\n refreshMs = 5000,\n expandable = true,\n thresholds = [75, 90],\n centerDisplay = \"value\",\n fetcher = defaultFetcher,\n className,\n}: TimeseriesGaugeProps) {\n const [expanded, setExpanded] = useState(false);\n\n const maxIsSeries = typeof max === \"object\";\n const maxSeries = maxIsSeries ? max : undefined;\n const ids = useMemo(() => {\n const list = [value.id];\n if (maxSeries) list.push(maxSeries.id);\n return list;\n }, [value.id, maxSeries]);\n\n const results = useQueries({\n queries: ids.map((id) => {\n const u = new URL(baseUrl + id, window.location.origin);\n if (range) u.searchParams.set(\"since\", range);\n const requestUrl = u.pathname + u.search;\n return {\n queryKey: [\"timeseries\", requestUrl],\n queryFn: () => fetcher(requestUrl),\n refetchInterval: refreshMs > 0 ? refreshMs : false,\n staleTime: 0,\n retry: 0,\n };\n }),\n });\n\n const rawValue = latestValue(results[0]?.data);\n const hasValue = rawValue !== undefined;\n const usage = value.transform ? value.transform(rawValue ?? 0) : (rawValue ?? 0);\n\n let limit: number | undefined;\n if (typeof max === \"number\") {\n limit = max;\n } else if (maxSeries) {\n const rawMax = latestValue(results[1]?.data);\n limit = rawMax === undefined ? undefined : maxSeries.transform ? maxSeries.transform(rawMax) : rawMax;\n }\n\n const bounded = hasValue && limit !== undefined && limit > 0;\n const pct = bounded ? Math.min(100, Math.round((usage / (limit as number)) * 100)) : 0;\n const tone = toneClass(pct, thresholds);\n\n const chartSeries: TimeseriesSeries[] = useMemo(() => {\n const s: TimeseriesSeries[] = [{ id: value.id, label: \"value\", ...(value.transform ? { transform: value.transform } : {}) }];\n if (maxSeries) s.push({ id: maxSeries.id, label: \"max\", ...(maxSeries.transform ? { transform: maxSeries.transform } : {}) });\n return s;\n }, [value.id, value.transform, maxSeries]);\n\n return (\n <div className={cn(\"flex flex-col items-center gap-1\", className)}>\n <div className=\"relative h-10 w-20\">\n <svg viewBox=\"0 0 100 50\" className=\"h-full w-full overflow-visible\">\n <path d={GAUGE_PATH} fill=\"none\" stroke=\"currentColor\" strokeWidth={9} strokeLinecap=\"round\" className=\"text-muted\" />\n <path\n d={GAUGE_PATH}\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={9}\n strokeLinecap=\"round\"\n className={tone}\n strokeDasharray={GAUGE_ARC}\n strokeDashoffset={GAUGE_ARC * (1 - pct / 100)}\n />\n </svg>\n <span className=\"absolute inset-x-0 bottom-0 text-center text-sm font-semibold text-foreground\">\n {centerDisplay === \"percent\"\n ? bounded\n ? `${pct}%`\n : hasValue\n ? \"n/a\"\n : \"—\"\n : hasValue\n ? formatUnit(usage, unit)\n : \"—\"}\n </span>\n {expandable && (\n <button\n type=\"button\"\n aria-label=\"Expand chart\"\n onClick={() => setExpanded(true)}\n className=\"absolute right-0 top-0 text-muted-foreground hover:text-foreground\"\n >\n <Icon icon={UiFullscreen} width={12} height={12} />\n </button>\n )}\n </div>\n <div className=\"flex items-center gap-1 text-xs text-muted-foreground\">\n {icon ? <GaugeIcon icon={icon} /> : null}\n <span>{title}</span>\n </div>\n\n {expandable && (\n <Modal open={expanded} onClose={() => setExpanded(false)} title={title} size=\"xl\">\n <TimeseriesPanel\n title={title}\n {...(icon ? { icon } : {})}\n {...(unit ? { unit } : {})}\n baseUrl={baseUrl}\n series={chartSeries}\n range={range}\n refreshMs={refreshMs}\n expandable={false}\n fetcher={fetcher}\n />\n </Modal>\n )}\n </div>\n );\n}\n"],"names":["jsx","Icon","useState","useMemo","useQueries","cn","jsxs","formatUnit","UiFullscreen","Modal","TimeseriesPanel"],"mappings":"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"}
@@ -0,0 +1,56 @@
1
+ import { StaticIconComponent } from './Icon';
2
+ import { TimeseriesResponse } from './TimeseriesPanel';
3
+ /** A metric series whose latest value drives the gauge fill or its maximum. */
4
+ export interface GaugeSeries {
5
+ /** Metric id appended to the gauge's baseUrl, e.g. "k8s.statefulset.cycle.cpu.usage". */
6
+ id: string;
7
+ /** Maps the latest value before display (e.g. unit scaling). Identity when omitted. */
8
+ transform?: (value: number) => number;
9
+ }
10
+ export interface TimeseriesGaugeProps {
11
+ /** Common prefix; the value/max requests are `baseUrl + id`. */
12
+ baseUrl?: string;
13
+ /** The metric whose latest value fills the gauge. */
14
+ value: GaugeSeries;
15
+ /**
16
+ * The gauge maximum. Either a metric series (its latest value, e.g. a `.limit`
17
+ * metric) or a fixed number. When omitted, or when the resolved max is 0, the
18
+ * gauge shows the raw value with no fill (an unbounded reading).
19
+ */
20
+ max?: GaugeSeries | number;
21
+ title: string;
22
+ /** Iconify name or static icon component shown beside the label. */
23
+ icon?: string | StaticIconComponent;
24
+ /** Grafana-style unit key for the readout (e.g. "bytes", "percent", "short", "ms"). */
25
+ unit?: string;
26
+ /** Look-back window for the expand chart, passed as ?since=; defaults to "1h". */
27
+ range?: string;
28
+ /** Poll interval in ms; defaults to 5000. Pass 0 to disable polling. */
29
+ refreshMs?: number;
30
+ /** Show an expand button that opens the value/max time-series chart in a modal. Default true. */
31
+ expandable?: boolean;
32
+ /**
33
+ * Utilisation thresholds (percent of max) at which the arc turns amber then
34
+ * red. Defaults to [75, 90].
35
+ */
36
+ thresholds?: [warning: number, danger: number];
37
+ /**
38
+ * What to print in the centre: the formatted value (default) or the
39
+ * utilisation percentage of max (e.g. CPU usage out of its millicore limit).
40
+ */
41
+ centerDisplay?: "value" | "percent";
42
+ /** Override the default fetch (e.g. to route through an app's API client). */
43
+ fetcher?: (url: string) => Promise<TimeseriesResponse>;
44
+ className?: string;
45
+ }
46
+ /**
47
+ * TimeseriesGauge renders a half (180°) radial gauge whose fill is the latest
48
+ * value of a metric over its maximum (a `.limit`-style metric or a fixed number),
49
+ * both read live from the timeseries store. The centre shows the utilisation
50
+ * percentage, with the value/max caption below; the arc colour crosses warning
51
+ * and danger thresholds. An expand button opens the full value/max time-series
52
+ * chart in a modal (a `TimeseriesPanel`), so the gauge gives the at-a-glance
53
+ * reading and the chart the trend.
54
+ */
55
+ export declare function TimeseriesGauge({ baseUrl, value, max, title, icon, unit, range, refreshMs, expandable, thresholds, centerDisplay, fetcher, className, }: TimeseriesGaugeProps): import("react/jsx-runtime").JSX.Element;
56
+ //# sourceMappingURL=TimeseriesGauge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TimeseriesGauge.d.ts","sourceRoot":"","sources":["../../src/data/TimeseriesGauge.tsx"],"names":[],"mappings":"AAMA,OAAO,EAAQ,KAAK,mBAAmB,EAAE,MAAM,QAAQ,CAAC;AACxD,OAAO,EAEL,KAAK,kBAAkB,EAExB,MAAM,mBAAmB,CAAC;AAE3B,+EAA+E;AAC/E,MAAM,WAAW,WAAW;IAC1B,yFAAyF;IACzF,EAAE,EAAE,MAAM,CAAC;IACX,uFAAuF;IACvF,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;CACvC;AAED,MAAM,WAAW,oBAAoB;IACnC,gEAAgE;IAChE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qDAAqD;IACrD,KAAK,EAAE,WAAW,CAAC;IACnB;;;;OAIG;IACH,GAAG,CAAC,EAAE,WAAW,GAAG,MAAM,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,oEAAoE;IACpE,IAAI,CAAC,EAAE,MAAM,GAAG,mBAAmB,CAAC;IACpC,uFAAuF;IACvF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kFAAkF;IAClF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,wEAAwE;IACxE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iGAAiG;IACjG,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/C;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACpC,8EAA8E;IAC9E,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACvD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAiCD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,EAC9B,OAAY,EACZ,KAAK,EACL,GAAG,EACH,KAAK,EACL,IAAI,EACJ,IAAI,EACJ,KAAY,EACZ,SAAgB,EAChB,UAAiB,EACjB,UAAqB,EACrB,aAAuB,EACvB,OAAwB,EACxB,SAAS,GACV,EAAE,oBAAoB,2CA4GtB"}
@@ -0,0 +1,144 @@
1
+ import { jsxs, jsx } from "react/jsx-runtime";
2
+ import { useQueries } from "@tanstack/react-query";
3
+ import { useState, useMemo } from "react";
4
+ import { cn } from "../lib/utils.js";
5
+ import { formatUnit } from "../lib/format.js";
6
+ import { Modal } from "../overlay/Modal.js";
7
+ import { Icon } from "./Icon.js";
8
+ import { TimeseriesPanel } from "./TimeseriesPanel.js";
9
+ import { UiFullscreen } from "../icons/components/UiFullscreen.js";
10
+ const defaultFetcher = async (url) => {
11
+ const res = await fetch(url);
12
+ if (!res.ok) throw new Error(`metrics request failed: ${res.status}`);
13
+ return res.json();
14
+ };
15
+ const GAUGE_RADIUS = 40;
16
+ const GAUGE_ARC = Math.PI * GAUGE_RADIUS;
17
+ const GAUGE_PATH = `M 10 50 A ${GAUGE_RADIUS} ${GAUGE_RADIUS} 0 0 1 90 50`;
18
+ function toneClass(pct, [warn, danger]) {
19
+ if (pct >= danger) return "text-red-500";
20
+ if (pct >= warn) return "text-amber-500";
21
+ return "text-emerald-500";
22
+ }
23
+ function latestValue(resp) {
24
+ var _a;
25
+ const points = resp == null ? void 0 : resp.points;
26
+ if (!points || points.length === 0) return void 0;
27
+ return (_a = points[points.length - 1]) == null ? void 0 : _a.value;
28
+ }
29
+ function GaugeIcon({ icon }) {
30
+ if (typeof icon === "string") {
31
+ return /* @__PURE__ */ jsx(Icon, { name: icon, width: 14, height: 14, className: "text-muted-foreground" });
32
+ }
33
+ return /* @__PURE__ */ jsx(Icon, { icon, width: 14, height: 14, className: "text-muted-foreground" });
34
+ }
35
+ function TimeseriesGauge({
36
+ baseUrl = "",
37
+ value,
38
+ max,
39
+ title,
40
+ icon,
41
+ unit,
42
+ range = "1h",
43
+ refreshMs = 5e3,
44
+ expandable = true,
45
+ thresholds = [75, 90],
46
+ centerDisplay = "value",
47
+ fetcher = defaultFetcher,
48
+ className
49
+ }) {
50
+ var _a, _b;
51
+ const [expanded, setExpanded] = useState(false);
52
+ const maxIsSeries = typeof max === "object";
53
+ const maxSeries = maxIsSeries ? max : void 0;
54
+ const ids = useMemo(() => {
55
+ const list = [value.id];
56
+ if (maxSeries) list.push(maxSeries.id);
57
+ return list;
58
+ }, [value.id, maxSeries]);
59
+ const results = useQueries({
60
+ queries: ids.map((id) => {
61
+ const u = new URL(baseUrl + id, window.location.origin);
62
+ if (range) u.searchParams.set("since", range);
63
+ const requestUrl = u.pathname + u.search;
64
+ return {
65
+ queryKey: ["timeseries", requestUrl],
66
+ queryFn: () => fetcher(requestUrl),
67
+ refetchInterval: refreshMs > 0 ? refreshMs : false,
68
+ staleTime: 0,
69
+ retry: 0
70
+ };
71
+ })
72
+ });
73
+ const rawValue = latestValue((_a = results[0]) == null ? void 0 : _a.data);
74
+ const hasValue = rawValue !== void 0;
75
+ const usage = value.transform ? value.transform(rawValue ?? 0) : rawValue ?? 0;
76
+ let limit;
77
+ if (typeof max === "number") {
78
+ limit = max;
79
+ } else if (maxSeries) {
80
+ const rawMax = latestValue((_b = results[1]) == null ? void 0 : _b.data);
81
+ limit = rawMax === void 0 ? void 0 : maxSeries.transform ? maxSeries.transform(rawMax) : rawMax;
82
+ }
83
+ const bounded = hasValue && limit !== void 0 && limit > 0;
84
+ const pct = bounded ? Math.min(100, Math.round(usage / limit * 100)) : 0;
85
+ const tone = toneClass(pct, thresholds);
86
+ const chartSeries = useMemo(() => {
87
+ const s = [{ id: value.id, label: "value", ...value.transform ? { transform: value.transform } : {} }];
88
+ if (maxSeries) s.push({ id: maxSeries.id, label: "max", ...maxSeries.transform ? { transform: maxSeries.transform } : {} });
89
+ return s;
90
+ }, [value.id, value.transform, maxSeries]);
91
+ return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col items-center gap-1", className), children: [
92
+ /* @__PURE__ */ jsxs("div", { className: "relative h-10 w-20", children: [
93
+ /* @__PURE__ */ jsxs("svg", { viewBox: "0 0 100 50", className: "h-full w-full overflow-visible", children: [
94
+ /* @__PURE__ */ jsx("path", { d: GAUGE_PATH, fill: "none", stroke: "currentColor", strokeWidth: 9, strokeLinecap: "round", className: "text-muted" }),
95
+ /* @__PURE__ */ jsx(
96
+ "path",
97
+ {
98
+ d: GAUGE_PATH,
99
+ fill: "none",
100
+ stroke: "currentColor",
101
+ strokeWidth: 9,
102
+ strokeLinecap: "round",
103
+ className: tone,
104
+ strokeDasharray: GAUGE_ARC,
105
+ strokeDashoffset: GAUGE_ARC * (1 - pct / 100)
106
+ }
107
+ )
108
+ ] }),
109
+ /* @__PURE__ */ jsx("span", { className: "absolute inset-x-0 bottom-0 text-center text-sm font-semibold text-foreground", children: centerDisplay === "percent" ? bounded ? `${pct}%` : hasValue ? "n/a" : "—" : hasValue ? formatUnit(usage, unit) : "—" }),
110
+ expandable && /* @__PURE__ */ jsx(
111
+ "button",
112
+ {
113
+ type: "button",
114
+ "aria-label": "Expand chart",
115
+ onClick: () => setExpanded(true),
116
+ className: "absolute right-0 top-0 text-muted-foreground hover:text-foreground",
117
+ children: /* @__PURE__ */ jsx(Icon, { icon: UiFullscreen, width: 12, height: 12 })
118
+ }
119
+ )
120
+ ] }),
121
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1 text-xs text-muted-foreground", children: [
122
+ icon ? /* @__PURE__ */ jsx(GaugeIcon, { icon }) : null,
123
+ /* @__PURE__ */ jsx("span", { children: title })
124
+ ] }),
125
+ expandable && /* @__PURE__ */ jsx(Modal, { open: expanded, onClose: () => setExpanded(false), title, size: "xl", children: /* @__PURE__ */ jsx(
126
+ TimeseriesPanel,
127
+ {
128
+ title,
129
+ ...icon ? { icon } : {},
130
+ ...unit ? { unit } : {},
131
+ baseUrl,
132
+ series: chartSeries,
133
+ range,
134
+ refreshMs,
135
+ expandable: false,
136
+ fetcher
137
+ }
138
+ ) })
139
+ ] });
140
+ }
141
+ export {
142
+ TimeseriesGauge
143
+ };
144
+ //# sourceMappingURL=TimeseriesGauge.js.map