From 57488191f21f7a44f42620cba1d252b916df0f85 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:36:20 +0100 Subject: [PATCH] chore: add workload logs and pin instance logs to instance. Also improve link between attached ALB. --- ui/consumer/package.json | 3 +- ui/consumer/src/adapter.test.ts | 75 +++++ ui/consumer/src/adapter.ts | 42 +++ ui/consumer/src/components/cli-section.tsx | 107 +++++-- ui/consumer/src/components/health-strip.tsx | 194 +++++++++--- ui/consumer/src/components/instance-logs.tsx | 168 +++++++++- .../src/components/workload-page-chrome.tsx | 22 +- ui/consumer/src/lib/api.ts | 20 +- ui/consumer/src/lib/o11y-logs.test.ts | 120 ++++++++ ui/consumer/src/lib/o11y-logs.ts | 108 ++++++- ui/consumer/src/pages/instance-logs.tsx | 1 + ui/consumer/src/pages/instance-overview.tsx | 1 + ui/consumer/src/pages/workload-detail.tsx | 290 ++++++++++++------ ui/consumer/src/pages/workload-logs.tsx | 18 ++ ui/consumer/src/schema.ts | 2 + ui/consumer/tsconfig.json | 3 +- ui/provider/src/lib/o11y-logs.ts | 2 + 17 files changed, 995 insertions(+), 181 deletions(-) create mode 100644 ui/consumer/src/adapter.test.ts create mode 100644 ui/consumer/src/lib/o11y-logs.test.ts create mode 100644 ui/consumer/src/pages/workload-logs.tsx diff --git a/ui/consumer/package.json b/ui/consumer/package.json index f4230f82..4f89763a 100644 --- a/ui/consumer/package.json +++ b/ui/consumer/package.json @@ -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", diff --git a/ui/consumer/src/adapter.test.ts b/ui/consumer/src/adapter.test.ts new file mode 100644 index 00000000..6886e16b --- /dev/null +++ b/ui/consumer/src/adapter.test.ts @@ -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([]); + }); +}); diff --git a/ui/consumer/src/adapter.ts b/ui/consumer/src/adapter.ts index 87d17d25..72b5c374 100644 --- a/ui/consumer/src/adapter.ts +++ b/ui/consumer/src/adapter.ts @@ -328,6 +328,7 @@ export interface RawInstance { conditions?: RawCondition[]; networkInterfaces?: { assignments?: { networkIP?: string; externalIP?: string }; + addresses?: { address?: string }[]; }[]; }; } @@ -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(); + 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 ?? '', @@ -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', diff --git a/ui/consumer/src/components/cli-section.tsx b/ui/consumer/src/components/cli-section.tsx index a20b619b..0dc36950 100644 --- a/ui/consumer/src/components/cli-section.tsx +++ b/ui/consumer/src/components/cli-section.tsx @@ -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); }); @@ -29,9 +47,10 @@ export function CommandBlock({ value, danger }: { value: string; danger?: boolea
+ "min-w-0 flex-1 break-all font-mono text-xs leading-relaxed sm:text-sm", + danger ? "text-red-500" : "text-foreground", + )} + > $ {value} @@ -39,8 +58,13 @@ export function CommandBlock({ value, danger }: { value: string; danger?: boolea 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 ? : } + aria-label="Copy command" + > + {copied ? ( + + ) : ( + + )}
); @@ -60,10 +84,26 @@ export function SectionCard({ danger?: boolean; }) { return ( - + - - {icon} + + + {icon} + {title} {description} @@ -100,39 +140,56 @@ export function Banner({ return (
+ data-testid={testId} + > {icon}

{title}

{description}

-
{actions}
+
+ {actions} +
); } /** 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 ( } + icon={ + + } title={title} description={description} actions={ <> + 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" + > Install CLI + 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" + > CLI Docs diff --git a/ui/consumer/src/components/health-strip.tsx b/ui/consumer/src/components/health-strip.tsx index e0a4a52d..e2a8391e 100644 --- a/ui/consumer/src/components/health-strip.tsx +++ b/ui/consumer/src/components/health-strip.tsx @@ -1,36 +1,93 @@ -import { Badge } from '@datum-cloud/datum-ui/badge'; -import { Card, CardContent } from '@datum-cloud/datum-ui/card'; -import { Icon } from '@datum-cloud/datum-ui/icons'; +import { Badge } from "@datum-cloud/datum-ui/badge"; +import { Card, CardContent } from "@datum-cloud/datum-ui/card"; +import { Icon } from "@datum-cloud/datum-ui/icons"; +import { Tooltip } from "@datum-cloud/datum-ui/tooltip"; import { CircleCheckIcon, GlobeIcon, RadioIcon, TriangleAlertIcon, -} from 'lucide-react'; -import type { ReactNode } from 'react'; -import { Link } from 'react-router'; -import { workloadHealthToBadgeType, type WorkloadHealth } from '../schema'; +} from "lucide-react"; +import type { ReactNode } from "react"; +import { Link } from "react-router"; +import { workloadHealthToBadgeType, type WorkloadHealth } from "../schema"; function Chip({ tone, icon, children, }: { - tone: 'success' | 'warning' | 'danger' | 'muted'; + tone: "success" | "warning" | "danger" | "muted"; icon: typeof GlobeIcon; children: ReactNode; }) { return ( + theme={tone === "muted" ? "solid" : "light"} + className="h-6 gap-1.5 rounded-md px-2 text-xs font-medium whitespace-nowrap" + > {children} ); } +function PublishedHostname({ + prefix, + hostname, + customHostnames, +}: { + prefix: string; + hostname: string; + customHostnames: string[]; +}) { + const extras = customHostnames.filter((host) => host && host !== hostname); + const hostnameEl = ( + + {hostname} + + ); + + return ( + + {prefix} + {extras.length === 0 ? ( + hostnameEl + ) : ( + + {extras.map((host) => ( + + {host} + + ))} + + } + > + + {hostnameEl} + +{extras.length} + + + )} + + ); +} + export function WorkloadHealthStrip({ health, healthyCount, @@ -38,6 +95,8 @@ export function WorkloadHealthStrip({ locationCount, albHref, albLabel, + albHostname, + customHostnames = [], }: { health: WorkloadHealth; healthyCount: number; @@ -45,52 +104,86 @@ export function WorkloadHealthStrip({ locationCount: number; albHref?: string; albLabel?: string; + /** Platform default hostname of the attached load balancer. */ + albHostname?: string; + /** User-attached hostnames. Listed in the reachable-at tooltip. */ + customHostnames?: string[]; }) { const tone = workloadHealthToBadgeType(health); const headline = (() => { - if (health === 'Available') { + if (health === "Available") { return { - icon: , - title: 'Serving normally', - detail: `${healthyCount}/${totalCount} instances · ${locationCount} ${locationCount === 1 ? 'location' : 'locations'}`, - ring: 'var(--color-badge-success)', + icon: ( + + ), + title: "Serving normally", + detail: `${healthyCount}/${totalCount} instances · ${locationCount} ${locationCount === 1 ? "location" : "locations"}`, + ring: "var(--color-badge-success)", }; } - if (health === 'Degraded') { + if (health === "Degraded") { return { - icon: , - title: 'Degraded', + icon: ( + + ), + title: "Degraded", detail: `${healthyCount}/${totalCount} instances available`, - ring: 'var(--color-badge-warning)', + ring: "var(--color-badge-warning)", }; } - if (health === 'Unavailable') { + if (health === "Unavailable") { return { - icon: , - title: 'Unavailable', - detail: totalCount === 0 ? 'No running instances' : `${healthyCount}/${totalCount} instances available`, - ring: 'var(--color-badge-danger)', + icon: ( + + ), + title: "Unavailable", + detail: + totalCount === 0 + ? "No running instances" + : `${healthyCount}/${totalCount} instances available`, + ring: "var(--color-badge-danger)", }; } return { - icon: , - title: 'Waiting for status', - detail: 'Workload health has not been reported yet.', - ring: 'var(--color-badge-info)', + icon: ( + + ), + title: "Waiting for status", + detail: "Workload health has not been reported yet.", + ring: "var(--color-badge-info)", }; })(); - const albChip = albHref && albLabel ? ( - + const albChip = + albHref && albLabel ? ( + + + + View ALB + + + + ) : ( - {albLabel} + No load balancer - - ) : ( - - No load balancer - - ); + ); return ( @@ -99,12 +192,33 @@ export function WorkloadHealthStrip({ + style={{ + background: `color-mix(in oklab, ${headline.ring} 10%, transparent)`, + }} + > {headline.icon}
- {headline.title} - {headline.detail} + + {headline.title} + + + {albHostname ? ( + <> + + · + + ) : null} + {headline.detail} +
diff --git a/ui/consumer/src/components/instance-logs.tsx b/ui/consumer/src/components/instance-logs.tsx index 04284407..4cbcddaf 100644 --- a/ui/consumer/src/components/instance-logs.tsx +++ b/ui/consumer/src/components/instance-logs.tsx @@ -11,6 +11,7 @@ import { ApiError } from '../lib/api'; import { ALB_LOGS_PREVIEW_LIMIT, combinedLogFacets, + COMPUTE_LOG_INSTANCE_LABEL, filterCombinedLogs, LOG_SOURCE_ALB, useInstanceLogs, @@ -29,6 +30,7 @@ import { import { EmptyContent } from '@datum-cloud/datum-ui/empty-content'; import { Icon, SpinnerIcon } from '@datum-cloud/datum-ui/icons'; import { + facetsFromEntries, httpStatusBadgeType, lastThirtyMinutes, logRequestHost, @@ -220,6 +222,7 @@ export function RecentInstanceLogs({ albHostname, instanceName, instanceNames, + upstreamIPs, className, }: { logsHref: string; @@ -229,6 +232,8 @@ export function RecentInstanceLogs({ albHostname?: string; instanceName?: string; instanceNames?: readonly string[]; + /** In-network IPs for the instance path; pins ALB rows to this replica. */ + upstreamIPs?: readonly string[]; className?: string; }) { const [timeRange] = useState(() => lastThirtyMinutes()); @@ -236,12 +241,21 @@ export function RecentInstanceLogs({ () => instanceNames ?? (instanceName ? [instanceName] : []), [instanceName, instanceNames] ); - const logsQuery = useWorkloadLogs(projectId, proxyId, names, { + const instanceScoped = !!instanceName && instanceNames === undefined; + const instanceLogs = useInstanceLogs(projectId, proxyId, instanceName, { timeRange, limit: ROW_LIMIT, live: true, - enabled: names.length > 0 || !!proxyId, + enabled: instanceScoped, + upstreamIPs, }); + const workloadLogs = useWorkloadLogs(projectId, proxyId, names, { + timeRange, + limit: ROW_LIMIT, + live: true, + enabled: !instanceScoped && (names.length > 0 || !!proxyId), + }); + const logsQuery = instanceScoped ? instanceLogs : workloadLogs; const denied = isLogsDenied(logsQuery.error); const errorMessage = logsQuery.error && !denied ? logsQuery.error.message : undefined; @@ -313,11 +327,13 @@ export function InstanceLogsExplorer({ projectId, proxyId, instanceName, + upstreamIPs, className, }: { projectId?: string; proxyId?: string; instanceName?: string; + upstreamIPs?: readonly string[]; className?: string; }) { const [filters, setFilters] = useState({}); @@ -337,6 +353,7 @@ export function InstanceLogsExplorer({ search, live, enabled: !!proxyId || !!instanceName, + upstreamIPs, }); const visibleEntries = useMemo( @@ -405,3 +422,150 @@ export function InstanceLogsExplorer({ ); } + +const INSTANCE_COLUMN: LogColumn = { + id: 'instance', + header: 'Instance', + size: 'hug', + className: 'text-muted-foreground truncate font-mono text-xs', + cell: ({ entry }) => { + const name = entry.labels[COMPUTE_LOG_INSTANCE_LABEL]; + if (!name) return '—'; + return ( + + {name} + + ); + }, +}; + +const WORKLOAD_EXPLORER_COLUMNS: readonly LogColumnSpec[] = [ + 'time', + SOURCE_COLUMN, + INSTANCE_COLUMN, + 'status', + 'host', + DETAIL_COLUMN, +]; + +function filterWorkloadExplorerLogs(entries: readonly LogEntry[], filters: LogFilters): LogEntry[] { + const names = filters[COMPUTE_LOG_INSTANCE_LABEL]; + const rest = { ...filters }; + delete rest[COMPUTE_LOG_INSTANCE_LABEL]; + let result = filterCombinedLogs(entries, rest); + if (!names?.length) return result; + return result.filter((entry) => + names.includes(entry.labels[COMPUTE_LOG_INSTANCE_LABEL] ?? '') + ); +} + +/** ALB access logs plus stdout from every instance on the workload. */ +export function WorkloadLogsExplorer({ + projectId, + proxyId, + instanceNames, + className, +}: { + projectId?: string; + proxyId?: string; + instanceNames: readonly string[]; + className?: string; +}) { + const [filters, setFilters] = useState({}); + const [search, setSearch] = useState(''); + const [live, setLive] = useState(false); + const [timeRange, setTimeRange] = useState(() => lastThirtyMinutes()); + + const handleRefresh = useCallback(() => { + setTimeRange((current) => + current.preset ? resolveLogTimeRange(current) : lastThirtyMinutes() + ); + }, []); + + const queryFilters = useMemo(() => { + if (!filters[COMPUTE_LOG_INSTANCE_LABEL]) return filters; + const rest = { ...filters }; + delete rest[COMPUTE_LOG_INSTANCE_LABEL]; + return rest; + }, [filters]); + + const logsQuery = useWorkloadLogs(projectId, proxyId, instanceNames, { + timeRange, + filters: queryFilters, + search, + live, + enabled: !!proxyId || instanceNames.length > 0, + }); + + const visibleEntries = useMemo( + () => filterWorkloadExplorerLogs(logsQuery.data ?? [], filters), + [logsQuery.data, filters] + ); + const facets = useMemo(() => { + const entries = logsQuery.data ?? []; + const instance = facetsFromEntries(entries, [COMPUTE_LOG_INSTANCE_LABEL]).map((facet) => ({ + ...facet, + label: 'Instance', + })); + return [...combinedLogFacets(entries), ...instance]; + }, [logsQuery.data]); + + const denied = isLogsDenied(logsQuery.error); + const errorMessage = logsQuery.error && !denied ? logsQuery.error.message : undefined; + + if (!proxyId && instanceNames.length === 0) { + return ( +
+ +
+ ); + } + + if (denied) { + return ( +
+ +
+ ); + } + + return ( + + + + + + + + ); +} diff --git a/ui/consumer/src/components/workload-page-chrome.tsx b/ui/consumer/src/components/workload-page-chrome.tsx index 86dc7fdb..2b994774 100644 --- a/ui/consumer/src/components/workload-page-chrome.tsx +++ b/ui/consumer/src/components/workload-page-chrome.tsx @@ -3,8 +3,6 @@ * Used by the splat layout at `:workloadName/*`. */ import { PluginTabs, type PluginTab } from './plugin-tabs'; -import { workloadHealthToBadgeType, type Workload } from '../schema'; -import { Badge } from '@datum-cloud/datum-ui/badge'; import { Breadcrumb, BreadcrumbItem, @@ -18,11 +16,16 @@ import { Icon } from '@datum-cloud/datum-ui/icons'; import { HomeIcon } from 'lucide-react'; import { Link } from 'react-router'; -export function workloadDetailTabs(overviewHref: string, metricsHref: string): PluginTab[] { +export function workloadDetailTabs( + overviewHref: string, + metricsHref: string, + logsHref: string +): PluginTab[] { return [ { label: 'Overview', href: overviewHref }, { label: 'Deployments' }, { label: 'Metrics', href: metricsHref }, + { label: 'Logs', href: logsHref }, { label: 'Activity' }, ]; } @@ -32,16 +35,16 @@ export function WorkloadPageChrome({ workloadsHref, overviewHref, metricsHref, + logsHref, titleName, - workload, children, }: { projectHref: string; workloadsHref: string; overviewHref: string; metricsHref: string; + logsHref: string; titleName: string; - workload?: Workload | null; children: React.ReactNode; }) { return ( @@ -73,17 +76,10 @@ export function WorkloadPageChrome({ className="flex-col items-start gap-3 sm:flex-row sm:items-center" description={titleName} descriptionClassName="break-all" - actions={ - workload ? ( - - {workload.health} - - ) : undefined - } /> diff --git a/ui/consumer/src/lib/api.ts b/ui/consumer/src/lib/api.ts index cf4d2b16..7c81bb58 100644 --- a/ui/consumer/src/lib/api.ts +++ b/ui/consumer/src/lib/api.ts @@ -515,8 +515,10 @@ interface RawHttpProxy { export interface ConnectedAlb { /** HTTPProxy metadata.name — Envoy `gateway_name` and LogQL `route_name`. */ proxyName: string; - /** Canonical/default hostname (`status.canonicalHostname`, then spec.hostnames). */ + /** Default hostname (`status.canonicalHostname`, else the first custom hostname). */ hostname?: string; + /** User-attached hostnames (`spec.hostnames`), excluding the displayed default. */ + customHostnames: string[]; /** Portal display name (`app.kubernetes.io/name`, then `kubernetes.io/display-name`). */ displayName: string; } @@ -556,6 +558,18 @@ function proxyHostname(proxy: RawHttpProxy): string | undefined { return proxy.status?.canonicalHostname || proxy.spec?.hostnames?.[0]; } +function proxyCustomHostnames(proxy: RawHttpProxy, displayed?: string): string[] { + const seen = new Set(); + const hosts: string[] = []; + for (const raw of proxy.spec?.hostnames ?? []) { + const host = raw.trim(); + if (!host || host === displayed || seen.has(host)) continue; + seen.add(host); + hosts.push(host); + } + return hosts; +} + function proxyDisplayName(proxy: RawHttpProxy): string { const annotations = proxy.metadata?.annotations; const chosen = annotations?.['app.kubernetes.io/name']?.trim(); @@ -573,9 +587,11 @@ function workloadNameForService(svc: RawNetworkService): string | undefined { function toConnectedAlb(proxy: RawHttpProxy): ConnectedAlb | null { const proxyName = proxy.metadata?.name ?? ''; if (!proxyName) return null; + const hostname = proxyHostname(proxy); return { proxyName, - hostname: proxyHostname(proxy), + hostname, + customHostnames: proxyCustomHostnames(proxy, hostname), displayName: proxyDisplayName(proxy), }; } diff --git a/ui/consumer/src/lib/o11y-logs.test.ts b/ui/consumer/src/lib/o11y-logs.test.ts new file mode 100644 index 00000000..e6fbbc27 --- /dev/null +++ b/ui/consumer/src/lib/o11y-logs.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test"; +import { + albUpstreamHostMatches, + buildAlbLogQL, + buildAlbUpstreamHostRegexp, + filterAlbLogsByUpstreamHost, + instanceUpstreamIPs, + LOG_SOURCE_ALB, + LOG_SOURCE_INSTANCE, + LOG_SOURCE_LABEL, +} from "./o11y-logs"; +import type { LogEntry } from "@datum-cloud/datum-ui/logs"; + +function entry( + source: string, + labels: Record = {}, +): LogEntry { + return { + id: `${source}-${labels.upstream_host ?? labels.line ?? "row"}`, + timestamp: new Date(0), + timestampNs: "0", + line: "", + labels: { [LOG_SOURCE_LABEL]: source, ...labels }, + }; +} + +describe("instanceUpstreamIPs", () => { + test("unique-merges internalIPs and internalIP", () => { + expect( + instanceUpstreamIPs({ + internalIP: "10.0.1.4", + internalIPs: ["10.0.1.4", "2001:db8::1"], + }), + ).toEqual(["10.0.1.4", "2001:db8::1"]); + }); + + test("strips host-route CIDR and skips delegated prefixes", () => { + expect( + instanceUpstreamIPs({ + internalIPs: ["10.0.1.4/32", "2001:db8:a001::/96", "2001:db8::1/128"], + }), + ).toEqual(["10.0.1.4", "2001:db8::1"]); + }); +}); + +describe("albUpstreamHostMatches", () => { + const cases: Array<{ + name: string; + host: string | undefined; + ips: string[]; + want: boolean; + }> = [ + { name: "IPv4 with port", host: "10.0.1.4:8080", ips: ["10.0.1.4"], want: true }, + { name: "IPv4 wrong host", host: "10.0.1.5:8080", ips: ["10.0.1.4"], want: false }, + { + name: "IPv6 bracketed", + host: "[2001:db8::1]:8080", + ips: ["2001:db8::1"], + want: true, + }, + { + name: "IPv6 unbracketed", + host: "2001:db8::1:8080", + ips: ["2001:db8::1"], + want: true, + }, + { name: "empty", host: "", ips: ["10.0.1.4"], want: false }, + { name: "dash", host: "-", ips: ["10.0.1.4"], want: false }, + { name: "missing", host: undefined, ips: ["10.0.1.4"], want: false }, + { name: "unix socket", host: "unix:///tmp/envoy.sock", ips: ["10.0.1.4"], want: false }, + ]; + + for (const tc of cases) { + test(tc.name, () => { + expect(albUpstreamHostMatches(tc.host, tc.ips)).toBe(tc.want); + }); + } +}); + +describe("buildAlbUpstreamHostRegexp", () => { + test("escapes IPv4 dots", () => { + expect(buildAlbUpstreamHostRegexp(["10.0.1.4"])).toBe("10\\.0\\.1\\.4:[0-9]+"); + }); + + test("IPv6 has bracketed and unbracketed arms", () => { + expect(buildAlbUpstreamHostRegexp(["2001:db8::1"])).toBe( + "\\[2001:db8::1\\]:[0-9]+|2001:db8::1:[0-9]+", + ); + }); +}); + +describe("buildAlbLogQL", () => { + test("pins upstream_host when IPs are provided", () => { + expect(buildAlbLogQL("my-proxy", undefined, ["10.0.1.4"])).toBe( + '{route_name=~"httproute/[^/]+/my-proxy/.*", upstream_host=~"10\\\\.0\\\\.1\\\\.4:[0-9]+"}', + ); + }); + + test("omits upstream_host when no IPs (workload path)", () => { + expect(buildAlbLogQL("my-proxy")).toBe( + '{route_name=~"httproute/[^/]+/my-proxy/.*"}', + ); + }); +}); + +describe("filterAlbLogsByUpstreamHost", () => { + test("filters ALB rows and keeps stdout", () => { + const rows = [ + entry(LOG_SOURCE_ALB, { upstream_host: "10.0.1.4:8080" }), + entry(LOG_SOURCE_ALB, { upstream_host: "10.0.1.5:8080" }), + entry(LOG_SOURCE_ALB, { upstream_host: "-" }), + entry(LOG_SOURCE_INSTANCE, { line: "ready" }), + ]; + const kept = filterAlbLogsByUpstreamHost(rows, ["10.0.1.4"]); + expect(kept.map((row) => row.labels.upstream_host ?? row.labels.line)).toEqual([ + "10.0.1.4:8080", + "ready", + ]); + }); +}); diff --git a/ui/consumer/src/lib/o11y-logs.ts b/ui/consumer/src/lib/o11y-logs.ts index 681a9386..76b32e6d 100644 --- a/ui/consumer/src/lib/o11y-logs.ts +++ b/ui/consumer/src/lib/o11y-logs.ts @@ -8,6 +8,7 @@ * Search and host filters stay client-side because Envoy OTEL access logs keep * an empty Body. */ +import { hostRouteIP } from "../adapter"; import { ApiError, PLUGIN_ID, getProjectScopedBase } from "./api"; import { useMemo } from "react"; import { useQuery, type UseQueryResult } from "@tanstack/react-query"; @@ -117,15 +118,87 @@ export function albLogMatchers( return rest; } +/** Host-route IPs an ALB may dial for this instance. */ +export function instanceUpstreamIPs(input: { + internalIP?: string; + internalIPs?: readonly string[]; +}): string[] { + const seen = new Set(); + const ips: string[] = []; + for (const raw of [...(input.internalIPs ?? []), input.internalIP]) { + const ip = hostRouteIP(raw); + if (!ip || seen.has(ip)) continue; + seen.add(ip); + ips.push(ip); + } + return ips; +} + +/** Envoy `%UPSTREAM_HOST%` host (no port). Empty, `-`, and unix sockets are unset. */ +export function parseUpstreamHostIP( + upstreamHost: string | undefined, +): string | undefined { + const raw = upstreamHost?.trim(); + if (!raw || raw === "-" || raw.startsWith("unix:")) return undefined; + if (raw.startsWith("[")) { + const end = raw.indexOf("]"); + if (end <= 1) return undefined; + return raw.slice(1, end); + } + const lastColon = raw.lastIndexOf(":"); + if (lastColon <= 0) return undefined; + return raw.slice(0, lastColon); +} + +/** True when `labels.upstream_host` names one of `ips`. */ +export function albUpstreamHostMatches( + upstreamHost: string | undefined, + ips: readonly string[], +): boolean { + const host = parseUpstreamHostIP(upstreamHost); + if (!host || ips.length === 0) return false; + return ips.includes(host); +} + +export function buildAlbUpstreamHostRegexp(ips: readonly string[]): string { + return ips + .flatMap((ip) => { + const escaped = escapeLogQLRegexp(ip); + if (ip.includes(":")) { + return [`\\[${escaped}\\]:[0-9]+`, `${escaped}:[0-9]+`]; + } + return [`${escaped}:[0-9]+`]; + }) + .join("|"); +} + +export function filterAlbLogsByUpstreamHost( + entries: readonly LogEntry[], + ips: readonly string[], +): LogEntry[] { + return entries.filter((entry) => { + if (entry.labels[LOG_SOURCE_LABEL] === LOG_SOURCE_INSTANCE) return true; + return albUpstreamHostMatches(entry.labels.upstream_host, ips); + }); +} + export function buildAlbLogQL( proxyId: string, extraFilters?: LogFilters, + upstreamIPs?: readonly string[], ): string { const extras = albLogMatchers(proxyId, extraFilters); const pin = `route_name=~"${escapeLogQLQuoted(albRouteNameRegexp(proxyId))}"`; + const parts = [pin]; + if (upstreamIPs && upstreamIPs.length > 0) { + parts.push( + `upstream_host=~"${escapeLogQLQuoted(buildAlbUpstreamHostRegexp(upstreamIPs))}"`, + ); + } + const prefix = parts.join(", "); const hasExtras = Object.values(extras).some((values) => values.length > 0); - if (!hasExtras) return `{${pin}}`; - return `{${pin}, ${buildLogQL({ matchers: extras }).slice(1)}`; + if (!hasExtras) return `{${prefix}}`; + return `{${prefix}, ${buildLogQL({ matchers: extras }).slice(1)}`; } /** LogQL for one instance's stdout. One stream selector — not `or`. */ @@ -189,6 +262,9 @@ function toComputeLogEntries(response: LokiQueryRangeResponse): LogEntry[] { ...entry, labels: { ...pickComputeLogLabels(entry.labels), + ...(entry.labels[COMPUTE_LOG_INSTANCE_LABEL] + ? { [COMPUTE_LOG_INSTANCE_LABEL]: entry.labels[COMPUTE_LOG_INSTANCE_LABEL] } + : {}), [LOG_SOURCE_LABEL]: LOG_SOURCE_INSTANCE, }, })); @@ -362,6 +438,8 @@ export interface UseAlbLogsOptions { live?: boolean; limit?: number; enabled?: boolean; + /** When set, pin ALB LogQL to these in-network IPs (`upstream_host`). */ + upstreamIPs?: readonly string[]; } function logsRetry(failureCount: number, error: ApiError) { @@ -386,9 +464,10 @@ export function useAlbLogs( live = false, limit = ALB_LOGS_PAGE_LIMIT, enabled = true, + upstreamIPs, } = options; - const query = proxyId ? buildAlbLogQL(proxyId, filters) : ""; + const query = proxyId ? buildAlbLogQL(proxyId, filters, upstreamIPs) : ""; const windowKey = live ? "live" : `${timeRange.from}/${timeRange.to}`; return useQuery({ @@ -524,20 +603,33 @@ export function useInstanceLogs( instanceName: string | undefined, options: UseAlbLogsOptions, ): UseInstanceLogsResult { - const { search, enabled = true, ...rest } = options; - const albEnabled = enabled && !!proxyId; + const { search, enabled = true, upstreamIPs, ...rest } = options; + const ips = useMemo( + () => instanceUpstreamIPs({ internalIPs: upstreamIPs }), + [upstreamIPs], + ); + const albEnabled = enabled && !!proxyId && ips.length > 0; const computeEnabled = enabled && !!instanceName; - const alb = useAlbLogs(projectId, proxyId, { ...rest, search: undefined, enabled: albEnabled }); + const alb = useAlbLogs(projectId, proxyId, { + ...rest, + search: undefined, + enabled: albEnabled, + upstreamIPs: ips, + }); const compute = useComputeLogs(projectId, instanceName, { ...rest, search: undefined, enabled: computeEnabled, }); + const albRows = useMemo( + () => filterAlbLogsByUpstreamHost(alb.data ?? [], ips), + [alb.data, ips], + ); const merged = useMemo( - () => mergeLogEntries(alb.data, compute.data), - [alb.data, compute.data], + () => mergeLogEntries(albRows, compute.data), + [albRows, compute.data], ); const data = useMemo(() => filterEntries(merged, {}, search), [merged, search]); const error = diff --git a/ui/consumer/src/pages/instance-logs.tsx b/ui/consumer/src/pages/instance-logs.tsx index b784be0e..3a3b2794 100644 --- a/ui/consumer/src/pages/instance-logs.tsx +++ b/ui/consumer/src/pages/instance-logs.tsx @@ -12,6 +12,7 @@ export default function InstanceLogs() { projectId={projectId} proxyId={proxyId} instanceName={instance.name} + upstreamIPs={instance.internalIPs} className="bg-card" /> ); diff --git a/ui/consumer/src/pages/instance-overview.tsx b/ui/consumer/src/pages/instance-overview.tsx index fa1a4a59..fbb11ce7 100644 --- a/ui/consumer/src/pages/instance-overview.tsx +++ b/ui/consumer/src/pages/instance-overview.tsx @@ -390,6 +390,7 @@ export default function InstanceOverview() { proxyId={proxyId} albHostname={albHostname} instanceName={instance.name} + upstreamIPs={instance.internalIPs} />
diff --git a/ui/consumer/src/pages/workload-detail.tsx b/ui/consumer/src/pages/workload-detail.tsx index a41c06fd..0299bb15 100644 --- a/ui/consumer/src/pages/workload-detail.tsx +++ b/ui/consumer/src/pages/workload-detail.tsx @@ -1,6 +1,7 @@ /** * `portal.page/project` extension at `:workloadName/*`, exposed as - * `WorkloadDetail`. Overview is the index; Metrics lives at `metrics`. + * `WorkloadDetail`. Overview is the index; Metrics lives at `metrics`, + * Logs at `logs`. * * Layout: health strip, topology, full-width logs, then live traffic and * instances. General/Configuration stay below. @@ -8,54 +9,70 @@ * Breadcrumbs are left to the host `ContentWrapper` — do not re-render them * inside the plugin (that double-stacks chrome vs native pages). */ -import { DetailList, StatusBadge } from '../components/detail-list'; -import { WorkloadHealthStrip } from '../components/health-strip'; -import { RecentInstanceLogs } from '../components/instance-logs'; -import { MetricAreaChart } from '../components/metric-area-chart'; -import { TopologyCard } from '../components/topology-card'; -import { WorkloadPageChrome } from '../components/workload-page-chrome'; +import { DetailList, StatusBadge } from "../components/detail-list"; +import { WorkloadHealthStrip } from "../components/health-strip"; +import { RecentInstanceLogs } from "../components/instance-logs"; +import { MetricAreaChart } from "../components/metric-area-chart"; +import { TopologyCard } from "../components/topology-card"; +import { WorkloadPageChrome } from "../components/workload-page-chrome"; import { DEFAULT_OVERVIEW_RANGE, OVERVIEW_RANGE_OPTIONS, useOverviewRange, type OverviewRange, type OverviewRangeValue, -} from '../components/overview-range'; -import { WorkloadMetricsSkeleton, WorkloadOverviewSkeleton } from '../components/skeletons'; -import { ErrorOrRestrictedState } from '../components/states'; -import { usePublishedUrl, useWorkload, useWorkloadInstances, type PublishedUrl } from '../lib/api'; -import { splitSlashValue } from '../lib/format'; -import { formatLocationName, formatLocationNames, formatLocationTooltip, useLocationIndex, type LocationIndex } from '../lib/locations'; +} from "../components/overview-range"; +import { + InstanceLogsSkeleton, + WorkloadMetricsSkeleton, + WorkloadOverviewSkeleton, +} from "../components/skeletons"; +import { ErrorOrRestrictedState } from "../components/states"; +import { + usePublishedUrl, + useWorkload, + useWorkloadInstances, + type PublishedUrl, +} from "../lib/api"; +import { splitSlashValue } from "../lib/format"; +import { + formatLocationName, + formatLocationNames, + formatLocationTooltip, + useLocationIndex, + type LocationIndex, +} from "../lib/locations"; import { albRpsQuery, identityValues, useProjectResourceIdentity, -} from '../lib/metrics-queries'; -import type { WorkloadOutletContext } from './workload-outlet-context'; -import { useWorkloadOutlet } from './workload-outlet-context'; -import WorkloadMetrics from './workload-metrics'; +} from "../lib/metrics-queries"; +import type { WorkloadOutletContext } from "./workload-outlet-context"; +import { useWorkloadOutlet } from "./workload-outlet-context"; +import WorkloadLogs from "./workload-logs"; +import WorkloadMetrics from "./workload-metrics"; import { instanceStatusToBadgeType, workloadHealthToBadgeType, type Instance, type Workload, -} from '../schema'; -import { Badge } from '@datum-cloud/datum-ui/badge'; +} from "../schema"; +import { Badge } from "@datum-cloud/datum-ui/badge"; import { Card, CardAction, CardContent, CardHeader, CardTitle, -} from '@datum-cloud/datum-ui/card'; -import { Icon } from '@datum-cloud/datum-ui/icons'; +} from "@datum-cloud/datum-ui/card"; +import { Icon } from "@datum-cloud/datum-ui/icons"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@datum-cloud/datum-ui/select'; +} from "@datum-cloud/datum-ui/select"; import { ActivityIcon, ArrowRightIcon, @@ -63,14 +80,22 @@ import { HistoryIcon, Settings2Icon, SquareLibraryIcon, -} from 'lucide-react'; -import { useCallback, useMemo, useState } from 'react'; -import { Link, Outlet, Route, Routes, useLocation, useNavigate, useParams } from 'react-router'; +} from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { + Link, + Outlet, + Route, + Routes, + useLocation, + useNavigate, + useParams, +} from "react-router"; -const COMING_SOON = 'Coming soon'; +const COMING_SOON = "Coming soon"; // Inline rather than `h-[27rem]`: the host only compiles that class because its // own ALB overview happens to use it today. -const PANEL_STYLE = { height: '27rem' } as const; +const PANEL_STYLE = { height: "27rem" } as const; function albOverviewHref(projectId: string, proxyName: string): string { return `/project/${projectId}/alb/${proxyName}/overview`; @@ -102,8 +127,15 @@ function LoadBalancerValue({ const label = alb.displayName || alb.proxyName; if (!projectId) { return ( - - + + {label} ); @@ -112,7 +144,8 @@ function LoadBalancerValue({ + className="text-primary inline-flex items-center gap-1.5 text-sm hover:underline" + > {label} @@ -142,7 +175,8 @@ function GeneralCard({ size="sm" sectioned className="h-full w-full overflow-hidden" - data-testid="compute-plugin-workload-general"> + data-testid="compute-plugin-workload-general" + > @@ -153,7 +187,7 @@ function GeneralCard({ {workload.health} @@ -161,11 +195,13 @@ function GeneralCard({ ), }, { - label: 'Resource Name', - content: {workload.name}, + label: "Resource Name", + content: ( + {workload.name} + ), }, { - label: 'Load balancer', + label: "Load balancer", content: ( + data-testid="compute-plugin-workload-configuration" + > @@ -222,15 +259,18 @@ function ConfigurationCard({ {COMING_SOON} ), }, { - label: 'Image', + label: "Image", content: workload.image ? ( - + {workload.image} ) : ( @@ -238,23 +278,25 @@ function ConfigurationCard({ ), }, { - label: 'Resources', + label: "Resources", content: resourceShort || workload.resources || ( {COMING_SOON} ), }, { - label: 'Replicas', + label: "Replicas", content: workload.replicasPerRegion !== undefined ? `${workload.replicasPerRegion}/location · ${workload.desiredReplicas} total` : `${workload.desiredReplicas} total`, }, { - label: 'Locations', + label: "Locations", content: workload.locations.length > 0 ? ( - {locationLabel} + + {locationLabel} + ) : ( — ), @@ -277,7 +319,8 @@ function LiveTrafficCard({ range: OverviewRange; onRangeChange: (value: OverviewRangeValue) => void; }) { - const rpsQuery = projectId && proxyId ? albRpsQuery(projectId, proxyId) : undefined; + const rpsQuery = + projectId && proxyId ? albRpsQuery(projectId, proxyId) : undefined; return ( @@ -287,18 +330,32 @@ function LiveTrafficCard({ Live traffic
- + onRangeChange(value as OverviewRangeValue) + } + > - + data-testid="compute-plugin-overview-range" + > + {OVERVIEW_RANGE_OPTIONS.map((option) => ( - + {option.label} ))} @@ -344,12 +401,16 @@ function InstancesPanel({ Instances - {instances.length} + + {instances.length} +
{instances.length === 0 ? ( -

No running instances

+

+ No running instances +

) : (
    {instances.map((instance) => ( @@ -358,25 +419,40 @@ function InstancesPanel({ type="button" className="hover:bg-muted/40 flex w-full items-center gap-3 px-4 py-3 text-left" onClick={() => onOpen(instance.name)} - data-testid="compute-plugin-instance-card"> + data-testid="compute-plugin-instance-card" + > - {instance.name} + + {instance.name} + + } + > {instance.location ? formatLocationName(instance.location, locationIndex) - : 'Unknown location'} + : "Unknown location"} - + {instance.status} - + ))} @@ -392,18 +468,31 @@ function WorkloadLayoutShell({ workloadsHref, overviewHref, metricsHref, + logsHref, titleName, }: { projectHref: string; workloadsHref: string; overviewHref: string; metricsHref: string; + logsHref: string; titleName: string; }) { const { pathname } = useLocation(); - const { projectId, workloadName } = useParams<{ projectId: string; workloadName: string }>(); - const { data: workload, isLoading, error, refetch } = useWorkload(projectId, workloadName); - const { data: instances = [] } = useWorkloadInstances(projectId, workloadName); + const { projectId, workloadName } = useParams<{ + projectId: string; + workloadName: string; + }>(); + const { + data: workload, + isLoading, + error, + refetch, + } = useWorkload(projectId, workloadName); + const { data: instances = [] } = useWorkloadInstances( + projectId, + workloadName, + ); const published = usePublishedUrl(projectId, workloadName); const locationIndex = useLocationIndex(projectId); const { @@ -412,7 +501,10 @@ function WorkloadLayoutShell({ isDenied: identityDenied, } = useProjectResourceIdentity(projectId); const metricKeys = useMemo(() => identityValues(instances), [instances]); - const instanceNames = useMemo(() => instances.map((instance) => instance.name), [instances]); + const instanceNames = useMemo( + () => instances.map((instance) => instance.name), + [instances], + ); const proxyId = published.data?.proxyName; return ( @@ -421,10 +513,13 @@ function WorkloadLayoutShell({ workloadsHref={workloadsHref} overviewHref={overviewHref} metricsHref={metricsHref} + logsHref={logsHref} titleName={workload?.name ?? titleName} - workload={workload}> + > {isLoading && - (pathname === metricsHref || pathname.startsWith(`${metricsHref}/`) ? ( + (pathname === logsHref || pathname.startsWith(`${logsHref}/`) ? ( + + ) : pathname === metricsHref || pathname.startsWith(`${metricsHref}/`) ? ( ) : ( @@ -477,29 +572,38 @@ function WorkloadOverview() { overviewHref, } = useWorkloadOutlet(); const navigate = useNavigate(); - const [rangeValue, setRangeValue] = useState(DEFAULT_OVERVIEW_RANGE); + const [rangeValue, setRangeValue] = useState( + DEFAULT_OVERVIEW_RANGE, + ); const range = useOverviewRange(rangeValue); const albLabel = published?.displayName || published?.proxyName; + const primaryAlb = published?.proxies[0]; const instanceHref = useCallback( (name: string) => `${overviewHref}/instances/${name}`, - [overviewHref] + [overviewHref], ); const instanceMetricsHref = useCallback( (name: string) => `${overviewHref}/instances/${name}/metrics`, - [overviewHref] + [overviewHref], ); const albHrefFor = useMemo( - () => (projectId ? (proxyName: string) => albOverviewHref(projectId, proxyName) : undefined), - [projectId] + () => + projectId + ? (proxyName: string) => albOverviewHref(projectId, proxyName) + : undefined, + [projectId], ); const albMetricsHrefFor = useMemo( - () => (projectId ? (proxyName: string) => albMetricsHref(projectId, proxyName) : undefined), - [projectId] + () => + projectId + ? (proxyName: string) => albMetricsHref(projectId, proxyName) + : undefined, + [projectId], ); - const logsHref = instances[0] ? `${instanceHref(instances[0].name)}/logs` : overviewHref; + const logsHref = `${overviewHref}/logs`; const healthyCount = instances.length - ? instances.filter((i) => i.status === 'Available').length + ? instances.filter((i) => i.status === "Available").length : workload.readyReplicas; const totalCount = instances.length || workload.desiredReplicas; @@ -510,8 +614,12 @@ function WorkloadOverview() { healthyCount={healthyCount} totalCount={totalCount} locationCount={workload.locations.length} - albHref={projectId && proxyId ? albOverviewHref(projectId, proxyId) : undefined} + albHref={ + projectId && proxyId ? albOverviewHref(projectId, proxyId) : undefined + } albLabel={albLabel} + albHostname={primaryAlb?.hostname} + customHostnames={primaryAlb?.customHostnames} /> (); const location = useLocation(); - const path = location.pathname.replace(/\/$/, ''); - const overviewHref = path.replace(/\/metrics$/, ''); + const path = location.pathname.replace(/\/$/, ""); + const overviewHref = path.replace(/\/(logs|metrics)$/, ""); + const logsHref = `${overviewHref}/logs`; const metricsHref = `${overviewHref}/metrics`; - const workloadsHref = overviewHref.replace(/\/[^/]+$/, ''); - const projectHref = projectId ? `/project/${projectId}` : '/'; - const titleName = workloadName ?? 'Workload'; + const workloadsHref = overviewHref.replace(/\/[^/]+$/, ""); + const projectHref = projectId ? `/project/${projectId}` : "/"; + const titleName = workloadName ?? "Workload"; return ( @@ -595,11 +704,14 @@ export default function WorkloadDetail() { workloadsHref={workloadsHref} overviewHref={overviewHref} metricsHref={metricsHref} + logsHref={logsHref} titleName={titleName} /> - }> + } + > } /> } /> + } /> ); diff --git a/ui/consumer/src/pages/workload-logs.tsx b/ui/consumer/src/pages/workload-logs.tsx new file mode 100644 index 00000000..d0bd2425 --- /dev/null +++ b/ui/consumer/src/pages/workload-logs.tsx @@ -0,0 +1,18 @@ +/** + * Workload Logs tab. ALB access logs for the connected load balancer, plus + * stdout from every instance, merged newest-first. + */ +import { WorkloadLogsExplorer } from '../components/instance-logs'; +import { useWorkloadOutlet } from './workload-outlet-context'; + +export default function WorkloadLogs() { + const { projectId, proxyId, instanceNames } = useWorkloadOutlet(); + return ( + + ); +} diff --git a/ui/consumer/src/schema.ts b/ui/consumer/src/schema.ts index fcfdc291..b603c6b9 100644 --- a/ui/consumer/src/schema.ts +++ b/ui/consumer/src/schema.ts @@ -121,6 +121,8 @@ export const instanceResourceSchema = z.object({ status: z.enum(['Available', 'Pending', 'Failed', 'Unknown']), externalIP: z.string().optional(), internalIP: z.string().optional(), + /** Host-route in-network IPs Envoy may dial (`upstream_host`), v4 and v6. */ + internalIPs: z.array(z.string()).default([]), conditions: z.array(instanceConditionSchema).default([]), }); diff --git a/ui/consumer/tsconfig.json b/ui/consumer/tsconfig.json index 2af885b1..57d88d9d 100644 --- a/ui/consumer/tsconfig.json +++ b/ui/consumer/tsconfig.json @@ -14,5 +14,6 @@ "isolatedModules": true, "types": ["vite/client"] }, - "include": ["src", "vite.config.ts"] + "include": ["src", "vite.config.ts"], + "exclude": ["src/**/*.test.ts"] } diff --git a/ui/provider/src/lib/o11y-logs.ts b/ui/provider/src/lib/o11y-logs.ts index 7817237b..84d82b08 100644 --- a/ui/provider/src/lib/o11y-logs.ts +++ b/ui/provider/src/lib/o11y-logs.ts @@ -7,6 +7,8 @@ * `datum_instance_name`. LogQL log queries take one stream selector — * `{a="x"} or {b="x"}` is a metric-query form and returns 400. Search and host * filters stay client-side because Envoy OTEL access logs keep an empty Body. + * Instance-scoped ALB pin (`upstream_host` vs in-network IPs) is consumer-only; + * this staff explorer stays workload-wide. */ import { ApiError, PLUGIN_ID, getProjectScopedBase, proxyFetchAbsolute } from "./api"; import { useMemo } from "react";