Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ui/consumer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"dev": "vite",
"preview": "vite preview",
"build": "vite build",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "bun test"
},
"dependencies": {
"@datum-cloud/datum-ui": "^2.9.1",
Expand Down
75 changes: 75 additions & 0 deletions ui/consumer/src/adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, test } from "bun:test";
import { collectInstanceInternalIPs, toInstance, type RawInstance } from "./adapter";

function raw(status: RawInstance["status"]): RawInstance {
return {
metadata: { name: "inst-1", uid: "uid-1", creationTimestamp: "2026-01-01T00:00:00Z" },
status,
};
}

describe("collectInstanceInternalIPs", () => {
test("only assignments.networkIP", () => {
const instance = toInstance(
raw({ networkInterfaces: [{ assignments: { networkIP: "10.0.1.4" } }] }),
);
expect(instance.internalIP).toBe("10.0.1.4");
expect(instance.internalIPs).toEqual(["10.0.1.4"]);
});

test("v4 + v6 in addresses", () => {
expect(
collectInstanceInternalIPs(
raw({
networkInterfaces: [
{
assignments: { networkIP: "10.0.1.4" },
addresses: [{ address: "10.0.1.4/32" }, { address: "2001:db8::1/128" }],
},
],
}),
),
).toEqual(["10.0.1.4", "2001:db8::1"]);
});

test("CIDR /32 strips to bare", () => {
expect(
collectInstanceInternalIPs(
raw({
networkInterfaces: [{ addresses: [{ address: "10.0.1.4/32" }] }],
}),
),
).toEqual(["10.0.1.4"]);
});

test("delegated prefix is not a candidate", () => {
expect(
collectInstanceInternalIPs(
raw({
networkInterfaces: [{ addresses: [{ address: "2001:db8:a001::/96" }] }],
}),
),
).toEqual([]);
});

test("ignores externalIP", () => {
expect(
collectInstanceInternalIPs(
raw({
networkInterfaces: [
{
assignments: { networkIP: "10.0.1.4", externalIP: "203.0.113.9" },
addresses: [{ address: "10.0.1.4/32" }],
},
],
}),
),
).toEqual(["10.0.1.4"]);
});

test("empty status", () => {
const instance = toInstance(raw({}));
expect(instance.internalIP).toBeUndefined();
expect(instance.internalIPs).toEqual([]);
});
});
42 changes: 42 additions & 0 deletions ui/consumer/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ export interface RawInstance {
conditions?: RawCondition[];
networkInterfaces?: {
assignments?: { networkIP?: string; externalIP?: string };
addresses?: { address?: string }[];
}[];
};
}
Expand Down Expand Up @@ -386,12 +387,52 @@ function resolveInstanceResources(runtime?: RawRuntime): {
return { cpu, memory };
}

/**
* Host-route in-network address. `/32` and `/128` strip to the bare IP;
* delegated prefixes (`/96`, …) are not a single host and are skipped.
*/
export function hostRouteIP(address?: string): string | undefined {
const trimmed = address?.trim();
if (!trimmed) return undefined;
const slash = trimmed.lastIndexOf("/");
if (slash === -1) return isBareIP(trimmed) ? trimmed : undefined;
const ip = trimmed.slice(0, slash);
const bits = Number(trimmed.slice(slash + 1));
if (!isBareIP(ip) || !Number.isInteger(bits)) return undefined;
const bitLen = ip.includes(":") ? 128 : 32;
if (bits !== bitLen) return undefined;
return ip;
}

function isBareIP(value: string): boolean {
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(value)) return true;
return value.includes(":") && !value.includes("/");
}

/** In-network host IPs the ALB may dial. Never includes external addresses. */
export function collectInstanceInternalIPs(raw: RawInstance): string[] {
const seen = new Set<string>();
const ips: string[] = [];
const add = (value?: string) => {
const ip = hostRouteIP(value);
if (!ip || seen.has(ip)) return;
seen.add(ip);
ips.push(ip);
};
for (const iface of raw.status?.networkInterfaces ?? []) {
for (const address of iface.addresses ?? []) add(address.address);
add(iface.assignments?.networkIP);
}
return ips;
}

export function toInstance(raw: RawInstance): Instance {
const labels = raw.metadata?.labels ?? {};
const assignments = raw.status?.networkInterfaces?.[0]?.assignments;
const container = raw.spec?.runtime?.sandbox?.containers?.[0];
const conditions = raw.status?.conditions ?? [];
const { cpu, memory } = resolveInstanceResources(raw.spec?.runtime);
const internalIPs = collectInstanceInternalIPs(raw);

return {
uid: raw.metadata?.uid ?? '',
Expand All @@ -412,6 +453,7 @@ export function toInstance(raw: RawInstance): Instance {
status: deriveInstanceStatus(conditions),
externalIP: assignments?.externalIP,
internalIP: assignments?.networkIP,
internalIPs,
conditions: conditions.map((c) => ({
type: c.type ?? '',
status: c.status ?? 'Unknown',
Expand Down
103 changes: 80 additions & 23 deletions ui/consumer/src/components/cli-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,39 @@
* which exports the same hook.
* - Icons rendered via `@datum-cloud/datum-ui/icons` `Icon` wrapper.
*/
import { useCopyToClipboard } from '@datum-cloud/datum-ui/hooks';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@datum-cloud/datum-ui/card';
import { Icon } from '@datum-cloud/datum-ui/icons';
import { toast } from '@datum-cloud/datum-ui/toast';
import { cn } from '@datum-cloud/datum-ui/utils';
import { BookOpenIcon, CheckIcon, CopyIcon, DownloadIcon, SquareTerminalIcon } from 'lucide-react';
import { useState } from 'react';
import { useCopyToClipboard } from "@datum-cloud/datum-ui/hooks";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@datum-cloud/datum-ui/card";
import { Icon } from "@datum-cloud/datum-ui/icons";
import { toast } from "@datum-cloud/datum-ui/toast";
import { cn } from "@datum-cloud/datum-ui/utils";
import {
BookOpenIcon,
CheckIcon,
CopyIcon,
DownloadIcon,
SquareTerminalIcon,
} from "lucide-react";
import { useState } from "react";

export function CommandBlock({ value, danger }: { value: string; danger?: boolean }) {
export function CommandBlock({
value,
danger,
}: {
value: string;
danger?: boolean;
}) {
const [, copy] = useCopyToClipboard();
const [copied, setCopied] = useState(false);

const handleCopy = () => {
copy(value).then(() => {
toast.success('Copied to clipboard');
toast.success("Copied to clipboard");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
Expand All @@ -29,18 +47,24 @@ export function CommandBlock({ value, danger }: { value: string; danger?: boolea
<div className="bg-background flex items-start gap-3 rounded-lg border px-3 py-3 sm:items-center sm:px-4">
<span
className={cn(
'min-w-0 flex-1 break-all font-mono text-xs leading-relaxed sm:text-sm',
danger ? 'text-red-500' : 'text-foreground'
)}>
"min-w-0 flex-1 break-all font-mono text-xs leading-relaxed sm:text-sm",
danger ? "text-red-500" : "text-foreground",
)}
>
<span className="text-muted-foreground mr-2">$</span>
{value}
</span>
<button
type="button"
onClick={handleCopy}
className="text-muted-foreground hover:text-foreground mt-0.5 shrink-0 transition-colors sm:mt-0"
aria-label="Copy command">
{copied ? <Icon icon={CheckIcon} size={16} /> : <Icon icon={CopyIcon} size={16} />}
aria-label="Copy command"
>
{copied ? (
<Icon icon={CheckIcon} size={16} />
) : (
<Icon icon={CopyIcon} size={16} />
)}
</button>
</div>
);
Expand All @@ -60,10 +84,26 @@ export function SectionCard({
danger?: boolean;
}) {
return (
<Card size="sm" sectioned className={cn(danger && 'border-red-200 dark:border-red-900')}>
<Card
size="sm"
sectioned
className={cn(danger && "border-red-200 dark:border-red-900")}
>
<CardHeader size="sm" bordered>
<CardTitle className={cn('flex items-center gap-2 text-sm', danger && 'text-red-500')}>
<span className={cn('shrink-0', danger ? 'text-red-500' : 'text-secondary')}>{icon}</span>
<CardTitle
className={cn(
"flex items-center gap-2 text-sm",
danger && "text-red-500",
)}
>
<span
className={cn(
"shrink-0",
danger ? "text-red-500" : "text-secondary",
)}
>
{icon}
</span>
{title}
</CardTitle>
<CardDescription>{description}</CardDescription>
Expand Down Expand Up @@ -100,22 +140,37 @@ export function Banner({
return (
<div
className="bg-primary/5 border-primary/20 flex flex-col gap-4 rounded-xl border p-4 sm:flex-row sm:items-center"
data-testid={testId}>
data-testid={testId}
>
{icon}
<div className="min-w-0 flex-1">
<p className="text-primary font-semibold">{title}</p>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">{actions}</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
{actions}
</div>
</div>
);
}

/** Banner pointing users at the datumctl CLI docs — shown wherever a resource is CLI-managed only. */
export function CliBanner({ title, description }: { title: string; description: string }) {
export function CliBanner({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<Banner
icon={<Icon icon={SquareTerminalIcon} size={32} className="text-primary shrink-0" />}
icon={
<Icon
icon={SquareTerminalIcon}
size={32}
className="text-primary shrink-0"
/>
}
title={title}
description={description}
actions={
Expand All @@ -124,15 +179,17 @@ export function CliBanner({ title, description }: { title: string; description:
href="https://www.datum.net/docs/datumctl/quickstart"
target="_blank"
rel="noreferrer"
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors">
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors"
>
<Icon icon={DownloadIcon} size={16} />
Install CLI
</a>
<a
href="https://www.datum.net/docs/datumctl/overview"
target="_blank"
rel="noreferrer"
className="border-border hover:bg-muted inline-flex items-center justify-center gap-1.5 rounded-md border px-3 py-2 text-sm font-medium transition-colors">
className="border-border hover:bg-muted inline-flex items-center justify-center gap-1.5 rounded-md border px-3 py-2 text-sm font-medium transition-colors"
>
<Icon icon={BookOpenIcon} size={16} />
CLI Docs
</a>
Expand Down
Loading
Loading