-
- {/* The real rows, with bones for text, so heights and dividers match at every width. */}
-
-
({
- label: ,
- content: ,
- }))}
- />
+
+
+ {/* The stats strip's cells (`StatCell` while loading): label and hint, value, sparkline. */}
+
+ {Array.from({ length: 4 }, (_, index) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 6 }, (_, index) => (
+
+
+
+
+ ))}
+
);
diff --git a/ui/consumer/src/components/workload-instances.tsx b/ui/consumer/src/components/workload-instances.tsx
index d0e4fdf8..bca0ce69 100644
--- a/ui/consumer/src/components/workload-instances.tsx
+++ b/ui/consumer/src/components/workload-instances.tsx
@@ -1,19 +1,37 @@
/**
- * The workload Overview's Instances section: one thin row per instance, in
- * the project home page's row shape (`h-10`, `px-2`, `rounded-md`,
- * `hover:bg-accent`). Each row says whether the instance is serving and why
- * not, where it runs, how to reach it on the private network, and links to
- * its Logs, Shell and Metrics.
+ * The workload Overview's Instances card: a table with one row per instance
+ * saying whether it is serving and why not, where it runs, its last hour of
+ * network traffic, how to reach it on the private network, and links to its Logs, Shell
+ * and Metrics. The workload-name prefix every instance shares is muted so the
+ * part that tells them apart stands out.
*/
import { formatLocationName, formatLocationTooltip, type LocationIndex } from '../lib/locations';
import type { ApiError } from '../lib/api';
-import { instanceReadiness, instancesSectionState, privateNameState } from '../lib/workload-presenters';
+import { seriesByWorkload } from '../lib/metrics-queries';
+import { useWorkloadNetworkMetrics } from '../lib/network-interface-metrics';
+import { usePrometheusChart } from '../lib/prometheus';
+import {
+ instanceNameParts,
+ instanceReadiness,
+ instancesSectionState,
+ STATUS_TONE_COLOR,
+ type StatusTone,
+} from '../lib/workload-presenters';
import type { Instance } from '../schema';
-import { InstanceRowsSkeleton } from './skeletons';
-import { QuietNotice, SectionFrame, StatusDot } from './surface';
+import { StatusBadge } from './detail-list';
+import { MetricSparkline } from './metric-sparkline';
+import { useOverviewRange } from './overview-range';
+import { InstanceTableSkeleton } from './skeletons';
+import { QuietNotice } from './surface';
import { LinkButton } from '@datum-cloud/datum-ui/button';
+import { Badge } from '@datum-cloud/datum-ui/badge';
+import { Card, CardHeader, CardTitle } from '@datum-cloud/datum-ui/card';
import { Icon } from '@datum-cloud/datum-ui/icons';
-import { ChartLineIcon, LogsIcon, SquareTerminalIcon } from 'lucide-react';
+import { cn } from '@datum-cloud/datum-ui/utils';
+import { Tooltip } from '@datum-cloud/datum-ui/tooltip';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@datum-cloud/datum-ui/table';
+import { ChartLineIcon, LogsIcon, ServerIcon, SquareTerminalIcon } from 'lucide-react';
+import { useMemo } from 'react';
import { Link } from 'react-router';
/** The address a person would dial first: the in-network one, else the external. */
@@ -21,29 +39,13 @@ function primaryAddress(instance: Instance): string | undefined {
return instance.addresses[0] ?? instance.internalIP ?? instance.externalIPs[0] ?? instance.externalIP;
}
-function PrivateName({ instance }: { instance: Instance }) {
- const state = privateNameState(instance);
- if (state.kind === 'missing') {
- return (
-
- {state.reason}
-
- );
- }
- const [first, ...rest] = state.names;
- return (
-
- {first}
- {rest.length > 0 ? +{rest.length} : null}
-
- );
-}
-
-// Inline: the host stylesheet doesn't reliably compile `w-56`.
-const COLUMN_STYLE = { width: '14rem', maxWidth: '100%' } as const;
+/** Badge colours per readiness tone; anything still coming up reads as info. */
+const BADGE_TYPE: Record
= {
+ ok: 'success',
+ warn: 'warning',
+ error: 'danger',
+ muted: 'info',
+};
const ROW_ACTIONS = [
{ label: 'Logs', suffix: '/logs', icon: LogsIcon },
@@ -51,67 +53,117 @@ const ROW_ACTIONS = [
{ label: 'Metrics', suffix: '/metrics', icon: ChartLineIcon },
] as const;
+// Column headers as the ALB Backends card sets them.
+const HEAD = 'text-muted-foreground text-3xs h-9 font-semibold uppercase';
+// Inline: the host compiles no fixed-width sparkline column.
+const SPARK_CELL = { width: '8rem', minWidth: '7rem' } as const;
+
function InstanceRow({
instance,
+ workloadName,
href,
locationIndex,
+ projectId,
+ traffic,
+ trafficLoading,
+ showTraffic,
+ timeRange,
+ deploying,
}: {
instance: Instance;
+ workloadName: string;
href: string;
locationIndex: LocationIndex;
+ projectId?: string;
+ traffic: Parameters[0]['series'];
+ trafficLoading: boolean;
+ showTraffic: boolean;
+ timeRange: { start: Date; end: Date };
+ deploying: boolean;
}) {
const readiness = instanceReadiness(instance);
const address = primaryAddress(instance);
+ const name = instanceNameParts(instance.name, workloadName);
+ // Not ready is expected while a deploy is under way, so only tint it afterwards.
+ const tinted = readiness.tone === 'error' || (readiness.tone === 'warn' && !deploying);
return (
-
-
-
-
-
- {instance.name}
-
-
+
+
+
+ {name.prefix ? {name.prefix} : null}
+ {name.rest}
+
+
+
+
{instance.location ? formatLocationName(instance.location, locationIndex) : 'Unknown location'}
-
-
-
- {address ? (
-
- {address}
-
- ) : null}
-
-
- {ROW_ACTIONS.map((action) => (
- }
- aria-label={`${action.label} for ${instance.name}`}>
- {action.label}
-
- ))}
-
-
+
+
+ {readiness.reason ? (
+ // Focusable so keyboard users can open the reason too.
+
+
+ {readiness.label}
+
+
+ ) : (
+ {readiness.label}
+ )}
+
+ {showTraffic ? (
+
+
+
+ ) : null}
+
+ {address ?? '—'}
+
+
+
+ {ROW_ACTIONS.map((action) => (
+
+
+ }
+ />
+
+
+ ))}
+
+
+
);
}
/**
- * The Instances section: rows once any instance is known, placeholder rows
+ * The Instances card: a table once any instance is known, placeholder rows
* while the query is unanswered, and a quiet notice for none or a failure.
*/
export function WorkloadInstances({
+ workloadName,
instances,
loading,
error,
@@ -119,7 +171,10 @@ export function WorkloadInstances({
instanceHref,
deploying,
deleting,
+ projectId,
+ metricsDenied,
}: {
+ workloadName: string;
instances: readonly Instance[];
/** The instances query hasn't answered yet. */
loading: boolean;
@@ -129,30 +184,81 @@ export function WorkloadInstances({
/** Still coming up, so no instances yet is expected. */
deploying: boolean;
deleting: boolean;
+ projectId?: string;
+ metricsDenied: boolean;
}) {
const state = instancesSectionState({ count: instances.length, loading, error, deploying, deleting });
+ const range = useOverviewRange('1h');
+ const network = useWorkloadNetworkMetrics(metricsDenied ? undefined : projectId, instances);
+ const showTraffic = !!network.throughputQuery;
+ const chart = usePrometheusChart(projectId, network.throughputQuery, range.timeRange, {
+ enabled: showTraffic,
+ keepPrevious: true,
+ });
+ // Interface series folded into one per instance (an instance may have several NICs).
+ const trafficByInstance = useMemo(
+ () => (chart.error ? {} : seriesByWorkload(chart.data, 'network', network.refsByInstance)),
+ [chart.data, chart.error, network.refsByInstance]
+ );
+
return (
-
+
+
+
+
+ Instances
+ {state.kind !== 'loading' && instances.length > 0 ? (
+
+ {instances.length}
+
+ ) : null}
+
+
{state.kind === 'loading' ? (
-
+
) : state.kind === 'notice' ? (
-
+
+
+
) : (
-
- {instances.map((instance) => (
-
- ))}
-
+
+
+
+ Instance
+ Location
+ State
+ {showTraffic ? (
+
+
+ Network · 1h
+
+
+ ) : null}
+ Address
+
+ Actions
+
+
+
+
+ {instances.map((instance) => (
+
+ ))}
+
+
)}
-
+
);
}
diff --git a/ui/consumer/src/components/workload-load-balancers.tsx b/ui/consumer/src/components/workload-load-balancers.tsx
new file mode 100644
index 00000000..6912715f
--- /dev/null
+++ b/ui/consumer/src/components/workload-load-balancers.tsx
@@ -0,0 +1,260 @@
+/**
+ * The workload Overview's Load balancers card: one row per load balancer
+ * whose rules send traffic to this workload, with its state, hostname and
+ * the routes that reach the workload. Same card and table styling as the
+ * Instances card.
+ */
+import { ensureWorkloadService, type AlbRoute, type ConnectedAlb, type PublishedUrl } from '../lib/api';
+import type { Workload } from '../schema';
+import type { AlbStatus } from '../lib/alb-status';
+import { albOverviewHref } from '../lib/use-workload-overview-state';
+import { albRowState, routePortText, type StatusTone } from '../lib/workload-presenters';
+import { StatusBadge } from './detail-list';
+import { LoadBalancerTableSkeleton } from './skeletons';
+import { QuietNotice } from './surface';
+import { Badge } from '@datum-cloud/datum-ui/badge';
+import { Button, LinkButton } from '@datum-cloud/datum-ui/button';
+import { Card, CardHeader, CardTitle } from '@datum-cloud/datum-ui/card';
+import { Icon } from '@datum-cloud/datum-ui/icons';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@datum-cloud/datum-ui/table';
+import { Tooltip } from '@datum-cloud/datum-ui/tooltip';
+import { cn } from '@datum-cloud/datum-ui/utils';
+import { ArrowUpRightIcon, NetworkIcon, PlusIcon, SquareArrowOutUpRightIcon } from 'lucide-react';
+import { useState } from 'react';
+import { Link, useNavigate } from 'react-router';
+
+// Column headers as the ALB Backends card (and our Instances card) sets them.
+const HEAD = 'text-muted-foreground text-3xs h-9 font-semibold uppercase';
+// Inline: the host compiles no arbitrary font size.
+const PILL_STYLE = { fontSize: '11px' } as const;
+
+const BADGE_TYPE: Record = {
+ ok: 'success',
+ warn: 'warning',
+ error: 'danger',
+ muted: 'info',
+};
+
+/** The badge's one word; the full state goes in its tooltip. */
+function badgeLabel(status: AlbStatus | undefined): string {
+ if (!status || status.phase === 'ready') return 'Ready';
+ return status.phase === 'error' ? 'Error' : 'Provisioning';
+}
+
+/** "[50% of] /api → :8080 (http) · shared with 1 other backend" */
+function RouteLine({ route }: { route: AlbRoute }) {
+ const port = routePortText(route.port);
+ return (
+
+ {route.percent !== undefined ? (
+ <>
+
+ {route.percent}%
+
+ of
+ >
+ ) : null}
+
+ {route.paths.join(', ')}
+ {route.narrowed ? '*' : null}
+
+ →
+ {port ? {port} : this workload }
+ {route.sharedWith > 0 ? (
+
+ · shared with {route.sharedWith} other {route.sharedWith === 1 ? 'backend' : 'backends'}
+
+ ) : null}
+
+ );
+}
+
+function AlbRow({ alb, status, projectId }: { alb: ConnectedAlb; status?: AlbStatus; projectId?: string }) {
+ const state = albRowState(status);
+ const label = badgeLabel(status);
+ const href = projectId ? albOverviewHref(projectId, alb.proxyName) : undefined;
+ const name = alb.displayName || alb.proxyName;
+ const badge = {label} ;
+ return (
+
+
+ {href ? (
+
+ {name}
+
+ ) : (
+ {name}
+ )}
+
+
+ {state.label !== label ? (
+
+
+ {badge}
+
+
+ ) : (
+ badge
+ )}
+
+
+ {alb.hostname ? (
+
+
+ {alb.hostname}
+
+
+ {alb.customHostnames.length > 0 ? (
+
+
+ +{alb.customHostnames.length} more
+
+
+ ) : null}
+
+ ) : (
+ No hostname yet
+ )}
+
+
+
+ {alb.routes.length > 0 ? (
+ alb.routes.map((route, index) => )
+ ) : (
+ // Labelled to the workload, with no service backend to read routes from.
+ All traffic
+ )}
+
+
+
+ {href ? (
+
+
+
+ }
+ />
+
+
+
+ ) : null}
+
+
+ );
+}
+
+/**
+ * Opens the portal's new load balancer dialog with this workload chosen. The
+ * dialog can only choose a workload with a NetworkService, so one is made
+ * first when it has none; if that fails the dialog still opens and says why.
+ */
+function AddToLoadBalancer({ projectId, workload }: { projectId: string; workload: Workload }) {
+ const navigate = useNavigate();
+ const [busy, setBusy] = useState(false);
+ const open = async () => {
+ setBusy(true);
+ try {
+ await ensureWorkloadService(projectId, workload);
+ } catch {
+ // The dialog explains a workload it can't choose.
+ }
+ void navigate(`/project/${projectId}/alb?action=create&workload=${encodeURIComponent(workload.name)}`);
+ };
+ return (
+ void open()}
+ icon={ }>
+ Add to a load balancer
+
+ );
+}
+
+export function WorkloadLoadBalancers({
+ projectId,
+ published,
+ loading,
+ albStatuses,
+ network,
+ workload,
+}: {
+ projectId?: string;
+ /** Chosen in the portal's create dialog. */
+ workload: Workload;
+ published?: PublishedUrl | null;
+ /** The load balancer lookup hasn't answered yet. */
+ loading: boolean;
+ albStatuses: Record;
+ /** The workload's network, for saying where it is reachable without one. */
+ network?: string;
+}) {
+ const albs = published?.proxies ?? [];
+ return (
+
+
+
+
+ Load balancers
+ {!loading && albs.length > 0 ? (
+
+ {albs.length}
+
+ ) : null}
+
+
+ {loading ? (
+
+ ) : albs.length === 0 ? (
+
+
+ ) : undefined
+ }
+ />
+
+ ) : (
+
+
+
+ Load balancer
+ State
+ Hostname
+ Routes
+
+ Actions
+
+
+
+
+ {albs.map((alb) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/ui/consumer/src/components/workload-overview-card.tsx b/ui/consumer/src/components/workload-overview-card.tsx
new file mode 100644
index 00000000..a4810ff3
--- /dev/null
+++ b/ui/consumer/src/components/workload-overview-card.tsx
@@ -0,0 +1,582 @@
+/**
+ * The workload Overview's summary card, in the shape of the ALB overview's
+ * health strip: a tinted status ring, a headline and detail, and when the
+ * workload last changed. Hostnames live in the Load balancers card. Below that, either a crash
+ * band (only while an instance is crashing), the deploy steps (while the
+ * workload first comes up), or the last hour's stats.
+ *
+ * The asleep state, wake-ups, revision and image digest from the design are
+ * left out until the API reports them (datum-cloud/compute#626).
+ */
+import { MetricSparkline } from './metric-sparkline';
+import { useOverviewRange } from './overview-range';
+import { formatKpiValue } from './metric-area-chart';
+import {
+ albRpsQuery,
+ workloadCpuSumQuery,
+ workloadMemorySumQuery,
+ type InstanceIdentityLabel,
+} from '../lib/metrics-queries';
+import type { DeployPhase, TimelineStep } from '../lib/deploy-presenters';
+import { useWorkloadNetworkMetrics } from '../lib/network-interface-metrics';
+import { usePrometheusChart, type ChartDataPoint } from '../lib/prometheus';
+import {
+ cpuUsageHint,
+ formatCpuUsage,
+ formatMemoryUsage,
+ memoryUsageHint,
+ type Allocation,
+} from '../lib/resource-usage';
+import {
+ crashDetailText,
+ formatAgo,
+ imageShortName,
+ instanceReadiness,
+ STATUS_TONE_COLOR,
+ type CrashDetail,
+ type InstanceProblem,
+ type OverviewStatus,
+ type StatusTone,
+} from '../lib/workload-presenters';
+import type { Instance } from '../schema';
+import { Card } from '@datum-cloud/datum-ui/card';
+import { Skeleton } from '@datum-cloud/datum-ui/skeleton';
+import { Icon, SpinnerIcon } from '@datum-cloud/datum-ui/icons';
+import {
+ CircleCheckIcon,
+ HourglassIcon,
+ InfoIcon,
+ CircleIcon,
+ CircleXIcon,
+ LogsIcon,
+ TriangleAlertIcon,
+} from 'lucide-react';
+import { useEffect, useMemo, useRef, type ReactNode } from 'react';
+import { Link } from 'react-router';
+
+const INFO_COLOR = 'var(--color-badge-info)';
+
+/** Text blocks keep this much width and push their neighbours onto the next line instead. */
+const WRAP_BASIS = { flexBasis: '18rem' } as const;
+
+
+/** A tone's color at a low opacity, for the ring and the attention band. */
+function tint(color: string, percent: number): string {
+ return `color-mix(in oklab, ${color} ${percent}%, transparent)`;
+}
+
+const TONE_ICON: Record = {
+ ok: CircleCheckIcon,
+ warn: TriangleAlertIcon,
+ error: CircleXIcon,
+ muted: CircleIcon,
+};
+
+function StatusRing({ tone, phase }: { tone: StatusTone; phase?: DeployPhase }) {
+ const ring = phase ? PHASE_RING[phase] : undefined;
+ const color = ring?.color ?? STATUS_TONE_COLOR[tone];
+ return (
+
+ {phase === 'deploying' ? : }
+
+ );
+}
+
+/** The instance that is crashing, and what its runtime last reported. */
+export interface OverviewCrash {
+ instance: Instance;
+ detail: CrashDetail;
+ /** Where the instance runs, in words. */
+ locationLabel: string;
+ logsHref: string;
+ instanceHref: string;
+}
+
+function CrashBand({ crash }: { crash: OverviewCrash }) {
+ const color = STATUS_TONE_COLOR.warn;
+ return (
+
+
+
+
+ {crash.instance.name} in {crash.locationLabel} keeps crashing
+
+ {crashDetailText(crash.detail)}
+
+
+
+ Instance
+
+
+
+ View logs
+
+
+
+ );
+}
+
+const PHASE_RING: Record = {
+ deploying: { color: INFO_COLOR },
+ deployed: { color: STATUS_TONE_COLOR.ok, icon: CircleCheckIcon },
+ stalled: { color: STATUS_TONE_COLOR.warn, icon: HourglassIcon },
+ failed: { color: STATUS_TONE_COLOR.error, icon: CircleXIcon },
+};
+
+/** While deploying: a slow pulse instead of fake progress, and what to expect. */
+/**
+ * An indeterminate bar sliding across the card. Animated with the Web
+ * Animations API because the host compiles no keyframes the plugin could use;
+ * it stays still for people who ask for reduced motion.
+ */
+function SlidingBar() {
+ const bar = useRef(null);
+ useEffect(() => {
+ const el = bar.current;
+ if (!el || typeof el.animate !== 'function') return;
+ if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
+ const animation = el.animate([{ transform: 'translateX(-100%)' }, { transform: 'translateX(300%)' }], {
+ duration: 1600,
+ easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ iterations: Infinity,
+ });
+ return () => animation.cancel();
+ }, []);
+ return (
+
+
+
+ );
+}
+
+function DeployingNote() {
+ return (
+ <>
+
+
+
+ Most deploys finish in under a minute. CPU, memory and network appear once an instance is ready.
+
+ >
+ );
+}
+
+const clock = (at: Date) => at.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
+
+function stepColor(state: TimelineStep['state']): string {
+ return state === 'done' ? STATUS_TONE_COLOR.ok : state === 'failed' ? STATUS_TONE_COLOR.error : STATUS_TONE_COLOR.warn;
+}
+
+/** Deployed: the steps on a line, each with its time and the gap from the one before. */
+function TimelineTrack({ steps }: { steps: TimelineStep[] }) {
+ return (
+
+ {steps.map((step, index) => {
+ const gap = index > 0 ? Math.round((step.at.getTime() - steps[index - 1].at.getTime()) / 1000) : undefined;
+ return (
+
+
+
+ {step.label}
+
+ {clock(step.at)}
+ {gap !== undefined ? ` · +${gap}s` : ''}
+
+
+
+ );
+ })}
+
+ );
+}
+
+/** Stalled and failed: steps reached so far, ending in the one it's stuck on. */
+function SoFar({ steps }: { steps: TimelineStep[] }) {
+ return (
+
+ So far
+ {steps.map((step) => (
+
+
+ {step.label}
+
+ {step.state === 'waiting' ? 'since ' : ''}
+ {clock(step.at)}
+
+
+ ))}
+
+ );
+}
+
+export interface OverviewAction {
+ label: string;
+ href: string;
+ primary?: boolean;
+}
+
+/** Stalled and failed: the cause in plain words, the platform's message, and what to try. */
+function ProblemBand({ problem, actions }: { problem: InstanceProblem; actions: OverviewAction[] }) {
+ const color = STATUS_TONE_COLOR[problem.tone];
+ return (
+
+
+
+
+ {problem.title}
+ {actions.length > 0 ? (
+
+ {actions.map((action) => (
+
+ {action.label}
+
+ ))}
+
+ ) : null}
+
+ {problem.message ? (
+
+ {problem.message}
+
+ ) : null}
+ {problem.hints.length > 0 ? (
+
+ {problem.hints.map((hint) => (
+
+
+ •
+
+ {hint}
+
+ ))}
+
+ ) : null}
+
+
+ );
+}
+
+function lastValue(points: readonly ChartDataPoint[] | undefined): number | undefined {
+ for (let i = (points?.length ?? 0) - 1; i >= 0; i--) {
+ const value = points?.[i]?.value;
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
+ }
+ return undefined;
+}
+
+/** Two series added point by point; they share timestamps because one range query samples both. */
+function addSeries(
+ a: readonly ChartDataPoint[] | undefined,
+ b: readonly ChartDataPoint[] | undefined
+): ChartDataPoint[] | undefined {
+ if (!a && !b) return undefined;
+ const sums = new Map();
+ for (const point of [...(a ?? []), ...(b ?? [])]) {
+ const prev = sums.get(point.timestamp);
+ sums.set(point.timestamp, prev ? { ...prev, value: prev.value + point.value } : { ...point });
+ }
+ return [...sums.values()].sort((x, y) => x.timestamp - y.timestamp);
+}
+
+/**
+ * One stat: label and hint, the value, then its sparkline (or instance
+ * squares). `loading` holds each line's place with a bone the same height, so
+ * nothing reads as zero before the numbers arrive and nothing moves when they do.
+ */
+function StatCell({
+ label,
+ hint,
+ value,
+ loading = false,
+ children,
+}: {
+ label: string;
+ hint?: string;
+ value: ReactNode;
+ loading?: boolean;
+ children?: ReactNode;
+}) {
+ return (
+
+
+ {label}
+ {loading && hint ? (
+
+
+
+ ) : hint ? (
+ {hint}
+ ) : null}
+
+ {loading ? (
+
+
+
+ ) : (
+ {value}
+ )}
+ {loading ? : children}
+
+ );
+}
+
+function Stats({
+ projectId,
+ instances,
+ identityLabel,
+ metricKeys,
+ metricsDenied,
+ proxyId,
+ allocation,
+ totalCount,
+ instancesLoading,
+ identityLoading,
+ publishedLoading,
+}: {
+ projectId?: string;
+ instances: readonly Instance[];
+ identityLabel?: InstanceIdentityLabel;
+ metricKeys: readonly string[];
+ metricsDenied: boolean;
+ proxyId?: string;
+ allocation: Allocation;
+ totalCount: number;
+ instancesLoading: boolean;
+ identityLoading: boolean;
+ publishedLoading: boolean;
+}) {
+ const range = useOverviewRange('1h');
+ const enabled = !!projectId && !!identityLabel && metricKeys.length > 0 && !metricsDenied;
+ const cpuQuery =
+ enabled && projectId && identityLabel ? workloadCpuSumQuery(projectId, identityLabel, metricKeys) : undefined;
+ const memoryQuery =
+ enabled && projectId && identityLabel ? workloadMemorySumQuery(projectId, identityLabel, metricKeys) : undefined;
+ const rpsQuery = projectId && proxyId && !metricsDenied ? albRpsQuery(projectId, proxyId) : undefined;
+ const cpu = usePrometheusChart(projectId, cpuQuery, range.timeRange, { enabled: !!cpuQuery, keepPrevious: true });
+ const memory = usePrometheusChart(projectId, memoryQuery, range.timeRange, {
+ enabled: !!memoryQuery,
+ keepPrevious: true,
+ });
+ const rps = usePrometheusChart(projectId, rpsQuery, range.timeRange, { enabled: !!rpsQuery, keepPrevious: true });
+ const network = useWorkloadNetworkMetrics(metricsDenied ? undefined : projectId, instances);
+ const rx = usePrometheusChart(projectId, network.receiveTotalQuery, range.timeRange, {
+ enabled: !!network.receiveTotalQuery,
+ keepPrevious: true,
+ });
+ const tx = usePrometheusChart(projectId, network.transmitTotalQuery, range.timeRange, {
+ enabled: !!network.transmitTotalQuery,
+ keepPrevious: true,
+ });
+
+ const cpuPoints = cpu.error ? undefined : cpu.data?.series[0]?.data;
+ const memoryPoints = memory.error ? undefined : memory.data?.series[0]?.data;
+ const rpsPoints = rps.error ? undefined : rps.data?.series[0]?.data;
+ const cpuNow = lastValue(cpuPoints);
+ const memoryNow = lastValue(memoryPoints);
+ const rpsNow = lastValue(rpsPoints);
+ const rxPoints = rx.error ? undefined : rx.data?.series[0]?.data;
+ const txPoints = tx.error ? undefined : tx.data?.series[0]?.data;
+ const rxNow = lastValue(rxPoints);
+ const txNow = lastValue(txPoints);
+ const ioPoints = useMemo(() => addSeries(rxPoints, txPoints), [rxPoints, txPoints]);
+ // Each stat's queries wait on the instances and the metrics label, so it is
+ // loading until those are in and its own query has answered.
+ const metricsPending = !metricsDenied && (instancesLoading || identityLoading);
+ const cpuLoading = !cpuPoints && (metricsPending || cpu.isLoading);
+ const memoryLoading = !memoryPoints && (metricsPending || memory.isLoading);
+ const networkLoading =
+ !metricsDenied && !rxPoints && !txPoints && (instancesLoading || network.isLoading || rx.isLoading || tx.isLoading);
+ const rpsLoading = !metricsDenied && !rpsPoints && (publishedLoading || rps.isLoading);
+ const showNetwork = !!network.receiveTotalQuery || networkLoading;
+ const showRequests = !!proxyId || (publishedLoading && !metricsDenied);
+
+ const readiness = useMemo(() => instances.map((instance) => instanceReadiness(instance)), [instances]);
+ const ready = readiness.filter((r) => r.tone === 'ok').length;
+ const notReady = readiness.length - ready;
+
+ const spark = (points: readonly ChartDataPoint[] | undefined, loading: boolean, color?: string, format: 'cores' | 'bytes' | 'requestsPerSecond' | 'bitsPerSecond' = 'cores', allocated?: number, flat = false) => (
+
+ );
+
+ return (
+
+ 0 ? `${notReady} not ready` : undefined}
+ value={`${ready} / ${totalCount}`}
+ loading={instancesLoading && instances.length === 0}>
+
+ {readiness.slice(0, 24).map((r, index) => (
+
+ ))}
+
+
+
+ {spark(cpuPoints, cpu.isLoading, undefined, 'cores', allocation.cores)}
+
+
+ {spark(memoryPoints, memory.isLoading, 'var(--color-chart-1)', 'bytes', allocation.memoryBytes)}
+
+ {showNetwork ? (
+
+ {spark(ioPoints, rx.isLoading || tx.isLoading || network.isLoading, 'var(--color-chart-3)', 'bitsPerSecond', undefined, true)}
+
+ ) : null}
+ {showRequests ? (
+
+ {spark(rpsPoints, rps.isLoading, INFO_COLOR, 'requestsPerSecond', undefined, true)}
+
+ ) : null}
+
+ );
+}
+
+export interface WorkloadOverviewCardProps {
+ status: OverviewStatus;
+ /** A deploy state replaces the stats while it lasts; see `deployState`. */
+ phase?: DeployPhase;
+ /** Deployed: the full timeline. Stalled and failed: the steps so far. */
+ timeline?: TimelineStep[];
+ /** Stalled and failed: what is wrong. */
+ problem?: InstanceProblem;
+ problemActions?: OverviewAction[];
+ image?: string;
+ updatedAt: Date;
+ now: number;
+ crash?: OverviewCrash;
+ projectId?: string;
+ instances: readonly Instance[];
+ totalCount: number;
+ identityLabel?: InstanceIdentityLabel;
+ metricKeys: readonly string[];
+ metricsDenied: boolean;
+ proxyId?: string;
+ allocation: Allocation;
+ /** Still loading: the stats hold their places rather than read as zero. */
+ instancesLoading: boolean;
+ identityLoading: boolean;
+ publishedLoading: boolean;
+}
+
+export function WorkloadOverviewCard(props: WorkloadOverviewCardProps) {
+ const { status, phase, image, updatedAt, now, crash } = props;
+ const attention = status.tone === 'warn' || status.tone === 'error';
+ const short = imageShortName(image);
+ return (
+
+
+
+
+
+ {status.title}
+
+ {status.detail}
+
+
+
+
+
+ Updated {formatAgo(updatedAt, now)}
+
+ {short ? (
+
+ {short}
+
+ ) : null}
+
+
+ {phase === 'deploying' ? : null}
+ {phase === 'deployed' && props.timeline ? : null}
+ {(phase === 'stalled' || phase === 'failed') && props.problem ? (
+
+ ) : null}
+ {(phase === 'stalled' || phase === 'failed') && props.timeline ? : null}
+ {!phase && crash ? : null}
+ {phase ? null : (
+
+ )}
+
+ );
+}
diff --git a/ui/consumer/src/components/workload-page-chrome.tsx b/ui/consumer/src/components/workload-page-chrome.tsx
index 167b2772..ceed8a25 100644
--- a/ui/consumer/src/components/workload-page-chrome.tsx
+++ b/ui/consumer/src/components/workload-page-chrome.tsx
@@ -2,11 +2,11 @@
* Shared workload layout chrome: breadcrumbs, title, and tab bar.
* Used by the splat layout at `:workloadName/*`.
*
- * The workload's public URL sits beside its name, so where to reach it is
- * visible from every tab rather than buried in the overview.
+ * No URL sits by the name: a hostname belongs to a load balancer, and the
+ * Overview's Load balancers card lists each one that reaches the workload.
*/
import { PluginTabs, type PluginTab } from './plugin-tabs';
-import { Button, LinkButton } from '@datum-cloud/datum-ui/button';
+import { Button } from '@datum-cloud/datum-ui/button';
import {
Breadcrumb,
BreadcrumbItem,
@@ -16,10 +16,8 @@ import {
BreadcrumbSeparator,
} from '@datum-cloud/datum-ui/breadcrumb';
import { PageTitle } from '@datum-cloud/datum-ui/page-title';
-import { Skeleton } from '@datum-cloud/datum-ui/skeleton';
-import { Tooltip } from '@datum-cloud/datum-ui/tooltip';
import { Icon } from '@datum-cloud/datum-ui/icons';
-import { HomeIcon, SquareArrowOutUpRightIcon, Trash2Icon } from 'lucide-react';
+import { HomeIcon, Trash2Icon } from 'lucide-react';
import { Link } from 'react-router';
export function workloadDetailTabs(
@@ -36,62 +34,6 @@ export function workloadDetailTabs(
];
}
-const VISIT_ICON = ;
-
-/**
- * The page's "go to my app" action, alongside the URL by the name. A real link
- * once the workload serves; disabled, with the reason on hover, while it comes
- * up; and holding its place while that's still being worked out.
- */
-function VisitButton({
- visit,
- loading,
-}: {
- visit?: { href: string; live: boolean; status?: string };
- loading: boolean;
-}) {
- if (visit?.live) {
- return (
-
- Visit
-
- );
- }
- if (!visit && !loading) return null;
- const button = (
-
- Visit
-
- );
- // A disabled button gets no pointer events, so the tooltip hangs off a wrapper.
- return visit?.status ? (
-
-
- {button}
-
-
- ) : (
- button
- );
-}
-
export function WorkloadPageChrome({
projectHref,
workloadsHref,
@@ -99,9 +41,6 @@ export function WorkloadPageChrome({
metricsHref,
logsHref,
titleName,
- url,
- urlLoading = false,
- visit,
onDelete,
deleteLoading = false,
children,
@@ -112,15 +51,6 @@ export function WorkloadPageChrome({
metricsHref: string;
logsHref: string;
titleName: string;
- /** The workload's public URL (`WorkloadUrl`), when a load balancer publishes it. */
- url?: React.ReactNode;
- /**
- * Still finding out whether there is a URL: hold its place (and the Visit
- * button's) so nothing jumps.
- */
- urlLoading?: boolean;
- /** Opens the workload's URL. `live` is false until it's serving; `status` says why. */
- visit?: { href: string; live: boolean; status?: string };
/** Omitted when the user can't delete this workload — the button is hidden. */
onDelete?: () => void;
/**
@@ -161,47 +91,21 @@ export function WorkloadPageChrome({
// Stacked on a phone that just indents them; `gap-3` above already
// spaces the two side by side. (`px-0` because `pl-0` isn't compiled.)
actionsClassName="px-0"
- description={
- url || urlLoading ? (
- // On a phone the URL always takes its own line under the name, so its
- // loading placeholder sits exactly where it will land. From `sm` up
- // they share a line, with a separator.
-
- {titleName}
-
- ·
-
- {url ?? (
-
- )}
-
- ) : (
- titleName
- )
- }
- descriptionClassName={url || urlLoading ? undefined : 'break-all'}
+ description={titleName}
+ descriptionClassName="break-all"
actions={
- visit || urlLoading || onDelete || deleteLoading ? (
-
-
- {onDelete || deleteLoading ? (
- }
- onClick={onDelete}
- disabled={!onDelete}
- aria-busy={deleteLoading || undefined}
- data-e2e="workload-delete">
- Delete
-
- ) : null}
-
+ onDelete || deleteLoading ? (
+ }
+ onClick={onDelete}
+ disabled={!onDelete}
+ aria-busy={deleteLoading || undefined}
+ data-e2e="workload-delete">
+ Delete
+
) : undefined
}
/>
diff --git a/ui/consumer/src/components/workload-table.tsx b/ui/consumer/src/components/workload-table.tsx
index 5533991e..ab90d57d 100644
--- a/ui/consumer/src/components/workload-table.tsx
+++ b/ui/consumer/src/components/workload-table.tsx
@@ -276,6 +276,7 @@ export function WorkloadTable({
format="cores"
allocated={allocationByWorkload[row.original.name]?.cores}
compact
+ hideValue
/>
),
},
@@ -292,6 +293,7 @@ export function WorkloadTable({
allocated={allocationByWorkload[row.original.name]?.memoryBytes}
color="var(--color-chart-1)"
compact
+ hideValue
/>
),
},
diff --git a/ui/consumer/src/lib/alb-status.test.ts b/ui/consumer/src/lib/alb-status.test.ts
index 1ef63106..75983bdf 100644
--- a/ui/consumer/src/lib/alb-status.test.ts
+++ b/ui/consumer/src/lib/alb-status.test.ts
@@ -27,6 +27,7 @@ function alb(conditions: ResourceCondition[], services = [serviceReady]): Connec
createdAt: new Date(T0),
conditions,
services,
+ routes: [],
};
}
diff --git a/ui/consumer/src/lib/api.test.ts b/ui/consumer/src/lib/api.test.ts
index 560de266..ccc818f7 100644
--- a/ui/consumer/src/lib/api.test.ts
+++ b/ui/consumer/src/lib/api.test.ts
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { focusManager, MutationObserver, QueryClient, QueryObserver } from '@tanstack/react-query';
import {
+ albRoutes,
computeEntitlementQueryOptions,
createDemoWorkloadMutationOptions,
deleteWorkloadMutationOptions,
@@ -8,7 +9,12 @@ import {
workloadsQueryOptions,
type ComputeEntitlement,
type DeleteWorkloadInput,
+ type RawHttpProxy,
+ type RawNetworkService,
+ servicePortName,
+ workloadServicePayload,
} from './api';
+import type { Workload } from '../schema';
const PROJECT_ID = 'p-1';
const ENTITLEMENT_KEY = [PLUGIN_ID, 'compute-entitlement', PROJECT_ID];
@@ -184,3 +190,109 @@ describe('host caches after writes', () => {
expect(domainsInvalidated()).toBe(true);
});
});
+
+describe('albRoutes', () => {
+ const service: RawNetworkService = {
+ metadata: { name: 'web' },
+ spec: { ports: [{ name: 'http', port: 8080, protocol: 'TCP' }] },
+ };
+ const proxy = (rules: NonNullable['rules']): RawHttpProxy => ({
+ metadata: { name: 'web-alb' },
+ spec: { rules },
+ });
+ const toWorkload = { networkService: { name: 'web', port: 'http' } };
+
+ test('a rule with no matches routes / to the named port', () => {
+ expect(albRoutes(proxy([{ backends: [toWorkload] }]), [service])).toEqual([
+ { paths: ['/'], narrowed: false, port: { name: 'http', number: 8080, appProtocol: undefined }, percent: undefined, sharedWith: 0 },
+ ]);
+ });
+
+ test('keeps only the rules that send traffic to this workload', () => {
+ const routes = albRoutes(
+ proxy([
+ { matches: [{ path: { type: 'PathPrefix', value: '/api' } }], backends: [toWorkload] },
+ { matches: [{ path: { type: 'PathPrefix', value: '/other' } }], backends: [{ endpoint: 'https://example.com' }] },
+ ]),
+ [service]
+ );
+ expect(routes.map((r) => r.paths)).toEqual([['/api']]);
+ });
+
+ test("a split rule gives this workload's share and how many backends share it", () => {
+ const [route] = albRoutes(
+ proxy([{ backends: [{ ...toWorkload, weight: 1 }, { endpoint: 'https://a.example', weight: 3 }] }]),
+ [service]
+ );
+ expect(route.percent).toBe(25);
+ expect(route.sharedWith).toBe(1);
+ });
+
+ test('unset weights count as 1', () => {
+ const [route] = albRoutes(proxy([{ backends: [toWorkload, { endpoint: 'https://a.example' }] }]), [service]);
+ expect(route.percent).toBe(50);
+ });
+
+ test('marks rules narrowed by headers, query or method, and regular expressions', () => {
+ const [route] = albRoutes(
+ proxy([
+ {
+ matches: [{ path: { type: 'RegularExpression', value: '/v[0-9]+' }, method: 'GET' }],
+ backends: [toWorkload],
+ },
+ ]),
+ [service]
+ );
+ expect(route.paths).toEqual(['~ /v[0-9]+']);
+ expect(route.narrowed).toBe(true);
+ });
+
+ test('a port the service does not declare keeps its name without a number', () => {
+ const [route] = albRoutes(
+ proxy([{ backends: [{ networkService: { name: 'web', port: 'grpc' } }] }]),
+ [service]
+ );
+ expect(route.port).toEqual({ name: 'grpc', number: undefined, appProtocol: undefined });
+ });
+
+ test('a proxy with no backend on this workload has no routes', () => {
+ expect(albRoutes(proxy([{ backends: [{ endpoint: 'https://a.example' }] }]), [service])).toEqual([]);
+ expect(albRoutes(proxy(undefined), [])).toEqual([]);
+ });
+});
+
+describe('workloadServicePayload', () => {
+ const workload = (namedPorts: Workload['namedPorts']) =>
+ ({ name: 'cpu-mem-wave', uid: 'uid-1', namedPorts }) as unknown as Workload;
+
+ test("matches the CLI's service: named for the workload, labelled, selecting its instances", () => {
+ const payload = workloadServicePayload(workload([{ name: 'http', port: 3000, protocol: 'TCP' }]));
+ expect(payload?.metadata).toMatchObject({
+ name: 'cpu-mem-wave',
+ labels: {
+ 'compute.datumapis.com/workload-name': 'cpu-mem-wave',
+ 'compute.datumapis.com/workload-uid': 'uid-1',
+ },
+ });
+ expect(payload?.spec).toEqual({
+ networkInterfaces: { selector: { matchLabels: { 'compute.datumapis.com/workload-name': 'cpu-mem-wave' } } },
+ ports: [{ name: 'http', port: 3000, protocol: 'TCP' }],
+ });
+ });
+
+ test('cleans port names into DNS labels and skips UDP and duplicates', () => {
+ const payload = workloadServicePayload(
+ workload([
+ { name: 'Web_UI', port: 8080, protocol: 'TCP' },
+ { name: 'web-ui', port: 8081, protocol: 'TCP' },
+ { name: 'dns', port: 53, protocol: 'UDP' },
+ ])
+ );
+ expect(payload?.spec.ports).toEqual([{ name: 'web-ui', port: 8080, protocol: 'TCP' }]);
+ });
+
+ test('is undefined with no named TCP port', () => {
+ expect(workloadServicePayload(workload([]))).toBeUndefined();
+ expect(servicePortName('--')).toBeUndefined();
+ });
+});
diff --git a/ui/consumer/src/lib/api.ts b/ui/consumer/src/lib/api.ts
index 7afe10c3..a013b3bb 100644
--- a/ui/consumer/src/lib/api.ts
+++ b/ui/consumer/src/lib/api.ts
@@ -19,10 +19,11 @@
* - Client-side 403 handling instead of PR #1315's server-loader RBAC gate
* (`runDetailLoader`, portal-internal) — see `ApiError` below.
*/
+import { workloadSettling } from './workload-settling';
import { toInstance, toInstanceList, toWorkload, toWorkloadList, INSTANCE_LABELS } from '../adapter';
import { anyAlbProvisioning } from './alb-status';
import type { RawInstance, RawInstanceList, RawWorkload, RawWorkloadList } from '../adapter';
-import type { Instance, Workload } from '../schema';
+import type { Instance, InstanceCondition, Workload } from '../schema';
import { useEffect } from 'react';
import {
mutationOptions,
@@ -84,6 +85,7 @@ export function getProjectScopedBase(projectId: string): string {
// in the compute repo.
const WORKLOADS_PATH = '/apis/compute.datumapis.com/v1alpha/namespaces/default/workloads';
const INSTANCES_PATH = '/apis/compute.datumapis.com/v1alpha/namespaces/default/instances';
+const WORKLOAD_DEPLOYMENTS_PATH = '/apis/compute.datumapis.com/v1alpha/namespaces/default/workloaddeployments';
async function proxyFetch(projectId: string, path: string): Promise {
const url = `${getProjectScopedBase(projectId)}${path}`;
@@ -530,10 +532,10 @@ export function useWorkload(
enabled: !!projectId && !!name,
queryFn: () => fetchWorkload(projectId as string, name as string),
...POLLED_QUERY,
+ // Fast while deploying, and while instances are still coming up after
+ // the workload reports Available (a placement exists seconds before they serve).
refetchInterval: (query) =>
- query.state.data?.health === 'Deploying' && !query.state.data.deleting
- ? DEPLOYING_REFETCH_INTERVAL_MS
- : REFETCH_INTERVAL_MS,
+ query.state.data && workloadSettling(query.state.data) ? DEPLOYING_REFETCH_INTERVAL_MS : REFETCH_INTERVAL_MS,
});
}
@@ -608,6 +610,53 @@ export function useInstance(
});
}
+// ── Workload deployments ─────────────────────────────────────────────────
+
+/** One WorkloadDeployment (a placement in one location), reduced to what explains a stalled deploy. */
+export interface WorkloadDeploymentSummary {
+ name: string;
+ location?: string;
+ conditions: InstanceCondition[];
+}
+
+interface RawWorkloadDeploymentList {
+ items?: Array<{
+ metadata?: { name?: string; labels?: Record };
+ status?: { conditions?: InstanceCondition[] };
+ }>;
+}
+
+async function fetchWorkloadDeployments(projectId: string, workloadUid: string): Promise {
+ const query = new URLSearchParams({ labelSelector: `compute.datumapis.com/workload-uid=${workloadUid}` });
+ const body = await proxyFetch(projectId, `${WORKLOAD_DEPLOYMENTS_PATH}?${query.toString()}`);
+ return (body.items ?? []).map((item) => ({
+ name: item.metadata?.name ?? '',
+ location: item.metadata?.labels?.['compute.datumapis.com/location'],
+ conditions: item.status?.conditions ?? [],
+ }));
+}
+
+/**
+ * A workload's deployments, read only while it is still coming up: some
+ * causes of a stalled deploy (a missing Secret, say) are reported only here,
+ * not on the Workload or its instances.
+ */
+export function useWorkloadDeployments(
+ projectId: string | undefined,
+ workloadUid: string | undefined,
+ enabled: boolean
+): UseQueryResult {
+ return useQuery({
+ queryKey: [PLUGIN_ID, 'workload-deployments', projectId, workloadUid],
+ enabled: enabled && !!projectId && !!workloadUid,
+ queryFn: () => fetchWorkloadDeployments(projectId as string, workloadUid as string),
+ ...POLLED_QUERY,
+ refetchInterval: DEPLOYING_REFETCH_INTERVAL_MS,
+ // A missing permission or an older API just means no extra detail.
+ retry: false,
+ });
+}
+
// ── Connected ALB (HTTPProxy via NetworkService) ─────────────────────────
//
// A workload is on a public URL when an HTTPProxy (ALB) names a NetworkService
@@ -630,17 +679,19 @@ export interface ResourceCondition {
lastTransitionTime?: string;
}
-interface RawNetworkService {
+export interface RawNetworkService {
metadata?: { name?: string; labels?: Record };
spec?: {
networkInterfaces?: {
selector?: { matchLabels?: Record };
};
+ /** The ports members answer on; a backend names one of them. */
+ ports?: Array<{ name?: string; port?: number; protocol?: string; appProtocol?: string }>;
};
status?: { conditions?: ResourceCondition[] };
}
-interface RawHttpProxy {
+export interface RawHttpProxy {
metadata?: {
name?: string;
creationTimestamp?: string;
@@ -650,8 +701,18 @@ interface RawHttpProxy {
spec?: {
hostnames?: string[];
rules?: Array<{
+ /** Gateway API `HTTPRouteMatch`es; any one matching selects the rule. Unset means `/`. */
+ matches?: Array<{
+ path?: { type?: string; value?: string };
+ headers?: unknown[];
+ queryParams?: unknown[];
+ method?: string;
+ }>;
backends?: Array<{
- networkService?: { name?: string };
+ networkService?: { name?: string; port?: string };
+ endpoint?: string;
+ /** Share of the rule's requests, relative to its other backends. Unset is 1. */
+ weight?: number;
}>;
}>;
};
@@ -672,6 +733,22 @@ export interface ConnectedAlb {
conditions: ResourceCondition[];
/** This workload's NetworkServices the proxy backends to, with their `Ready` condition. */
services: Array<{ name: string; ready?: ResourceCondition }>;
+ /** The proxy's rules that send traffic to this workload, in spec order. */
+ routes: AlbRoute[];
+}
+
+/** One rule of a load balancer that sends traffic to the workload. */
+export interface AlbRoute {
+ /** The paths the rule matches, as written: `/`, `/api`. */
+ paths: string[];
+ /** The rule also matches on headers, query parameters or method. */
+ narrowed: boolean;
+ /** The workload port the rule sends to, resolved from the service's named port. */
+ port?: { name: string; number?: number; appProtocol?: string };
+ /** This workload's share of the rule's requests when other backends share it, 0-100. */
+ percent?: number;
+ /** How many other backends share the rule. */
+ sharedWith: number;
}
export interface PublishedUrl {
@@ -735,6 +812,47 @@ function workloadNameForService(svc: RawNetworkService): string | undefined {
);
}
+/** A match's path as a person reads it. A regular expression is marked, since `/a.*` reads as a literal. */
+function matchPath(path: { type?: string; value?: string } | undefined): string {
+ const value = path?.value || '/';
+ return path?.type === 'RegularExpression' ? `~ ${value}` : value;
+}
+
+/**
+ * The proxy's rules that send traffic to one of `services` (the workload's own
+ * NetworkServices), each with the paths it matches, the workload port it sends
+ * to and, when the rule splits traffic with other backends, this workload's
+ * share. Weights follow the Gateway API: unset is 1.
+ */
+export function albRoutes(proxy: RawHttpProxy, services: RawNetworkService[]): AlbRoute[] {
+ const byName = new Map(services.flatMap((svc) => (svc.metadata?.name ? [[svc.metadata.name, svc] as const] : [])));
+ return (proxy.spec?.rules ?? []).flatMap((rule) => {
+ const backends = rule.backends ?? [];
+ const own = backends.filter((b) => b.networkService?.name && byName.has(b.networkService.name));
+ if (own.length === 0) return [];
+ const weight = (b: (typeof backends)[number]) => b.weight ?? 1;
+ const total = backends.reduce((sum, b) => sum + weight(b), 0);
+ const ownWeight = own.reduce((sum, b) => sum + weight(b), 0);
+ const matches = rule.matches?.length ? rule.matches : [{}];
+ const portName = own[0].networkService?.port;
+ const servicePort = portName
+ ? byName.get(own[0].networkService!.name!)?.spec?.ports?.find((p) => p.name === portName)
+ : undefined;
+ const sharedWith = backends.length - own.length;
+ return [
+ {
+ paths: [...new Set(matches.map((m) => matchPath(m.path)))],
+ narrowed: matches.some((m) => !!m.method || !!m.headers?.length || !!m.queryParams?.length),
+ port: portName
+ ? { name: portName, number: servicePort?.port, appProtocol: servicePort?.appProtocol }
+ : undefined,
+ percent: sharedWith > 0 && total > 0 ? Math.round((ownWeight / total) * 100) : undefined,
+ sharedWith,
+ },
+ ];
+ });
+}
+
/** `services` are the workload's own NetworkServices the proxy names as backends. */
function toConnectedAlb(proxy: RawHttpProxy, services: RawNetworkService[] = []): ConnectedAlb | null {
const proxyName = proxy.metadata?.name ?? '';
@@ -752,6 +870,7 @@ function toConnectedAlb(proxy: RawHttpProxy, services: RawNetworkService[] = [])
name: svc.metadata?.name ?? '',
ready: svc.status?.conditions?.find((c) => c.type === 'Ready'),
})),
+ routes: albRoutes(proxy, services),
};
}
@@ -1023,6 +1142,86 @@ export function useDeletePermissions(
return { ...(query.data ?? NO_DELETE_PERMISSIONS), isLoading: query.isPending };
}
+// ── Load balancer backend ────────────────────────────────────────────────
+
+/**
+ * A port name a NetworkService (and a load balancer backend naming it) will
+ * accept: a DNS label. Mirrors the CLI's `url.PortName`. Undefined when
+ * nothing usable is left.
+ */
+export function servicePortName(name: string): string | undefined {
+ const cleaned = name
+ .toLowerCase()
+ .replace(/[^a-z0-9]/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 63)
+ .replace(/-+$/, '');
+ return cleaned || undefined;
+}
+
+/**
+ * The NetworkService that lets a load balancer reach `workload`: the same
+ * object `datumctl compute url` makes (named for the workload, labelled with
+ * its name and UID, selecting its instances), exposing each named TCP port.
+ * Undefined when the workload has no named TCP port to expose.
+ */
+export function workloadServicePayload(workload: Workload) {
+ const seen = new Set();
+ const ports = workload.namedPorts.flatMap((p) => {
+ const name = servicePortName(p.name);
+ if (!name || seen.has(name) || p.protocol.toUpperCase() !== 'TCP') return [];
+ seen.add(name);
+ return [{ name, port: p.port, protocol: 'TCP' }];
+ });
+ if (ports.length === 0) return undefined;
+ return {
+ apiVersion: 'networking.datumapis.com/v1alpha',
+ kind: 'NetworkService',
+ metadata: {
+ name: workload.name,
+ namespace: 'default',
+ labels: {
+ [INSTANCE_LABELS.workloadName]: workload.name,
+ 'compute.datumapis.com/workload-uid': workload.uid,
+ },
+ ownerReferences: [
+ {
+ apiVersion: 'compute.datumapis.com/v1alpha',
+ kind: 'Workload',
+ name: workload.name,
+ uid: workload.uid,
+ controller: false,
+ blockOwnerDeletion: false,
+ },
+ ],
+ },
+ spec: {
+ networkInterfaces: { selector: { matchLabels: { [INSTANCE_LABELS.workloadName]: workload.name } } },
+ ports,
+ },
+ };
+}
+
+/**
+ * Make sure a load balancer can pick `workload` as its backend: creates its
+ * NetworkService unless one already selects it. A 409 means one with its name
+ * exists already, which is as good.
+ */
+export async function ensureWorkloadService(projectId: string, workload: Workload): Promise {
+ const services = await listOrUnavailable(projectId, NETWORKSERVICES_PATH);
+ if (services?.some((svc) => workloadNameForService(svc) === workload.name)) return;
+ const payload = workloadServicePayload(workload);
+ if (!payload) return;
+ const res = await fetch(`${getProjectScopedBase(projectId)}${NETWORKSERVICES_PATH}`, {
+ method: 'POST',
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok && res.status !== 409) {
+ throw new ApiError(res.status, `Request failed (${res.status}): ${NETWORKSERVICES_PATH}`);
+ }
+}
+
// ── Delete mutation ──────────────────────────────────────────────────────
/** DELETEs one object; a 404 means it's already gone, which is what we wanted. */
diff --git a/ui/consumer/src/lib/deploy-presenters.test.ts b/ui/consumer/src/lib/deploy-presenters.test.ts
new file mode 100644
index 00000000..4fbd6803
--- /dev/null
+++ b/ui/consumer/src/lib/deploy-presenters.test.ts
@@ -0,0 +1,160 @@
+import { describe, expect, test } from 'vitest';
+import {
+ DEPLOYED_FOR_MS,
+ deployHeadline,
+ deployState,
+ deployTimeline,
+ stallCause,
+} from './deploy-presenters';
+import type { DeployStatus } from './workload-presenters';
+import type { Instance, Workload } from '../schema';
+
+const T0 = new Date('2026-11-06T12:06:20Z');
+const at = (seconds: number) => new Date(T0.getTime() + seconds * 1000);
+
+function workload(extra: Partial = {}): Workload {
+ return {
+ uid: 'w',
+ name: 'web',
+ createdAt: T0,
+ health: 'Available',
+ currentReplicas: 1,
+ readyReplicas: 1,
+ desiredReplicas: 1,
+ placements: [],
+ placementRegions: [],
+ conditions: [],
+ tags: [],
+ ports: [],
+ locations: ['us-east-1'],
+ networks: [],
+ deleting: false,
+ ...extra,
+ };
+}
+
+function instance(conditions: Instance['conditions'], extra: Partial = {}): Instance {
+ return {
+ uid: 'i',
+ name: 'web-default-us-east-1-0',
+ createdAt: at(5),
+ location: 'us-east-1',
+ ports: [],
+ containers: [],
+ internalIPs: [],
+ addresses: [],
+ externalIPs: [],
+ hostnames: [],
+ networkInterfaces: [],
+ status: 'Pending',
+ conditions,
+ ...extra,
+ };
+}
+
+const ready = instance(
+ [
+ { type: 'Programmed', status: 'True', lastTransitionTime: at(7).toISOString() },
+ { type: 'Ready', status: 'True', lastTransitionTime: at(13).toISOString() },
+ ],
+ { status: 'Available' }
+);
+const booting = instance([
+ { type: 'Programmed', status: 'True', lastTransitionTime: at(7).toISOString() },
+ { type: 'Ready', status: 'False', reason: 'Provisioning' },
+]);
+const badImage = instance(
+ [{ type: 'Ready', status: 'False', reason: 'ImageUnavailable', message: 'could not be pulled', lastTransitionTime: at(9).toISOString() }],
+ { status: 'Failed', image: 'ghcr.io/acme/app:nope' }
+);
+const deploying: DeployStatus = { label: 'Deploying', title: 'Deploying', step: 'Starting instances', tone: 'info', inProgress: true, since: T0 };
+
+describe('deployTimeline', () => {
+ test('rebuilds Created → Placed → Started → Ready from timestamps', () => {
+ expect(deployTimeline(workload(), [ready]).map((s) => [s.label, s.at.toISOString()])).toEqual([
+ ['Created', T0.toISOString()],
+ ['Placed in us-east-1', at(5).toISOString()],
+ ['Started', at(7).toISOString()],
+ ['Ready', at(13).toISOString()],
+ ]);
+ });
+ test('stops where instances have got to and adds the step it is stuck on', () => {
+ const steps = deployTimeline(workload(), [booting], { label: 'Waiting for quota', at: at(5), state: 'waiting' });
+ expect(steps.map((s) => s.label)).toEqual(['Created', 'Placed in us-east-1', 'Started', 'Waiting for quota']);
+ expect(steps.at(-1)?.state).toBe('waiting');
+ });
+});
+
+describe('stallCause', () => {
+ test('names a missing Secret from the deployments', () => {
+ const cause = stallCause(
+ [{ name: 'd', conditions: [{ type: 'ReferencedDataReady', status: 'False', reason: 'SourceNotFound', message: 'Secret "x" not found' }] }],
+ []
+ );
+ expect(cause).toMatchObject({ title: "A Secret or ConfigMap it uses doesn't exist", message: 'Secret "x" not found' });
+ });
+ test('names a quota wait from the instances', () => {
+ const cause = stallCause([], [instance([{ type: 'QuotaGranted', status: 'False', reason: 'QuotaExceeded', message: 'over limit' }])]);
+ expect(cause).toMatchObject({ title: 'Waiting for quota', message: 'over limit' });
+ });
+ test('is undefined when nothing names a cause', () => {
+ expect(stallCause([], [booting], deploying)).toBeUndefined();
+ });
+});
+
+describe('deployState', () => {
+ const base = { deployments: [], healthyCount: 0 };
+ test('deploying while instances boot', () => {
+ const state = deployState({ ...base, workload: workload(), instances: [booting], now: at(10).getTime() });
+ expect(state?.phase).toBe('deploying');
+ });
+ test('stalled once it has taken over two minutes', () => {
+ const state = deployState({ ...base, workload: workload(), instances: [booting], deploy: deploying, now: at(150).getTime() });
+ expect(state?.phase).toBe('stalled');
+ expect(state?.problem?.title).toBe('Starting instances is taking longer than usual');
+ expect(state?.stuckOn).toBe('Starting instances');
+ });
+ test('stalled straight away when a cause is named', () => {
+ const state = deployState({
+ ...base,
+ workload: workload({ health: 'Deploying' }),
+ instances: [booting],
+ deploy: deploying,
+ deployments: [{ name: 'd', conditions: [{ type: 'ReferencedDataReady', status: 'False', reason: 'SourceNotFound' }] }],
+ now: at(10).getTime(),
+ });
+ expect(state?.phase).toBe('stalled');
+ });
+ test('failed when nothing serves and an instance cannot recover', () => {
+ const state = deployState({ ...base, workload: workload(), instances: [badImage], now: at(20).getTime() });
+ expect(state).toMatchObject({ phase: 'failed', failedInstance: badImage });
+ });
+ test('deployed for a minute after the last instance is ready, then nothing', () => {
+ const input = { ...base, workload: workload(), instances: [ready], healthyCount: 1 };
+ expect(deployState({ ...input, now: at(20).getTime() })?.phase).toBe('deployed');
+ expect(deployState({ ...input, now: at(13).getTime() + DEPLOYED_FOR_MS + 1 })).toBeUndefined();
+ });
+ test('no deployed moment for an old workload', () => {
+ const old = workload({ createdAt: new Date(T0.getTime() - 3_600_000) });
+ expect(deployState({ ...base, workload: old, instances: [ready], healthyCount: 1, now: at(20).getTime() })).toBeUndefined();
+ });
+});
+
+describe('deployHeadline', () => {
+ const counts = { healthy: 1, total: 1, locations: 1 };
+ test('deployed reads the time from creation to ready', () => {
+ expect(deployHeadline({ phase: 'deployed', since: T0, readyAt: at(13) }, counts, at(20).getTime())).toEqual({
+ tone: 'ok',
+ title: 'Deployed in 13s',
+ detail: '1/1 instance ready · 1 location',
+ });
+ });
+ test('failed names the problem', () => {
+ const headline = deployHeadline(
+ { phase: 'failed', since: T0, problem: { tone: 'error', title: "The image can't be pulled", hints: [], showLogs: false } },
+ { ...counts, healthy: 0 },
+ at(20).getTime()
+ );
+ expect(headline.detail).toBe("0/1 instance ready · the image can't be pulled");
+ });
+});
diff --git a/ui/consumer/src/lib/deploy-presenters.ts b/ui/consumer/src/lib/deploy-presenters.ts
new file mode 100644
index 00000000..066f1f6b
--- /dev/null
+++ b/ui/consumer/src/lib/deploy-presenters.ts
@@ -0,0 +1,241 @@
+/**
+ * The workload Overview's deploy states. A deploy takes seconds, faster than
+ * the page polls, so these say what can be said truthfully: it's happening,
+ * it worked (with a timeline rebuilt from condition timestamps), it's stuck
+ * and why, or it failed.
+ */
+import type { WorkloadDeploymentSummary } from './api';
+import {
+ DEPLOY_STALLED_AFTER_MS,
+ formatElapsed,
+ instanceProblem,
+ instancesStarting,
+ type DeployStatus,
+ type InstanceProblem,
+ type OverviewStatus,
+} from './workload-presenters';
+import type { Instance, Workload } from '../schema';
+
+export type DeployPhase = 'deploying' | 'deployed' | 'stalled' | 'failed';
+
+/** How long "Deployed in …" stays up after the last instance is ready. */
+export const DEPLOYED_FOR_MS = 60_000;
+
+/** Only a workload this young gets "Deployed in …": older ones are rollouts, not first deploys. */
+export const FIRST_DEPLOY_WINDOW_MS = 15 * 60_000;
+
+type Condition = Instance['conditions'][number];
+
+function transitionTime(condition: Condition | undefined): Date | undefined {
+ if (!condition?.lastTransitionTime) return undefined;
+ const at = new Date(condition.lastTransitionTime);
+ return Number.isNaN(at.getTime()) ? undefined : at;
+}
+
+/** The latest time `type` turned True, once it is True on every instance. */
+function allTrueAt(instances: readonly Instance[], type: string): Date | undefined {
+ if (instances.length === 0) return undefined;
+ let latest: Date | undefined;
+ for (const instance of instances) {
+ const condition = instance.conditions.find((c) => c.type === type);
+ if (condition?.status !== 'True') return undefined;
+ const at = transitionTime(condition) ?? instance.createdAt;
+ if (!latest || at > latest) latest = at;
+ }
+ return latest;
+}
+
+/** When the last instance became Ready, once all are. */
+export function instancesReadyAt(instances: readonly Instance[]): Date | undefined {
+ return allTrueAt(instances, 'Ready');
+}
+
+export interface TimelineStep {
+ label: string;
+ at: Date;
+ state: 'done' | 'waiting' | 'failed';
+}
+
+/**
+ * Created → Placed → Started → Ready, from timestamps the API keeps, so it is
+ * complete even when no poll landed mid-deploy. A step only appears once
+ * every instance has reached it. `end` adds the step a stalled or failed
+ * deploy is stuck on.
+ */
+export function deployTimeline(
+ workload: Workload,
+ instances: readonly Instance[],
+ end?: { label: string; at: Date; state: 'waiting' | 'failed' }
+): TimelineStep[] {
+ const steps: TimelineStep[] = [{ label: 'Created', at: workload.createdAt, state: 'done' }];
+ if (instances.length > 0) {
+ const placedAt = instances.reduce((min, i) => (i.createdAt < min ? i.createdAt : min), instances[0].createdAt);
+ const locations = [...new Set(instances.map((i) => i.location).filter(Boolean))];
+ steps.push({ label: locations.length ? `Placed in ${locations.join(', ')}` : 'Placed', at: placedAt, state: 'done' });
+ }
+ const startedAt = allTrueAt(instances, 'Programmed');
+ if (startedAt) steps.push({ label: 'Started', at: startedAt, state: 'done' });
+ const readyAt = allTrueAt(instances, 'Ready');
+ if (readyAt) steps.push({ label: 'Ready', at: readyAt, state: 'done' });
+ if (end && !readyAt) steps.push(end);
+ return steps;
+}
+
+/** Reasons a referenced Secret or ConfigMap blocks a deploy, in plain words. */
+const REFERENCED_DATA_PROBLEM: Record = {
+ SourceNotFound: {
+ title: "A Secret or ConfigMap it uses doesn't exist",
+ hint: 'Create it in this project, or remove the reference from the workload. The deploy carries on by itself once it exists.',
+ },
+ SourceUnauthorized: {
+ title: "Datum can't read a Secret or ConfigMap it uses",
+ hint: 'Check the permissions on the referenced Secret or ConfigMap.',
+ },
+ SourceTooLarge: {
+ title: 'A Secret or ConfigMap it uses is too large',
+ hint: 'Split it up or trim it, then redeploy.',
+ },
+ SourceNameTooLong: {
+ title: 'A referenced Secret or ConfigMap name is too long',
+ hint: 'Rename it to something shorter and update the reference.',
+ },
+};
+
+const QUOTA_PROBLEM_REASONS = new Set(['QuotaExceeded', 'QuotaNoBudget']);
+
+/**
+ * Why a deploy is stuck, when the API says: a referenced Secret or ConfigMap
+ * (reported only on the deployments), quota (on the instances), or the
+ * workload's own waiting message. Undefined when nothing names a cause.
+ */
+export function stallCause(
+ deployments: readonly WorkloadDeploymentSummary[],
+ instances: readonly Instance[],
+ deploy?: DeployStatus
+): InstanceProblem | undefined {
+ for (const deployment of deployments) {
+ const data = deployment.conditions.find((c) => c.type === 'ReferencedDataReady' && c.status === 'False');
+ const known = data?.reason && Object.hasOwn(REFERENCED_DATA_PROBLEM, data.reason) ? REFERENCED_DATA_PROBLEM[data.reason] : undefined;
+ if (known) return { tone: 'warn', title: known.title, message: data?.message, hints: [known.hint], showLogs: false };
+ }
+ const quota = instances
+ .flatMap((i) => i.conditions)
+ .find((c) => c.type === 'QuotaGranted' && c.status === 'False' && QUOTA_PROBLEM_REASONS.has(c.reason ?? ''));
+ if (quota || deploy?.label === 'Waiting for quota') {
+ return {
+ tone: 'warn',
+ title: 'Waiting for quota',
+ message: quota?.message || deploy?.message,
+ hints: [
+ 'Scale down or delete another workload in this project to free resources.',
+ 'Or ask Datum for a higher limit. The deploy carries on by itself once quota is granted.',
+ ],
+ showLogs: false,
+ };
+ }
+ return undefined;
+}
+
+export interface DeployState {
+ phase: DeployPhase;
+ /** When this deploy started. */
+ since: Date;
+ /** Set once every instance is ready. */
+ readyAt?: Date;
+ /** What is wrong, for stalled and failed. */
+ problem?: InstanceProblem;
+ /** The instance a failure comes from. */
+ failedInstance?: Instance;
+ /** Stalled: the step it is stuck on, short, for the end of the "so far" line. */
+ stuckOn?: string;
+}
+
+export interface DeployStateInput {
+ workload: Workload;
+ instances: readonly Instance[];
+ deploy?: DeployStatus;
+ deployments: readonly WorkloadDeploymentSummary[];
+ healthyCount: number;
+ now: number;
+}
+
+/**
+ * Which deploy state the Overview shows, if any:
+ * - failed: nothing serves and an instance hit a failure that won't clear on its own;
+ * - stalled: still coming up past `DEPLOY_STALLED_AFTER_MS`, or blocked on a named cause;
+ * - deploying: still coming up;
+ * - deployed: a first deploy whose last instance became ready in the last minute.
+ * Undefined once the workload is just running (or being deleted).
+ */
+export function deployState(input: DeployStateInput): DeployState | undefined {
+ const { workload, instances, deploy, deployments, healthyCount, now } = input;
+ if (workload.deleting) return undefined;
+
+ if (healthyCount === 0) {
+ for (const instance of instances) {
+ const problem = instanceProblem(instance);
+ if (problem?.tone === 'error') {
+ return { phase: 'failed', since: workload.createdAt, problem, failedInstance: instance };
+ }
+ }
+ }
+
+ const coming = !!deploy || instancesStarting(instances);
+ if (coming) {
+ const since = deploy?.since ?? workload.updatedAt ?? workload.createdAt;
+ const cause = stallCause(deployments, instances, deploy);
+ const slow = deploy?.tone === 'warning' || now - since.getTime() > DEPLOY_STALLED_AFTER_MS;
+ if (cause || slow) {
+ return {
+ phase: 'stalled',
+ since,
+ stuckOn: cause?.title ?? deploy?.step ?? 'Starting instances',
+ problem: cause ?? {
+ tone: 'warn',
+ title: `${deploy?.step ?? 'Starting instances'} is taking longer than usual`,
+ message: deploy?.message,
+ hints: ['Most deploys finish within a minute.', 'The instances below show how far each one has got.'],
+ showLogs: false,
+ },
+ };
+ }
+ return { phase: 'deploying', since };
+ }
+
+ const readyAt = instancesReadyAt(instances);
+ if (
+ readyAt &&
+ now - readyAt.getTime() < DEPLOYED_FOR_MS &&
+ now - workload.createdAt.getTime() < FIRST_DEPLOY_WINDOW_MS
+ ) {
+ return { phase: 'deployed', since: workload.createdAt, readyAt };
+ }
+ return undefined;
+}
+
+const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
+
+/** The card's headline for a deploy state. */
+export function deployHeadline(
+ state: DeployState,
+ counts: { healthy: number; total: number; locations: number },
+ now: number
+): OverviewStatus {
+ const ready = `${counts.healthy}/${plural(counts.total, 'instance', 'instances')} ready`;
+ switch (state.phase) {
+ case 'deploying':
+ return { tone: 'muted', title: 'Deploying', detail: `Started ${formatElapsed(state.since, now)} ago · ${ready}` };
+ case 'deployed':
+ return {
+ tone: 'ok',
+ title: `Deployed in ${formatElapsed(state.since, (state.readyAt ?? new Date(now)).getTime())}`,
+ detail: `${ready} · ${plural(counts.locations, 'location', 'locations')}`,
+ };
+ case 'stalled':
+ return { tone: 'warn', title: 'Taking longer than expected', detail: `Deploying for ${formatElapsed(state.since, now)} · ${ready}` };
+ case 'failed': {
+ const what = state.problem?.title ?? 'an instance failed';
+ return { tone: 'error', title: 'Deploy failed', detail: `${ready} · ${what.charAt(0).toLowerCase()}${what.slice(1)}` };
+ }
+ }
+}
diff --git a/ui/consumer/src/lib/metrics-queries.test.ts b/ui/consumer/src/lib/metrics-queries.test.ts
index 72a55b9d..fadad6e2 100644
--- a/ui/consumer/src/lib/metrics-queries.test.ts
+++ b/ui/consumer/src/lib/metrics-queries.test.ts
@@ -4,6 +4,8 @@ import {
namesMatcher,
networkInterfaceSeriesLabels,
networkTrafficQuery,
+ networkTrafficTotalQuery,
+ networkThroughputQuery,
networkTrafficSeriesMatch,
networkInterfaceRefs,
} from './metrics-queries';
@@ -112,3 +114,23 @@ describe('seriesByWorkload', () => {
expect(seriesByWorkload(undefined, 'resource_name', { web: ['web-1'] })).toEqual({});
});
});
+
+describe('networkTrafficTotalQuery', () => {
+ test('sums every interface into one series', () => {
+ expect(networkTrafficTotalQuery('proj', 'default', ifaces, 'receive')).toBe(
+ 'sum(rate(datum_vpc_interface_receive_bytes_total{resourcemanager_datumapis_com_project_name="proj",resource_namespace="default",network=~"web-0-eth0|web-0-eth1"}[5m])) * 8'
+ );
+ });
+ test('has no query without refs or a namespace', () => {
+ expect(networkTrafficTotalQuery('proj', 'default', [], 'transmit')).toBeUndefined();
+ expect(networkTrafficTotalQuery('proj', undefined, ifaces, 'transmit')).toBeUndefined();
+ });
+});
+
+describe('networkThroughputQuery', () => {
+ test('adds receive and transmit per interface', () => {
+ expect(networkThroughputQuery('proj', 'default', [ifaces[0]])).toBe(
+ 'sum by (network) (rate({__name__=~"datum_vpc_interface_receive_bytes_total|datum_vpc_interface_transmit_bytes_total",resourcemanager_datumapis_com_project_name="proj",resource_namespace="default",network="web-0-eth0"}[5m])) * 8'
+ );
+ });
+});
diff --git a/ui/consumer/src/lib/metrics-queries.ts b/ui/consumer/src/lib/metrics-queries.ts
index 92c0abfc..a8c0353d 100644
--- a/ui/consumer/src/lib/metrics-queries.ts
+++ b/ui/consumer/src/lib/metrics-queries.ts
@@ -98,6 +98,34 @@ export function networkTrafficQuery(
return `sum by (network) (rate(${metric}{${labels}}[5m])) * 8`;
}
+/**
+ * bits/s summed over every interface, one series. The workload Overview's
+ * Network stat passes every instance's interfaces; the series carry no
+ * workload label, so only instances that exist now are counted.
+ */
+export function networkTrafficTotalQuery(
+ projectId: string,
+ namespace: string | undefined,
+ interfaces: readonly InstanceNetworkInterface[],
+ direction: TrafficDirection
+): string | undefined {
+ const labels = networkTrafficLabels(projectId, namespace, interfaces);
+ if (!labels) return undefined;
+ const metric = direction === 'receive' ? VPC_INTERFACE_RECEIVE_METRIC : VPC_INTERFACE_TRANSMIT_METRIC;
+ return `sum(rate(${metric}{${labels}}[5m])) * 8`;
+}
+
+/** bits/s in and out together, per interface (`network` label). */
+export function networkThroughputQuery(
+ projectId: string,
+ namespace: string | undefined,
+ interfaces: readonly InstanceNetworkInterface[]
+): string | undefined {
+ const labels = networkTrafficLabels(projectId, namespace, interfaces);
+ if (!labels) return undefined;
+ return `sum by (network) (rate({__name__=~"${VPC_INTERFACE_RECEIVE_METRIC}|${VPC_INTERFACE_TRANSMIT_METRIC}",${labels}}[5m])) * 8`;
+}
+
/** Instant selector used to probe whether the metric exists for this instance. */
export function networkTrafficSeriesMatch(
projectId: string,
@@ -197,7 +225,7 @@ export function workloadMemoryByInstanceQuery(
*/
export function seriesByWorkload(
data: FormattedMetricData | undefined,
- label: InstanceIdentityLabel,
+ label: string,
instanceKeysByWorkload: Readonly>
): Record {
if (!data) return {};
diff --git a/ui/consumer/src/lib/network-interface-metrics.test.ts b/ui/consumer/src/lib/network-interface-metrics.test.ts
index 64f153bb..668920ca 100644
--- a/ui/consumer/src/lib/network-interface-metrics.test.ts
+++ b/ui/consumer/src/lib/network-interface-metrics.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'vitest';
-import { resolveNetworkInterfaceMetrics } from './network-interface-metrics';
+import { resolveNetworkInterfaceMetrics, workloadInterfaceSeriesLabels } from './network-interface-metrics';
+import type { Instance } from '../schema';
const ifaces = [{ name: 'eth0', network: 'default', networkInterfaceRef: 'web-0-eth0' }];
@@ -138,3 +139,19 @@ describe('resolveNetworkInterfaceMetrics', () => {
expect(s.transmitQuery).toBeUndefined();
});
});
+
+describe('workloadInterfaceSeriesLabels', () => {
+ const inst = (name: string, refs: string[]) =>
+ ({
+ name,
+ networkInterfaces: refs.map((ref, i) => ({ name: `eth${i}`, networkInterfaceRef: ref })),
+ }) as unknown as Instance;
+ test('labels each interface with its instance, and the interface when there are several', () => {
+ expect(
+ workloadInterfaceSeriesLabels([
+ inst('web-default-us-east-1-0', ['a-eth0']),
+ inst('web-default-eu-west-1-0', ['b-eth0', 'b-eth1']),
+ ])
+ ).toEqual({ 'a-eth0': 'us-east-1-0', 'b-eth0': 'eu-west-1-0 · eth0', 'b-eth1': 'eu-west-1-0 · eth1' });
+ });
+});
diff --git a/ui/consumer/src/lib/network-interface-metrics.ts b/ui/consumer/src/lib/network-interface-metrics.ts
index 115cfbdb..56665127 100644
--- a/ui/consumer/src/lib/network-interface-metrics.ts
+++ b/ui/consumer/src/lib/network-interface-metrics.ts
@@ -6,11 +6,14 @@
import { useMemo } from 'react';
import {
networkInterfaceRefs,
+ networkTrafficTotalQuery,
+ networkThroughputQuery,
networkInterfaceSeriesLabels,
networkTrafficQuery,
networkTrafficSeriesMatch,
} from './metrics-queries';
import { isPrometheusDenied, usePrometheusLabelValues } from './prometheus';
+import { shortInstanceLabels } from './series-view';
import type { Instance, InstanceNetworkInterface } from '../schema';
export interface NetworkInterfaceMetricsState {
@@ -94,3 +97,72 @@ export function useNetworkInterfaceMetrics(
[projectId, namespace, interfaces, probedNetworks, probeDenied, probeError, probeLoading]
);
}
+
+/**
+ * Series labels for a workload's interfaces: the instance's short label
+ * ("us-east-1-0"), plus the interface name when an instance has several.
+ */
+export function workloadInterfaceSeriesLabels(instances: readonly Instance[]): Record {
+ const short = shortInstanceLabels(instances.map((instance) => instance.name));
+ const labels: Record = {};
+ for (const instance of instances) {
+ const refs = instance.networkInterfaces.filter((i) => i.networkInterfaceRef);
+ for (const iface of refs) {
+ const base = short[instance.name] ?? instance.name;
+ labels[iface.networkInterfaceRef as string] = refs.length > 1 ? `${base} · ${iface.name}` : base;
+ }
+ }
+ return labels;
+}
+
+export interface WorkloadNetworkMetricsState extends NetworkInterfaceMetricsState {
+ /** Every interface summed, for a single headline series. */
+ receiveTotalQuery: string | undefined;
+ transmitTotalQuery: string | undefined;
+ /** In and out together, one series per interface. */
+ throughputQuery: string | undefined;
+ /** Each instance's interface refs, to fold interface series into instance rows. */
+ refsByInstance: Record;
+}
+
+/**
+ * Network traffic across a workload's instances: the instance hook's probe
+ * and queries over every instance's interfaces, with per-instance series
+ * labels and summed totals.
+ */
+export function useWorkloadNetworkMetrics(
+ projectId: string | undefined,
+ instances: readonly Instance[]
+): WorkloadNetworkMetricsState {
+ const interfaces = useMemo(() => instances.flatMap((instance) => instance.networkInterfaces), [instances]);
+ const namespace = instances.find((instance) => instance.namespace)?.namespace;
+ const match = projectId ? networkTrafficSeriesMatch(projectId, namespace, interfaces) : undefined;
+ const enabled = !!projectId && networkInterfaceRefs(interfaces).length > 0;
+ const probe = usePrometheusLabelValues(projectId, 'network', match, { enabled });
+ const probeDenied = isPrometheusDenied(probe.error);
+ const probeError = probe.error ?? undefined;
+ const probeLoading = enabled && probe.isLoading;
+ const probedNetworks = probe.data;
+ return useMemo(() => {
+ const state = resolveNetworkInterfaceMetrics({
+ projectId,
+ namespace,
+ interfaces,
+ probedNetworks,
+ probeDenied,
+ probeError,
+ probeLoading,
+ });
+ const live = projectId && !state.unavailable && !state.isDenied;
+ return {
+ ...state,
+ seriesLabels: workloadInterfaceSeriesLabels(instances),
+ receiveTotalQuery: live ? networkTrafficTotalQuery(projectId, namespace, interfaces, 'receive') : undefined,
+ transmitTotalQuery: live ? networkTrafficTotalQuery(projectId, namespace, interfaces, 'transmit') : undefined,
+ throughputQuery: live ? networkThroughputQuery(projectId, namespace, interfaces) : undefined,
+ refsByInstance: Object.fromEntries(
+ instances.map((instance) => [instance.name, networkInterfaceRefs(instance.networkInterfaces)])
+ ),
+ };
+ }, [projectId, namespace, interfaces, instances, probedNetworks, probeDenied, probeError, probeLoading]);
+}
diff --git a/ui/consumer/src/lib/use-workload-overview-state.ts b/ui/consumer/src/lib/use-workload-overview-state.ts
index 999e6e15..148c853d 100644
--- a/ui/consumer/src/lib/use-workload-overview-state.ts
+++ b/ui/consumer/src/lib/use-workload-overview-state.ts
@@ -5,8 +5,15 @@
* topology and instance rows link to.
*/
import { albStatus, anyAlbProvisioning, type AlbStatus } from './alb-status';
+import { DEPLOYED_FOR_MS, instancesReadyAt } from './deploy-presenters';
import { useNow } from './use-now';
-import { deployStatus, overviewHealth, type DeployStatus, type StatusDisplay } from './workload-presenters';
+import {
+ deployStatus,
+ instancesStarting,
+ overviewHealth,
+ type DeployStatus,
+ type StatusDisplay,
+} from './workload-presenters';
import type { WorkloadOutletContext } from '../pages/workload-outlet-context';
import { workloadHealthToBadgeType, type WorkloadHealth } from '../schema';
import { useCallback, useMemo } from 'react';
@@ -71,7 +78,11 @@ export function useWorkloadOverviewState({
const { health, failure, instancesFailing } = overviewHealth(workload, instances);
const albs = published?.proxies;
- const now = useNow(health === 'Deploying' || (!!albs && anyAlbProvisioning(albs)));
+ const readyAt = instancesReadyAt(instances);
+ const justDeployed = !!readyAt && Date.now() - readyAt.getTime() < DEPLOYED_FOR_MS;
+ const now = useNow(
+ health === 'Deploying' || instancesStarting(instances) || justDeployed || (!!albs && anyAlbProvisioning(albs))
+ );
const deploy = instancesFailing ? undefined : deployStatus(workload, now);
const albStatuses = useMemo(
() =>
diff --git a/ui/consumer/src/lib/workload-presenters.test.ts b/ui/consumer/src/lib/workload-presenters.test.ts
index 849e3cc9..b0d85b58 100644
--- a/ui/consumer/src/lib/workload-presenters.test.ts
+++ b/ui/consumer/src/lib/workload-presenters.test.ts
@@ -1,6 +1,15 @@
import { describe, expect, test } from 'vitest';
import {
+ albRowState,
+ routePortText,
compactDetailRows,
+ crashDetail,
+ crashDetailText,
+ firstCrash,
+ formatAgo,
+ instanceNameParts,
+ instanceProblem,
+ instancesStarting,
DEPLOY_STALLED_AFTER_MS,
HEALTH_DOT_COLOR,
STATUS_TONE_COLOR,
@@ -21,6 +30,7 @@ import {
workloadStatusTone,
} from './workload-presenters';
import type { Instance, Workload, WorkloadHealth } from '../schema';
+import { SETTLING_WINDOW_MS, workloadSettling } from './workload-settling';
function workload(name: string, health: WorkloadHealth, createdAt: string): Workload {
return {
@@ -676,3 +686,183 @@ describe('instanceReadiness before programming', () => {
expect(result.tone).toBe('muted');
});
});
+
+describe('formatAgo', () => {
+ const now = new Date('2026-11-06T12:00:00Z').getTime();
+ test.each([
+ [2_000, 'just now'],
+ [45_000, '45s ago'],
+ [12 * 60_000, '12m ago'],
+ [3 * 3_600_000, '3h ago'],
+ [47 * 3_600_000, '47h ago'],
+ [4 * 86_400_000, '4d ago'],
+ ])('%i ms ago reads %s', (ago, text) => {
+ expect(formatAgo(new Date(now - ago), now)).toBe(text);
+ });
+});
+
+describe('instanceNameParts', () => {
+ test('splits off the workload name', () => {
+ expect(instanceNameParts('web-default-us-east-1-0', 'web')).toEqual({ prefix: 'web-', rest: 'default-us-east-1-0' });
+ });
+ test('keeps names that do not start with the workload name whole', () => {
+ expect(instanceNameParts('other-0', 'web')).toEqual({ prefix: '', rest: 'other-0' });
+ expect(instanceNameParts('web-', 'web')).toEqual({ prefix: '', rest: 'web-' });
+ });
+});
+
+describe('crashDetail', () => {
+ test('reads exit code and restarts from the runtime message', () => {
+ expect(crashDetail('Container "app" exited with code 137 and has restarted 3 times')).toEqual({
+ exitCode: 137,
+ restarts: 3,
+ });
+ });
+ test('reads restarts alone', () => {
+ expect(crashDetail('Container "app" keeps exiting and has restarted 1 time')).toEqual({ restarts: 1 });
+ });
+ test('is undefined for other messages', () => {
+ expect(crashDetail('Back-off restarting failed container')).toBeUndefined();
+ expect(crashDetail(undefined)).toBeUndefined();
+ });
+ test('describes exit 137 as out of memory', () => {
+ expect(crashDetailText({ exitCode: 137, restarts: 3 })).toBe('Killed, usually out of memory (exit 137) · 3 restarts');
+ expect(crashDetailText({ exitCode: 1, restarts: 1 })).toBe('Exited with code 1 · 1 restart');
+ });
+});
+
+describe('firstCrash', () => {
+ test('finds the first instance whose runtime is crashing', () => {
+ const healthy = instance('a', [{ type: 'Ready', status: 'True' }], 'Available');
+ const crash = instance('b', [
+ { type: 'RuntimeReady', status: 'False', reason: 'InstanceCrashing', message: 'Container "app" exited with code 2 and has restarted 5 times' },
+ ]);
+ expect(firstCrash([healthy, crash])).toEqual({ instance: crash, detail: { exitCode: 2, restarts: 5 } });
+ expect(firstCrash([healthy])).toBeUndefined();
+ });
+});
+
+
+describe('overviewStatusLine, partial failure', () => {
+ test('is degraded while some instances still serve', () => {
+ const line = overviewStatusLine({
+ health: 'Unavailable',
+ deleting: false,
+ healthyCount: 2,
+ totalCount: 3,
+ locationCount: 3,
+ failure: '1 instance failing: Back-off',
+ });
+ expect(line).toEqual({ tone: 'warn', title: 'Degraded', detail: '2/3 instances available · 1 instance failing: Back-off' });
+ });
+});
+
+describe('starting instances', () => {
+ const booting = () =>
+ instance('b', [
+ { type: 'Programmed', status: 'True' },
+ { type: 'Ready', status: 'False', reason: 'Provisioning' },
+ ]);
+ const running = () => instance('a', [{ type: 'Ready', status: 'True' }], 'Available');
+
+ test('a placed instance that is still provisioning reads as starting, not amber', () => {
+ expect(instanceReadiness(booting())).toEqual({ tone: 'muted', label: 'Starting', reason: 'Booting' });
+ });
+ test('instancesStarting is true while any instance boots and none has failed', () => {
+ expect(instancesStarting([running(), booting()])).toBe(true);
+ expect(instancesStarting([running()])).toBe(false);
+ expect(instancesStarting([])).toBe(false);
+ expect(instancesStarting([booting(), crashing('c')])).toBe(false);
+ });
+});
+
+describe('workloadSettling', () => {
+ const now = new Date('2026-11-06T12:00:00Z').getTime();
+ const at = (ago: number) => new Date(now - ago);
+ test('is true while deploying', () => {
+ expect(workloadSettling({ ...workload('w', 'Deploying', '2026-11-06T11:00:00Z') }, now)).toBe(true);
+ });
+ test('stays true after Available until replicas are ready, within the window', () => {
+ const w = { ...workload('w', 'Available', '2026-11-06T11:00:00Z'), readyReplicas: 0, updatedAt: at(5_000) };
+ expect(workloadSettling(w, now)).toBe(true);
+ expect(workloadSettling({ ...w, updatedAt: at(SETTLING_WINDOW_MS + 1) }, now)).toBe(false);
+ expect(workloadSettling({ ...w, readyReplicas: 1 }, now)).toBe(false);
+ });
+ test('is false while deleting', () => {
+ expect(workloadSettling({ ...workload('w', 'Deploying', '2026-11-06T11:00:00Z'), deleting: true }, now)).toBe(false);
+ });
+});
+
+describe('instanceProblem', () => {
+ const withImage = (i: Instance, image: string): Instance => ({ ...i, image });
+ test('is undefined for a healthy or starting instance', () => {
+ expect(instanceProblem(instance('a', [{ type: 'Ready', status: 'True' }], 'Available'))).toBeUndefined();
+ expect(instanceProblem(instance('b', [{ type: 'Ready', status: 'False', reason: 'Provisioning' }]))).toBeUndefined();
+ });
+ test('explains an image that cannot be pulled anonymously from Docker Hub', () => {
+ const p = instanceProblem(
+ withImage(
+ instance('c', [
+ { type: 'Programmed', status: 'False', reason: 'ImageUnavailable', message: 'Image "docker.io/library/busybox:1.37" for container "app" could not be pulled anonymously' },
+ ]),
+ 'docker.io/library/busybox:1.37'
+ )
+ );
+ expect(p?.title).toBe("The image can't be pulled");
+ expect(p?.tone).toBe('error');
+ expect(p?.showLogs).toBe(false);
+ expect(p?.hints).toHaveLength(3);
+ expect(p?.hints[0]).toContain('docker.io/library/busybox:1.37');
+ });
+ test('leaves out the Docker Hub hint for other registries', () => {
+ const p = instanceProblem(
+ withImage(
+ instance('d', [{ type: 'Ready', status: 'False', reason: 'ImageUnavailable', message: 'could not be pulled anonymously' }]),
+ 'ghcr.io/acme/app:1'
+ )
+ );
+ expect(p?.hints).toHaveLength(2);
+ });
+ test('turns a crash message into exit code, restarts and a memory hint', () => {
+ const p = instanceProblem(
+ instance('e', [
+ { type: 'RuntimeReady', status: 'False', reason: 'InstanceCrashing', message: 'Container "app" exited with code 137 and has restarted 3 times' },
+ ])
+ );
+ expect(p).toMatchObject({ tone: 'warn', title: 'The app keeps crashing', message: 'Killed, usually out of memory (exit 137) · 3 restarts', showLogs: true });
+ expect(p?.hints[0]).toContain('out of memory');
+ });
+});
+
+describe('albRowState', () => {
+ test('ready, or nothing to judge, reads Ready', () => {
+ expect(albRowState({ phase: 'ready' })).toEqual({ tone: 'ok', label: 'Ready' });
+ expect(albRowState(undefined)).toEqual({ tone: 'ok', label: 'Ready' });
+ });
+
+ test('provisioning names the step, amber once it is slow', () => {
+ expect(albRowState({ phase: 'provisioning', step: 'Issuing TLS certificate', stalled: false })).toEqual({
+ tone: 'muted',
+ label: 'Provisioning · issuing TLS certificate',
+ });
+ expect(albRowState({ phase: 'provisioning', step: 'Issuing TLS certificate', stalled: true }).tone).toBe('warn');
+ });
+
+ test('an error carries its message', () => {
+ expect(albRowState({ phase: 'error', message: 'Certificate rejected' })).toEqual({
+ tone: 'error',
+ label: 'Certificate rejected',
+ });
+ });
+});
+
+describe('routePortText', () => {
+ test('number and name when the service declares the port', () => {
+ expect(routePortText({ name: 'http', number: 8080 })).toBe(':8080 (http)');
+ });
+
+ test('name alone when it does not', () => {
+ expect(routePortText({ name: 'grpc' })).toBe('port grpc');
+ expect(routePortText(undefined)).toBeUndefined();
+ });
+});
diff --git a/ui/consumer/src/lib/workload-presenters.ts b/ui/consumer/src/lib/workload-presenters.ts
index aea66666..1f34741a 100644
--- a/ui/consumer/src/lib/workload-presenters.ts
+++ b/ui/consumer/src/lib/workload-presenters.ts
@@ -2,6 +2,8 @@
* Display helpers shared by the workloads card grid and table views.
*/
import { instanceFailure, QUOTA_REASONS } from '../adapter';
+import type { AlbRoute } from './api';
+import type { AlbStatus } from './alb-status';
import { formatLocationNames, formatLocationSelector, type LocationIndex } from './locations';
import {
workloadHealthToBadgeType,
@@ -310,6 +312,10 @@ export function instanceReadiness(instance: Instance): InstanceReadiness {
if (instance.status === 'Available' && ready?.status !== 'False') {
return { tone: 'ok', label: 'Running' };
}
+ // Placed and booting: still on its way up, not a problem yet.
+ if (programmed?.status === 'True' && ready?.status === 'False' && ready.reason === 'Provisioning') {
+ return { tone: 'muted', label: 'Starting', reason: 'Booting' };
+ }
if (programmed?.status === 'True' && ready?.status !== 'True') {
const unrecoverable = UNRECOVERABLE_READY_REASONS.has(ready?.reason ?? '');
const base: InstanceReadiness = unrecoverable
@@ -354,6 +360,24 @@ export function privateNameState(instance: Instance): PrivateNameState {
};
}
+/**
+ * A load balancer row's state on the workload Overview: "Ready",
+ * "Provisioning · issuing TLS certificate" (amber once it is taking longer
+ * than usual), or the error.
+ */
+export function albRowState(status: AlbStatus | undefined): { tone: StatusTone; label: string } {
+ if (!status || status.phase === 'ready') return { tone: 'ok', label: 'Ready' };
+ if (status.phase === 'error') return { tone: 'error', label: status.message || 'Error' };
+ const step = status.step.charAt(0).toLowerCase() + status.step.slice(1);
+ return { tone: status.stalled ? 'warn' : 'muted', label: `Provisioning · ${step}` };
+}
+
+/** The workload port a route sends to: ":8080 (http)", or "port http" when the number isn't known. */
+export function routePortText(port: AlbRoute['port']): string | undefined {
+ if (!port) return undefined;
+ return port.number !== undefined ? `:${port.number} (${port.name})` : `port ${port.name}`;
+}
+
function isEmptyValue(value: unknown): boolean {
return value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0);
}
@@ -487,6 +511,8 @@ export function overviewStatusLine(input: OverviewStatusInput): OverviewStatus {
].join(' · ');
return { tone: deploy.tone === 'warning' ? 'warn' : 'muted', title: deploy.title, detail };
}
+ // Some instances still serve: a partial failure is degraded, not down.
+ if (failure && healthyCount > 0) return { tone: 'warn', title: 'Degraded', detail: `${available} · ${failure}` };
if (failure) return { tone: 'error', title: 'Unavailable', detail: `${failure} · ${available}` };
if (health === 'Unavailable') {
return { tone: 'error', title: 'Unavailable', detail: totalCount === 0 ? 'No running instances' : available };
@@ -567,3 +593,154 @@ export function matchesWorkloadSearch(
.toLowerCase()
.includes(needle);
}
+
+/** "just now", "45s ago", "12m ago", "3h ago", "4d ago". */
+export function formatAgo(since: Date, now: number = Date.now()): string {
+ const secs = Math.max(0, Math.floor((now - since.getTime()) / 1000));
+ if (secs < 5) return 'just now';
+ if (secs < 60) return `${secs}s ago`;
+ const mins = Math.floor(secs / 60);
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 48) return `${hours}h ago`;
+ return `${Math.floor(hours / 24)}d ago`;
+}
+
+/** An instance name split so the part after the workload name can be emphasised. */
+export interface InstanceNameParts {
+ /** The workload name and its trailing hyphen, or empty when it doesn't lead. */
+ prefix: string;
+ rest: string;
+}
+
+export function instanceNameParts(instanceName: string, workloadName: string): InstanceNameParts {
+ const prefix = `${workloadName}-`;
+ return workloadName && instanceName.startsWith(prefix) && instanceName.length > prefix.length
+ ? { prefix, rest: instanceName.slice(prefix.length) }
+ : { prefix: '', rest: instanceName };
+}
+
+/** What a crash message says about the last exit, until the API reports it as fields. */
+export interface CrashDetail {
+ exitCode?: number;
+ restarts?: number;
+}
+
+/**
+ * Reads the exit code and restart count out of a `RuntimeReady` crash message
+ * ("Container "app" exited with code 137 and has restarted 3 times"). The
+ * instance status has no fields for them yet (datum-cloud/compute#626).
+ */
+export function crashDetail(message: string | undefined): CrashDetail | undefined {
+ if (!message) return undefined;
+ const exit = /exited with code (\d+)/.exec(message);
+ const restarts = /restarted (\d+) times?/.exec(message);
+ if (!exit && !restarts) return undefined;
+ const detail: CrashDetail = {};
+ if (exit) detail.exitCode = Number(exit[1]);
+ if (restarts) detail.restarts = Number(restarts[1]);
+ return detail;
+}
+
+/** "Killed, usually out of memory (exit 137) · 3 restarts". */
+export function crashDetailText(detail: CrashDetail): string {
+ const parts: string[] = [];
+ if (detail.exitCode === 137) parts.push('Killed, usually out of memory (exit 137)');
+ else if (detail.exitCode !== undefined) parts.push(`Exited with code ${detail.exitCode}`);
+ if (detail.restarts !== undefined) parts.push(plural(detail.restarts, 'restart', 'restarts'));
+ return parts.join(' · ');
+}
+
+/** The crash detail of the first crashing instance, with the instance it came from. */
+export function firstCrash(
+ instances: readonly Instance[]
+): { instance: Instance; detail: CrashDetail } | undefined {
+ for (const instance of instances) {
+ const failing = instance.conditions.find(
+ (c) => (c.type === 'RuntimeReady' || c.type === 'Ready') && c.status === 'False' && c.reason === 'InstanceCrashing'
+ );
+ const detail = crashDetail(failing?.message);
+ if (detail) return { instance, detail };
+ }
+ return undefined;
+}
+
+/**
+ * Instances still on their way up: none has failed, and at least one is
+ * starting. The workload reports Available as soon as a placement exists,
+ * seconds before its instances serve, so the Overview keeps showing deploy
+ * progress until this is false.
+ */
+export function instancesStarting(instances: readonly Instance[]): boolean {
+ if (instances.length === 0) return false;
+ const readiness = instances.map((instance) => instanceReadiness(instance));
+ if (readiness.some((r) => r.tone === 'error' || r.tone === 'warn')) return false;
+ return readiness.some((r) => r.tone === 'muted');
+}
+
+/** What is wrong with an instance, in plain words, and what to try. */
+export interface InstanceProblem {
+ tone: 'error' | 'warn';
+ title: string;
+ /** The platform's own message, shown as reported. */
+ message?: string;
+ hints: string[];
+ /** The logs are likely to say more (the app ran at least once). */
+ showLogs: boolean;
+}
+
+/**
+ * The instance's failure, explained: from its `Ready`/`Programmed` failure
+ * reason, or a crash reported by `RuntimeReady`. Undefined while healthy or
+ * still starting.
+ */
+export function instanceProblem(instance: Instance): InstanceProblem | undefined {
+ const crash = instance.conditions.find(
+ (c) => (c.type === 'RuntimeReady' || c.type === 'Ready') && c.status === 'False' && c.reason === 'InstanceCrashing'
+ );
+ const failure = crash ?? instanceFailure(instance.conditions);
+ if (!failure) return undefined;
+ const message = failure.message || undefined;
+
+ switch (failure.reason) {
+ case 'ImageUnavailable': {
+ const image = instance.image ?? 'the image';
+ const anonymous = /anonymous/i.test(message ?? '');
+ const dockerHub = /^(docker\.io\/|index\.docker\.io\/|[^./]+(:|$)|[^./]+\/[^./]+(:|$))/.test(instance.image ?? '');
+ const hints = [`Check that ${image} exists and its tag is spelled correctly.`];
+ hints.push(
+ anonymous
+ ? 'It was pulled without credentials. A private image needs registry credentials (imagePullSecrets) on the workload.'
+ : 'If the image is private, add registry credentials (imagePullSecrets) to the workload.'
+ );
+ if (dockerHub && anonymous) {
+ hints.push('Docker Hub can refuse anonymous pulls when it rate-limits. Retry later, or use the same image from another registry.');
+ }
+ return { tone: 'error', title: "The image can't be pulled", message, hints, showLogs: false };
+ }
+ case 'InstanceCrashing': {
+ const detail = crashDetail(message);
+ const hints = ['Check the logs for the error just before each exit.'];
+ if (detail?.exitCode === 137) {
+ hints.unshift('Exit 137 usually means it ran out of memory. Try a larger instance type, or lower the app’s memory use.');
+ }
+ return {
+ tone: 'warn',
+ title: 'The app keeps crashing',
+ message: detail ? crashDetailText(detail) : message,
+ hints,
+ showLogs: true,
+ };
+ }
+ case 'ConfigurationError':
+ return {
+ tone: 'error',
+ title: 'The configuration was rejected',
+ message,
+ hints: ['Fix the field the message names in the workload spec, then redeploy.'],
+ showLogs: false,
+ };
+ default:
+ return { tone: 'error', title: 'The instance failed', message, hints: [], showLogs: true };
+ }
+}
diff --git a/ui/consumer/src/lib/workload-settling.ts b/ui/consumer/src/lib/workload-settling.ts
new file mode 100644
index 00000000..2b8f4c3e
--- /dev/null
+++ b/ui/consumer/src/lib/workload-settling.ts
@@ -0,0 +1,20 @@
+/**
+ * Whether a workload's pages should poll fast. Kept free of runtime imports
+ * so `api.ts` can use it without an import cycle.
+ */
+import type { Workload } from '../schema';
+
+/** How long after its last change a workload short of ready replicas is still polled fast. */
+export const SETTLING_WINDOW_MS = 10 * 60_000;
+
+/**
+ * Whether a workload is still settling: deploying, or recently changed with
+ * fewer ready replicas than desired. Pages poll faster while it is, so short
+ * starting states are seen; a long-broken workload falls back to the normal rate.
+ */
+export function workloadSettling(workload: Workload, now: number = Date.now()): boolean {
+ if (workload.deleting) return false;
+ if (workload.health === 'Deploying') return true;
+ const changed = (workload.updatedAt ?? workload.createdAt).getTime();
+ return workload.readyReplicas < workload.desiredReplicas && now - changed < SETTLING_WINDOW_MS;
+}
diff --git a/ui/consumer/src/pages/instance-overview.tsx b/ui/consumer/src/pages/instance-overview.tsx
index 50505cfd..2beaee0e 100644
--- a/ui/consumer/src/pages/instance-overview.tsx
+++ b/ui/consumer/src/pages/instance-overview.tsx
@@ -2,7 +2,6 @@
* Instance Overview tab body. Mounted under {@link ./instance-detail.tsx}'s
* layout shell via ` ` — chrome stays mounted across tab changes.
*/
-import { instanceFailure } from '../adapter';
import { CommandBlock } from '../components/cli-section';
import { DetailList, StatusBadge } from '../components/detail-list';
import { RecentInstanceLogs } from '../components/instance-logs';
@@ -20,7 +19,8 @@ import {
import { formatLocationName, formatLocationTooltip, useLocationIndex } from '../lib/locations';
import { cpuUsageQuery, memoryUsageQuery, useInstanceMetricIdentity } from '../lib/metrics-queries';
import { lastThirtyMinutesRange, usePrometheusCard, type PrometheusTimeRange } from '../lib/prometheus';
-import { instanceStatusLabel } from '../lib/workload-presenters';
+import { instanceProblem, instanceStatusLabel } from '../lib/workload-presenters';
+import { InstanceProblemCallout } from '../components/instance-problem';
import { instanceStatusToBadgeType, type Instance } from '../schema';
import {
Card,
@@ -137,19 +137,10 @@ function SizeValue({ instance }: { instance: Instance }) {
);
}
-/** Badge, plus what's wrong when the instance is failing. */
+/** The status badge. What's wrong, when anything is, is in the problem callout above the cards. */
function StatusValue({ instance }: { instance: Instance }) {
- const failure = instanceFailure(instance.conditions);
-
return (
-
-
- {instanceStatusLabel(instance)}
-
- {failure?.message ? (
- {failure.message}
- ) : null}
-
+ {instanceStatusLabel(instance)}
);
}
@@ -466,9 +457,11 @@ export default function InstanceOverview() {
albLoading,
} = useInstanceOutlet();
const locationIndex = useLocationIndex(projectId);
+ const problem = instanceProblem(instance);
return (
<>
+ {problem ? : null}
import("./workload-activity"));
// The topology canvas is the plugin's largest component; load it with its tab.
const WorkloadTopology = lazy(() => import("./workload-topology"));
-function LoadBalancerValue({
- projectId,
- published,
- isLoading,
- albStatuses,
-}: {
- projectId?: string;
- published?: PublishedUrl | null;
- isLoading: boolean;
- albStatuses: Record;
-}) {
- if (isLoading) {
- return — ;
- }
- if (!published?.proxies.length) {
- return Not connected ;
- }
-
- return (
-
- {published.proxies.map((alb) => {
- const label = alb.displayName || alb.proxyName;
- const albStatus = albStatuses[alb.proxyName];
- const display = albStatus ? albStatusDisplay(albStatus) : undefined;
- const badge =
- albStatus && albStatus.phase !== "ready" && display ? (
-
-
- {albStatus.phase === "provisioning" ? albStatus.step : "Error"}
-
-
- ) : null;
- if (!projectId) {
- return (
-
-
- {label}
- {badge}
-
- );
- }
- return (
-
-
-
- {label}
-
- {badge}
-
- );
- })}
-
- );
-}
-
const DETAIL_DATE_FORMAT: Intl.DateTimeFormatOptions = {
day: "2-digit",
month: "short",
@@ -153,12 +87,7 @@ interface DetailRow {
interface WorkloadDetailsProps {
workload: Workload;
- albStatuses: Record;
- healthyCount: number;
- totalCount: number;
- projectId?: string;
- published?: PublishedUrl | null;
- publishedLoading: boolean;
+ instances: readonly Instance[];
locationLabel: string;
}
@@ -170,52 +99,40 @@ function mono(text: string) {
);
}
-/** General and Configuration rows, in display order, before empty ones are dropped. */
+/** "2 vCPU · 4 GB" from the first instance's allocation, when it is known. */
+function instanceSize(instances: readonly Instance[]): string | undefined {
+ const first = instances[0];
+ const cores = parseCpuCores(first?.cpu);
+ const memory = parseMemoryBytes(first?.memory);
+ const parts = [
+ cores !== undefined ? `${cores} vCPU` : undefined,
+ memory !== undefined ? formatBytes(memory) : undefined,
+ ].filter(Boolean);
+ return parts.length > 0 ? parts.join(" · ") : undefined;
+}
+
+/** Configuration rows, in display order, before empty ones are dropped. */
function workloadDetailRows({
workload,
- albStatuses,
- healthyCount,
- totalCount,
- projectId,
- published,
- publishedLoading,
+ instances,
locationLabel,
}: WorkloadDetailsProps): DetailRow[] {
const { main: resourceShort } = splitSlashValue(workload.resources ?? "");
+ const size = instanceSize(instances);
+ const type = resourceShort || workload.resources;
return [
- { label: "Resource name", value: workload.name, content: mono(workload.name) },
- {
- label: "Load balancer",
- // Held while loading so the row doesn't pop in after the rest.
- value: publishedLoading ? "loading" : published?.proxies,
- content: (
-
- ),
- },
- { label: "Instances", value: totalCount, content: `${healthyCount}/${totalCount}` },
- {
- label: "Created",
- value: workload.createdAt,
- content: workload.createdAt.toLocaleDateString("en-GB", DETAIL_DATE_FORMAT),
- },
- { label: "Runtime", value: workload.runtimeType, content: workload.runtimeType },
{ label: "Image", value: workload.image, content: workload.image ? mono(workload.image) : null },
{
- label: "Resources",
- value: resourceShort || workload.resources,
- content: resourceShort || workload.resources,
+ label: "Instance type",
+ value: type ?? size,
+ content: [type, size].filter(Boolean).join(" · "),
},
{
label: "Replicas",
value: workload.desiredReplicas,
content:
workload.replicasPerRegion !== undefined
- ? `${workload.replicasPerRegion}/location · ${workload.desiredReplicas} total`
+ ? `${workload.replicasPerRegion} per location · ${workload.desiredReplicas} total`
: `${workload.desiredReplicas} total`,
},
{
@@ -223,30 +140,49 @@ function workloadDetailRows({
value: workload.locations,
content: {locationLabel} ,
},
- { label: "Networks", value: workload.networks, content: mono(workload.networks.join(", ")) },
{ label: "Ports", value: workload.ports, content: mono(workload.ports.join(", ")) },
+ { label: "Networks", value: workload.networks, content: mono(workload.networks.join(", ")) },
+ { label: "Runtime", value: workload.runtimeType, content: workload.runtimeType },
+ {
+ label: "Created",
+ value: workload.createdAt,
+ content: workload.createdAt.toLocaleDateString("en-GB", DETAIL_DATE_FORMAT),
+ },
];
}
+// Inline: the host compiles no fixed label width.
+const DETAIL_LABEL_STYLE = { width: "8rem" } as const;
+
/**
- * General and Configuration merged into one two-column list. Rows with no
- * value are left out rather than shown as "Coming soon" or a dash, and the
- * grid fills row by row so dropped rows leave no holes.
+ * What the workload is, in its own card. Rows with no value are left out
+ * rather than shown as a dash; live state (instances, health) stays in the
+ * cards above.
*/
function WorkloadDetails(props: WorkloadDetailsProps) {
const rows = compactDetailRows(workloadDetailRows(props));
return (
-
-
- ({ label: row.label, content: row.content }))}
- />
-
-
+
+
+
+
+ Configuration
+
+
+
+ {rows.map((row, index) => (
+
+
+ {row.label}
+
+ {row.content}
+
+ ))}
+
+
);
}
@@ -284,7 +220,8 @@ function WorkloadLayoutShell({
} = useWorkloadInstances(
projectId,
workloadName,
- workload?.health === "Deploying",
+ // Fast while instances are still coming up, not only while deploying.
+ workload ? workloadSettling(workload) : false,
);
const published = usePublishedUrl(projectId, workloadName);
const locationIndex = useLocationIndex(projectId);
@@ -299,14 +236,6 @@ function WorkloadLayoutShell({
[instances],
);
const proxyId = published.data?.proxyName;
- const headerAlb = published.data?.proxies[0];
- const headerUrl =
- workload && headerAlb?.hostname
- ? workloadUrlState(
- !workload.deleting && workload.health === "Available",
- albStatus(headerAlb, { workloadServing: workload.health === "Available" }),
- )
- : undefined;
const navigate = useNavigate();
const permissions = useDeletePermissions(projectId);
const deleteDialog = useDeleteWorkloadDialog();
@@ -323,25 +252,6 @@ function WorkloadLayoutShell({
metricsHref={metricsHref}
logsHref={logsHref}
titleName={workload?.name ?? titleName}
- // Held until the workload is known too, since that decides whether it links.
- urlLoading={published.isLoading || (isLoading && !!headerAlb?.hostname)}
- deleteLoading={(isLoading || permissions.isLoading) && !workload?.deleting}
- visit={
- headerAlb?.hostname && headerUrl
- ? { href: `https://${headerAlb.hostname}`, live: headerUrl.live, status: headerUrl.status }
- : undefined
- }
- url={
- headerAlb?.hostname && headerUrl ? (
-
- ) : undefined
- }
onDelete={canDelete && workload ? () => deleteDialog.show(workload) : undefined}>
(c.type === "Ready" || c.type === "Programmed") && c.status === "False" && c.lastTransitionTime,
+ );
+ return condition?.lastTransitionTime ? new Date(condition.lastTransitionTime) : undefined;
+}
+
+export function WorkloadOverview() {
const context = useWorkloadOutlet();
const {
workload,
@@ -411,55 +334,97 @@ function WorkloadOverview() {
published,
publishedLoading,
locationIndex,
- instanceNames,
+ identityLabel,
+ identityLoading,
+ identityDenied,
+ metricKeys,
overviewHref,
} = context;
- const {
- health,
- failure,
- deploy,
- albStatuses,
- now,
- healthyCount,
- totalCount,
- instanceHref,
- } = useWorkloadOverviewState(context);
+ const { health, failure, deploy, albStatuses, now, healthyCount, totalCount, instanceHref } =
+ useWorkloadOverviewState(context);
const primaryAlb = published?.proxies[0];
const primaryAlbStatus = primaryAlb ? albStatuses[primaryAlb.proxyName] : undefined;
- const logsHref = `${overviewHref}/logs`;
- const status = overviewStatusLine({
- health,
- deleting: workload.deleting,
- deploy,
- failure,
- albError: primaryAlbStatus?.phase === "error" ? primaryAlbStatus.message : undefined,
- healthyCount,
- totalCount,
- locationCount: workload.locations.length,
- now,
- });
+ // While a deploy is under way, stuck, failed or just finished, the card
+ // says so instead of showing (empty) stats.
+ const coming = !!deploy || (!workload.deleting && instancesStarting(instances));
+ const { data: deployments = [] } = useWorkloadDeployments(projectId, workload.uid, coming);
+ const state = deployState({ workload, instances, deploy, deployments, healthyCount, now });
+ const status = state
+ ? deployHeadline(state, { healthy: healthyCount, total: totalCount, locations: workload.locations.length }, now)
+ : overviewStatusLine({
+ health,
+ deleting: workload.deleting,
+ deploy,
+ failure,
+ albError: primaryAlbStatus?.phase === "error" ? primaryAlbStatus.message : undefined,
+ healthyCount,
+ totalCount,
+ locationCount: workload.locations.length,
+ now,
+ });
+ const timeline = state
+ ? deployTimeline(
+ workload,
+ instances,
+ state.phase === "failed" && state.problem
+ ? { label: state.problem.title, at: failedAt(state.failedInstance) ?? new Date(now), state: "failed" }
+ : state.phase === "stalled" && state.stuckOn
+ ? { label: state.stuckOn, at: state.since, state: "waiting" }
+ : undefined,
+ )
+ : undefined;
+ const problemActions =
+ state?.phase === "failed" && state.failedInstance
+ ? [{ label: "View instance", href: instanceHref(state.failedInstance.name), primary: true }]
+ : state?.phase === "stalled" && state.problem?.title === "Waiting for quota" && projectId
+ ? [{ label: "View usage", href: `/project/${projectId}/usage` }]
+ : [];
+ const crash = useMemo(() => firstCrash(instances), [instances]);
+ const allocation = useMemo(() => allocationOf(instances), [instances]);
+ const resolver = useMemo(
+ () => computeResourceLinkResolver(workload.name, overviewHref),
+ [workload.name, overviewHref],
+ );
return (
<>
-
-
{status.detail}}
- action={
- primaryAlb?.hostname ? (
-
- ) : undefined
- }
- />
-
+
-
-
-
- >
- );
-}
-
-/**
- * The status line's action slot doesn't shrink, so the hostname needs its
- * own cap to truncate instead of pushing the detail out on narrow screens.
- */
-const ALB_HOSTNAME_STYLE = { maxWidth: 'min(20rem, 45vw)' } as const;
-/** The load balancer's hostname on the status line, with its state while it isn't ready. */
-function AlbHostnameLink({
- hostname,
- href,
- status,
-}: {
- hostname: string;
- href?: string;
- status?: AlbStatus;
-}) {
- const display = status ? albStatusDisplay(status) : undefined;
- const label = (
- <>
-
- {hostname}
+
+
+ All activity
+
+ }
+ testId="compute-plugin-workload-recent-activity">
+
+
+
+
>
);
- return (
-
- {display ? (
-
- {display.short}
-
- ) : null}
- {href ? (
-
- {label}
-
- ) : (
- {label}
- )}
-
- );
}
export default function WorkloadDetail() {
diff --git a/ui/consumer/src/pages/workload-metrics.tsx b/ui/consumer/src/pages/workload-metrics.tsx
index 700bbb5d..9993fcc4 100644
--- a/ui/consumer/src/pages/workload-metrics.tsx
+++ b/ui/consumer/src/pages/workload-metrics.tsx
@@ -19,6 +19,7 @@ import {
memoryUsageHint,
} from '../lib/resource-usage';
import { useInstanceFilters } from '../lib/instance-filters';
+import { useWorkloadNetworkMetrics } from '../lib/network-interface-metrics';
import {
albErrorRateQuery,
albP99Query,
@@ -87,6 +88,7 @@ export default function WorkloadMetrics() {
const coresTotal = allocatedTotal(coresByInstance, filters.matching);
const memoryTotal = allocatedTotal(memoryByInstance, filters.matching);
const noMatch = filters.isFiltered && filters.matching.length === 0;
+ const network = useWorkloadNetworkMetrics(projectId, filters.matching);
const scopeHint = filters.isFiltered
? `${filters.matching.length} of ${instances.length} instances`
: undefined;
@@ -215,16 +217,36 @@ export default function WorkloadMetrics() {
/>
-
+
+
+
+
{proxyId ? (
diff --git a/ui/consumer/src/schema.ts b/ui/consumer/src/schema.ts
index f3e5ca80..e28e3c9a 100644
--- a/ui/consumer/src/schema.ts
+++ b/ui/consumer/src/schema.ts
@@ -63,6 +63,8 @@ export interface Workload {
runtimeType?: string;
tags: string[];
ports: string[];
+ /** The containers' named ports, which a NetworkService can expose to a load balancer. */
+ namedPorts: Array<{ name: string; port: number; protocol: string }>;
locations: string[];
resources?: string;
replicasPerRegion?: number;
diff --git a/ui/provider/package.json b/ui/provider/package.json
index 069fcd0b..29867d54 100644
--- a/ui/provider/package.json
+++ b/ui/provider/package.json
@@ -12,7 +12,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "@datum-cloud/datum-ui": "^3.5.0",
+ "@datum-cloud/datum-ui": "^3.8.0",
"@monaco-editor/react": "^4.7.0",
"@tanstack/react-query": "5.101.4",
"@tanstack/react-table": "^9.0.0",
diff --git a/ui/provider/pnpm-lock.yaml b/ui/provider/pnpm-lock.yaml
index 30f1629a..1ec46946 100644
--- a/ui/provider/pnpm-lock.yaml
+++ b/ui/provider/pnpm-lock.yaml
@@ -13,8 +13,8 @@ importers:
.:
dependencies:
'@datum-cloud/datum-ui':
- specifier: ^3.5.0
- version: 3.5.0(@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@tanstack/react-table@9.2.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@tanstack/react-virtual@3.14.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(date-fns-tz@3.2.0(date-fns@4.4.0))(date-fns@4.4.0)(js-yaml@5.4.2)(lucide-react@1.31.0(react@19.2.8))(monaco-editor@0.55.1)(motion@13.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(nuqs@2.10.1(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(react-day-picker@10.0.1(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1))(sonner@2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(zod@4.6.7)
+ specifier: ^3.8.0
+ version: 3.8.0(@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@tanstack/react-table@9.2.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@tanstack/react-virtual@3.14.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(ai@7.0.163(zod@4.6.7))(date-fns-tz@3.2.0(date-fns@4.4.0))(date-fns@4.4.0)(js-yaml@5.4.2)(lucide-react@1.31.0(react@19.2.8))(monaco-editor@0.55.1)(motion@13.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(nuqs@2.10.1(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(react-day-picker@10.0.1(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1))(sonner@2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(zod@4.6.7)
'@monaco-editor/react':
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -94,6 +94,22 @@ importers:
packages:
+ '@ai-sdk/gateway@4.0.136':
+ resolution: {integrity: sha512-P851NL80P85Ee6MhblRuuCzNlQdOYU5rKmdt/Wuo5dMtkzvIHhyVZY9wtfKG3wBJUUoxHxWKsI2aR6O75o8diA==}
+ engines: {node: '>=22'}
+ peerDependencies:
+ zod: ^3.25.76 || ^4.1.8
+
+ '@ai-sdk/provider-utils@5.0.75':
+ resolution: {integrity: sha512-k4yAsFliGZwoTpyTpm3ptxwIeaPvRh5vGh/UWoqDiLLXKoyz4zuBGS7p3ygf8to5Bh4fAOHynotLBsWLIYdu2A==}
+ engines: {node: '>=22'}
+ peerDependencies:
+ zod: ^3.25.76 || ^4.1.8
+
+ '@ai-sdk/provider@4.0.36':
+ resolution: {integrity: sha512-GqvWZSZdBelCGughlBdOoupT258rgM3YMbaLAS9WSZJ0vlt8c9vm3My+ZT31mYhiReMCMCF2qF2axnGbILpk1A==}
+ engines: {node: '>=22'}
+
'@asamuzakjp/css-color@6.0.7':
resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==}
engines: {node: ^22.13.0 || >=24.0.0}
@@ -228,8 +244,13 @@ packages:
'@date-fns/tz@1.5.0':
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
- '@datum-cloud/datum-ui@3.5.0':
- resolution: {integrity: sha512-ShGLKZrfWJdsNHGiUrLmFDl0ZhsP2Z7LKQPzcD6uPxBRqM7hNUYNq/cKri7KWFIosfB6Z8GzenZKWOX8QQ+/WQ==}
+ '@datum-cloud/datum-ui-assistant-core@0.1.0':
+ resolution: {integrity: sha512-k/d17fVwYDt/DswVuWOCWo6hsAUJ/Y1hLeambRb3PckUqXnU+RdXJpYi62gNqHLpUZdU7rmmsVOohxyhFoxX0w==}
+ peerDependencies:
+ ai: '>=7 <8'
+
+ '@datum-cloud/datum-ui@3.8.0':
+ resolution: {integrity: sha512-T0IJxfBllKP7/TWO1Pbi7XCKKUYa0icaK176Okoyy1k4gR6ibOVl9QZSDrqR7LiHKGOVar7htzbetDID6K9vsQ==}
peerDependencies:
'@conform-to/react': '>=1.19.4 <2'
'@conform-to/zod': '>=1.19.4 <2'
@@ -271,7 +292,7 @@ packages:
recharts: '>=3 <4'
sonner: '>=2.0.7 <3'
streamdown: '>=2 <3'
- zod: '>=4 <5'
+ zod: '>=4.6.6 <5'
peerDependenciesMeta:
'@conform-to/react':
optional: true
@@ -323,8 +344,6 @@ packages:
optional: true
monaco-editor:
optional: true
- motion:
- optional: true
nprogress:
optional: true
nuqs:
@@ -1429,16 +1448,29 @@ packages:
cpu: [x64]
os: [win32]
+ '@vercel/oidc@3.2.0':
+ resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
+ engines: {node: '>= 20'}
+
'@vitejs/plugin-react@5.2.0':
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
+ '@workflow/serde@4.1.0':
+ resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==}
+
adm-zip@0.6.1:
resolution: {integrity: sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==}
engines: {node: '>=14.0'}
+ ai@7.0.163:
+ resolution: {integrity: sha512-nm8+qn36NQXO4Hjfj7SlbcNRhUyNXKtdoS9xhXr/z92lvdGFuPkzHmA2tFzhaUQy13IitNYrcw2QSNpEJC/iSA==}
+ engines: {node: '>=22'}
+ peerDependencies:
+ zod: ^3.25.76 || ^4.1.8
+
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -1588,6 +1620,10 @@ packages:
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+ eventsource-parser@3.1.1:
+ resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==}
+ engines: {node: '>=18.0.0'}
+
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -1635,8 +1671,8 @@ packages:
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
- isomorphic-dompurify@3.22.0:
- resolution: {integrity: sha512-cz/BkSODnQir4IV2quqbuEHhAGz4MAYGvhoemsxuwMpY/oZbRHlNjPLPyIQ0fdULiwDPUE+OHPwWb6XTEiNk1A==}
+ isomorphic-dompurify@3.23.0:
+ resolution: {integrity: sha512-RuYFgdNApoTlR3QxVUyWdS/1ATpLEWEVg3H2tMImbfr6HkYPnuW1sdEtW4PRGlFhp04VbQQjcjXnucHYpXT44A==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
deprecated: Raised the minimum Node.js version (breaking) without a major bump. Use 4.x for the same code with correct semver, or pin 3.19.0 for Node < 22.22.2.
@@ -1666,6 +1702,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
+ json-schema@0.4.0:
+ resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
+
json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
@@ -1902,8 +1941,8 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
- tailwind-merge@3.6.0:
- resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
+ tailwind-merge@3.7.0:
+ resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -2067,6 +2106,26 @@ packages:
snapshots:
+ '@ai-sdk/gateway@4.0.136(zod@4.6.7)':
+ dependencies:
+ '@ai-sdk/provider': 4.0.36
+ '@ai-sdk/provider-utils': 5.0.75(zod@4.6.7)
+ '@vercel/oidc': 3.2.0
+ zod: 4.6.7
+
+ '@ai-sdk/provider-utils@5.0.75(zod@4.6.7)':
+ dependencies:
+ '@ai-sdk/provider': 4.0.36
+ '@standard-schema/spec': 1.1.0
+ '@workflow/serde': 4.1.0
+ eventsource-parser: 3.1.1
+ undici: 7.30.1
+ zod: 4.6.7
+
+ '@ai-sdk/provider@4.0.36':
+ dependencies:
+ json-schema: 0.4.0
+
'@asamuzakjp/css-color@6.0.7':
dependencies:
'@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
@@ -2224,8 +2283,13 @@ snapshots:
'@date-fns/tz@1.5.0': {}
- '@datum-cloud/datum-ui@3.5.0(@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@tanstack/react-table@9.2.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@tanstack/react-virtual@3.14.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(date-fns-tz@3.2.0(date-fns@4.4.0))(date-fns@4.4.0)(js-yaml@5.4.2)(lucide-react@1.31.0(react@19.2.8))(monaco-editor@0.55.1)(motion@13.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(nuqs@2.10.1(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(react-day-picker@10.0.1(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1))(sonner@2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(zod@4.6.7)':
+ '@datum-cloud/datum-ui-assistant-core@0.1.0(ai@7.0.163(zod@4.6.7))':
+ dependencies:
+ ai: 7.0.163(zod@4.6.7)
+
+ '@datum-cloud/datum-ui@3.8.0(@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@tanstack/react-table@9.2.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@tanstack/react-virtual@3.14.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(ai@7.0.163(zod@4.6.7))(date-fns-tz@3.2.0(date-fns@4.4.0))(date-fns@4.4.0)(js-yaml@5.4.2)(lucide-react@1.31.0(react@19.2.8))(monaco-editor@0.55.1)(motion@13.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(nuqs@2.10.1(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(react-day-picker@10.0.1(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1))(sonner@2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(zod@4.6.7)':
dependencies:
+ '@datum-cloud/datum-ui-assistant-core': 0.1.0(ai@7.0.163(zod@4.6.7))
'@radix-ui/react-avatar': 1.2.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-checkbox': 1.3.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-collapsible': 1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -2245,21 +2309,22 @@ snapshots:
class-variance-authority: 0.7.1
clsx: 2.1.1
cmdk: 1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- isomorphic-dompurify: 3.22.0
+ isomorphic-dompurify: 3.23.0
lucide-react: 1.31.0(react@19.2.8)
+ motion: 13.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
- tailwind-merge: 3.6.0
+ tailwind-merge: 3.7.0
tw-animate-css: 1.4.0
optionalDependencies:
'@monaco-editor/react': 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@tanstack/react-table': 9.2.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@tanstack/react-virtual': 3.14.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ ai: 7.0.163(zod@4.6.7)
date-fns: 4.4.0
date-fns-tz: 3.2.0(date-fns@4.4.0)
js-yaml: 5.4.2
monaco-editor: 0.55.1
- motion: 13.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
nuqs: 2.10.1(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)
react-day-picker: 10.0.1(@types/react@19.2.18)(react@19.2.8)
recharts: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1)
@@ -3182,6 +3247,8 @@ snapshots:
'@typescript/typescript-win32-x64@7.0.2':
optional: true
+ '@vercel/oidc@3.2.0': {}
+
'@vitejs/plugin-react@5.2.0(vite@7.3.7)':
dependencies:
'@babel/core': 7.29.7
@@ -3194,8 +3261,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@workflow/serde@4.1.0': {}
+
adm-zip@0.6.1: {}
+ ai@7.0.163(zod@4.6.7):
+ dependencies:
+ '@ai-sdk/gateway': 4.0.136(zod@4.6.7)
+ '@ai-sdk/provider': 4.0.36
+ '@ai-sdk/provider-utils': 5.0.75(zod@4.6.7)
+ zod: 4.6.7
+
argparse@2.0.1: {}
aria-hidden@1.2.6:
@@ -3351,6 +3427,8 @@ snapshots:
eventemitter3@5.0.4: {}
+ eventsource-parser@3.1.1: {}
+
fdir@6.5.0(picomatch@4.0.5):
optionalDependencies:
picomatch: 4.0.5
@@ -3383,7 +3461,7 @@ snapshots:
is-potential-custom-element-name@1.0.1: {}
- isomorphic-dompurify@3.22.0:
+ isomorphic-dompurify@3.23.0:
dependencies:
dompurify: 3.4.17
jsdom: 30.0.1
@@ -3429,6 +3507,8 @@ snapshots:
jsesc@3.1.0: {}
+ json-schema@0.4.0: {}
+
json5@2.2.3: {}
lru-cache@11.5.2: {}
@@ -3641,7 +3721,7 @@ snapshots:
symbol-tree@3.2.4: {}
- tailwind-merge@3.6.0: {}
+ tailwind-merge@3.7.0: {}
tiny-invariant@1.3.3: {}