From 2fd92aed247c085a51a05b7c9bdfd8cf290c1913 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:29:29 +0100 Subject: [PATCH] feat(ui): Let users delete a workload from its detail page The workload detail header gets a Delete button that opens a type-DELETE confirmation, matching the portal's other delete dialogs. Deleting the Workload removes what the compute controllers made for it, but not the ALB (HTTPProxy and the NetworkService behind it) or the Network. The dialog lists those as opt-in checkboxes. Anything shared with another workload, or that the user can't delete, is disabled with a reason. The workload is deleted first, so a refused delete touches nothing else. The Delete button is hidden when a delete SelfSubjectAccessReview on workloads fails, following cloud-portal's RBAC conventions. Confirming goes straight back to the workload list, where the workload's card and table row show a Deleting badge until it's gone. The plugin also invalidates cloud-portal's http-proxies, network-services and compute-workloads caches so the ALB pages refetch. Co-Authored-By: Claude Opus 5.5 (1M context) --- ui/consumer/README.md | 21 +- ui/consumer/package.json | 2 +- ui/consumer/src/adapter.ts | 16 +- .../src/components/delete-workload-dialog.tsx | 438 ++++++++++++++++++ .../src/components/workload-page-chrome.tsx | 27 +- .../src/components/workload-status-badge.tsx | 47 ++ ui/consumer/src/components/workload-table.tsx | 18 +- ui/consumer/src/lib/api.ts | 327 ++++++++++++- ui/consumer/src/lib/workload-presenters.ts | 1 + ui/consumer/src/pages/workload-detail.tsx | 28 +- ui/consumer/src/pages/workload-list.tsx | 21 +- ui/consumer/src/schema.ts | 4 + 12 files changed, 890 insertions(+), 60 deletions(-) create mode 100644 ui/consumer/src/components/delete-workload-dialog.tsx create mode 100644 ui/consumer/src/components/workload-status-badge.tsx diff --git a/ui/consumer/README.md b/ui/consumer/README.md index 2564fa6d..92426f3c 100644 --- a/ui/consumer/README.md +++ b/ui/consumer/README.md @@ -1,16 +1,16 @@ # Compute Portal Plugin -A read-only operational dashboard for the compute service (`compute.datumapis.com`), +An operational dashboard for the compute service (`compute.datumapis.com`), shipped as a **Module Federation remote** that the cloud portal loads at runtime — the [Portal Plugin System](https://github.com/datum-cloud/cloud-portal/blob/main/docs/enhancements/portal-plugin-system.md). Structural template: [`examples/sample-plugin/`](https://github.com/datum-cloud/cloud-portal/tree/main/examples/sample-plugin) in the `cloud-portal` repo. -## Scope — v1 is read-only +## Scope — visibility, plus delete -This is a **CLI-first** service: workload creation, deployment, scaling, -restarts, and deletion all happen via `datumctl compute …`. The plugin's job -is to give operators visibility into what's already running: +This is a **CLI-first** service: workload creation, deployment, scaling and +restarts all happen via `datumctl compute …`. The plugin's job is to give +operators visibility into what's already running, and to delete workloads: Paths below are relative to the plugin mount, `/project/:projectId/services/` (the slug is operator-supplied; `workloads` locally). The list page is the @@ -29,7 +29,16 @@ mount root so URLs read `…/services/workloads/`, not tab always queries instance stdout; ALB access logs are merged in when an HTTPProxy exists. Manage/Activity remain placeholders. -No deploy/edit/delete forms. Activity stays out of scope until there is a +**Delete** is in the workload detail page header. +The confirmation dialog (`src/components/delete-workload-dialog.tsx`) also +offers the workload's Application Load Balancers (HTTPProxy plus the +NetworkService behind it) and Networks as opt-in extras, because deleting the +Workload doesn't remove them. Anything shared with another workload can't be +ticked. Following the portal's RBAC conventions, the delete action is +hidden when a `delete` SelfSubjectAccessReview on `workloads` fails, and +extras the user can't delete are disabled with a tooltip. + +No deploy/edit forms. Activity stays out of scope until there is a real activity source. `CliBanner`/`SectionCard` (in `src/components/cli-section.tsx`) point users at the equivalent `datumctl` commands wherever the portal can't do something itself. diff --git a/ui/consumer/package.json b/ui/consumer/package.json index f4230f82..f70a3f3f 100644 --- a/ui/consumer/package.json +++ b/ui/consumer/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.1.0", "type": "module", - "description": "Compute service Portal Plugin: read-only workload/instance operational dashboard, a Module Federation remote reading data exclusively through the portal's Milo control-plane proxy. Workload creation stays in datumctl (CLI-first).", + "description": "Compute service Portal Plugin: workload/instance operational dashboard with workload delete, a Module Federation remote reading data exclusively through the portal's Milo control-plane proxy. Workload creation stays in datumctl (CLI-first).", "scripts": { "dev": "vite", "preview": "vite preview", diff --git a/ui/consumer/src/adapter.ts b/ui/consumer/src/adapter.ts index 87d17d25..93288a2e 100644 --- a/ui/consumer/src/adapter.ts +++ b/ui/consumer/src/adapter.ts @@ -35,6 +35,7 @@ interface RawObjectMeta { namespace?: string; resourceVersion?: string; creationTimestamp?: string; + deletionTimestamp?: string; labels?: Record; } @@ -88,7 +89,12 @@ interface RawWorkloadPlacementStatus { export interface RawWorkload { metadata?: RawObjectMeta; spec?: { - template?: { spec?: { runtime?: RawRuntime } }; + template?: { + spec?: { + runtime?: RawRuntime; + networkInterfaces?: Array<{ network?: { name?: string } }>; + }; + }; placements?: RawWorkloadPlacement[]; }; status?: { @@ -275,6 +281,12 @@ function toPlacementRegions( }); } +/** Distinct network names across the template's interfaces, in declaration order. */ +function deriveNetworks(interfaces?: Array<{ network?: { name?: string } }>): string[] { + const names = (interfaces ?? []).map((iface) => iface.network?.name).filter(Boolean) as string[]; + return [...new Set(names)]; +} + export function toWorkload(raw: RawWorkload): Workload { const conditions = raw.status?.conditions ?? []; const placements = raw.spec?.placements ?? []; @@ -304,6 +316,8 @@ export function toWorkload(raw: RawWorkload): Workload { locations: workloadLocations(placements, raw.status?.placements ?? []), resources: deriveResources(runtime), replicasPerRegion: deriveReplicasPerRegion(placements), + networks: deriveNetworks(raw.spec?.template?.spec?.networkInterfaces), + deleting: !!raw.metadata?.deletionTimestamp, conditions: conditions.map((c) => ({ type: c.type ?? '', status: c.status ?? 'Unknown', diff --git a/ui/consumer/src/components/delete-workload-dialog.tsx b/ui/consumer/src/components/delete-workload-dialog.tsx new file mode 100644 index 00000000..d8c653f6 --- /dev/null +++ b/ui/consumer/src/components/delete-workload-dialog.tsx @@ -0,0 +1,438 @@ +/** + * Delete-workload confirmation, modelled on the portal's shared + * `ConfirmationDialog` (app/components/confirmation-dialog), which lives + * inside the host and isn't reachable from a plugin. Same pieces: datum-ui + * Dialog + a type-"DELETE" Input + danger/borderless buttons. + * + * On top of that it offers the workload's ALBs and Networks as opt-in extras, + * since deleting the Workload leaves both behind. Rows the user can't delete + * (RBAC) or that are shared with another workload are disabled with a tooltip. + */ +import { + ApiError, + useDeleteWorkload, + usePublishedUrl, + useWorkloadRelatedResources, + type DeletePermissions, + type DeleteWorkloadResult, + type WorkloadRelatedResources, +} from '../lib/api'; +import type { Workload } from '../schema'; +import { Button } from '@datum-cloud/datum-ui/button'; +import { Checkbox } from '@datum-cloud/datum-ui/checkbox'; +import { Dialog } from '@datum-cloud/datum-ui/dialog'; +import { Icon } from '@datum-cloud/datum-ui/icons'; +import { Input } from '@datum-cloud/datum-ui/input'; +import { Label } from '@datum-cloud/datum-ui/label'; +import { Skeleton } from '@datum-cloud/datum-ui/skeleton'; +import { toast } from '@datum-cloud/datum-ui/toast'; +import { Tooltip } from '@datum-cloud/datum-ui/tooltip'; +import { cn } from '@datum-cloud/datum-ui/utils'; +import { GlobeIcon, InfoIcon, NetworkIcon, type LucideIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +const CONFIRM_VALUE = 'DELETE'; + +const NO_RELATED: WorkloadRelatedResources = { albs: [], networks: [], serviceProxies: {} }; + +const ALB_DENIED = "You don't have permission to delete this Application Load Balancer"; +const NETWORK_DENIED = "You don't have permission to delete this Network"; + +/** Open/close state for the dialog. The workload is kept after closing so the + * content doesn't blank out during the close animation. */ +export function useDeleteWorkloadDialog() { + const [workload, setWorkload] = useState(); + const [open, setOpen] = useState(false); + return { + workload, + open, + show: (target: Workload) => { + setWorkload(target); + setOpen(true); + }, + close: () => setOpen(false), + }; +} + +function toggle(set: Set, name: string, on: boolean): Set { + const next = new Set(set); + if (on) next.add(name); + else next.delete(name); + return next; +} + +function plural(count: number, one: string, many: string): string { + return `${count} ${count === 1 ? one : many}`; +} + +/** One tickable resource. Rows the user can't tick say why inline (and in a + * tooltip), rather than leaving a greyed-out checkbox to be guessed at. */ +function RelatedRow({ + id, + icon, + kind, + label, + detail, + checked, + disabledReason, + first, + onCheckedChange, +}: { + id: string; + icon: LucideIcon; + kind: string; + label: string; + detail?: string; + checked: boolean; + disabledReason?: string; + first: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + const disabled = !!disabledReason; + const row = ( + + ); + if (!disabled) return row; + return ( + +
{row}
+
+ ); +} + +/** A skeleton bar in a box exactly one line of `textClass` tall. The hidden + * nbsp gives the box the host's real line height for that text size, so the + * bar never assumes Tailwind's defaults. */ +function SkeletonLine({ textClass, className }: { textClass: string; className: string }) { + return ( + +   + + + ); +} + +/** Heading + hint above the list. Static copy, so the loading state renders + * the real thing rather than guessing how it wraps. */ +function RelatedHeader() { + return ( +
+

Also delete related resources

+

+ These aren't removed with the workload. Anything left unchecked stays in your project and + may keep serving traffic or incurring cost. +

+
+ ); +} + +function KeptAlbHint({ workloadName }: { workloadName?: string }) { + return ( +

+ + + Kept load balancers reconnect if you redeploy a workload named{' '} + {workloadName}. + +

+ ); +} + +/** Same element tree as `RelatedRow`, with bars in place of text. */ +function RelatedRowSkeleton({ detail, first }: { detail: boolean; first: boolean }) { + return ( +
+ + + + + {detail && } + + +
+ ); +} + +/** + * Loading state for the "Also delete" section, shaped from what's already + * known so it resolves in place: the real header and hint, one row per ALB in + * the published-URL cache (same matcher as the lookup, with or without a + * hostname line) and one per network on the workload's template. + */ +function RelatedSkeleton({ + albs, + networkCount, + permissions, + workloadName, +}: { + albs: { hasHostname: boolean }[]; + networkCount: number; + permissions: DeletePermissions; + workloadName?: string; +}) { + // A row that can't be ticked shows its reason on the second line, so a + // permission-denied row is two lines even without a hostname. (Rows disabled + // for being shared can't be predicted before the lookup; those still grow.) + const rows = [ + ...albs.map((alb) => ({ detail: alb.hasHostname || !permissions.canDeleteAlb })), + ...Array.from({ length: networkCount }, () => ({ detail: !permissions.canDeleteNetwork })), + ]; + if (rows.length === 0) return null; + return ( +
+ +
+ {rows.map((row, index) => ( + + ))} +
+ {/* ALBs start unticked, so the reconnect hint shows whenever there is one. */} + {albs.length > 0 && } +
+ ); +} + +function failureSummary(result: DeleteWorkloadResult): string { + const albs = result.failed.filter((item) => item.kind === 'alb').map((item) => item.name); + const networks = result.failed.filter((item) => item.kind === 'network').map((item) => item.name); + const parts: string[] = []; + if (albs.length > 0) parts.push(`Application Load Balancer ${albs.join(', ')}`); + if (networks.length > 0) parts.push(`Network ${networks.join(', ')}`); + return `Couldn't delete ${parts.join(' and ')}. You can remove ${ + result.failed.length === 1 ? 'it' : 'them' + } from the project later.`; +} + +export function DeleteWorkloadDialog({ + projectId, + workload, + open, + permissions, + onClose, + onDeleted, +}: { + projectId?: string; + workload?: Workload; + open: boolean; + permissions: DeletePermissions; + onClose: () => void; + /** Called as soon as the user confirms, e.g. to leave the workload's detail page. */ + onDeleted?: (workloadName: string) => void; +}) { + const related = useWorkloadRelatedResources(projectId, open ? workload : undefined); + // Already cached by the page behind the dialog; only used to size the skeleton. + const published = usePublishedUrl(projectId, open ? workload?.name : undefined); + const skeletonAlbs = published.data + ? published.data.proxies.map((alb) => ({ hasHostname: !!alb.hostname })) + : published.data === null + ? [] + : [{ hasHostname: true }]; + const { mutateAsync, isPending } = useDeleteWorkload(projectId); + const [albs, setAlbs] = useState>(new Set()); + const [networks, setNetworks] = useState>(new Set()); + const [confirmText, setConfirmText] = useState(''); + + useEffect(() => { + if (!open) return; + setAlbs(new Set()); + setNetworks(new Set()); + setConfirmText(''); + }, [open, workload?.name]); + + // A failed lookup still lets the user delete the workload on its own. + const relatedData = related.data ?? (related.error ? NO_RELATED : undefined); + const relatedAlbs = relatedData?.albs ?? []; + const relatedNetworks = relatedData?.networks ?? []; + const hasRelated = relatedAlbs.length > 0 || relatedNetworks.length > 0; + // An ALB left in place still selects instances by workload name, so it + // picks a same-named workload back up — worth saying while one is unticked. + const keptAlb = relatedAlbs.some((alb) => !albs.has(alb.proxyName)); + + // Related resources must have loaded (or failed) before submitting, so the + // user never deletes with a checklist they haven't seen. + const canSubmit = confirmText === CONFIRM_VALUE && !!relatedData && !isPending; + + const close = () => { + if (!isPending) onClose(); + }; + + const submit = () => { + if (!workload || !canSubmit || !relatedData) return; + const name = workload.name; + // Leave straight away and let the deletes finish in the background. + // mutateAsync's promise still settles after this dialog (and the detail + // page) unmount, whereas mutate()'s per-call callbacks would be dropped. + mutateAsync({ + workloadName: name, + albs: [...albs], + networks: [...networks], + related: relatedData, + canDeleteNetworkService: permissions.canDeleteNetworkService, + }).then( + (result) => { + if (result.failed.length > 0) { + toast.warning(`Workload ${name} is being deleted`, { description: failureSummary(result) }); + } else { + toast.success(`Workload ${name} is being deleted`); + } + }, + (error: unknown) => { + toast.error( + error instanceof ApiError && error.status === 403 + ? "You don't have permission to delete this workload" + : `Failed to delete workload ${name}` + ); + } + ); + onClose(); + onDeleted?.(name); + }; + + return ( + !next && close()}> + + + + {related.isLoading && ( + + )} + + {related.error && ( +

+ Couldn't look up this workload's load balancers and networks. Only the workload will be deleted. +

+ )} + + {hasRelated && ( +
+ +
+ {relatedAlbs.map((alb, index) => ( + setAlbs((prev) => toggle(prev, alb.proxyName, on))} + /> + ))} + {relatedNetworks.map((network, index) => ( + 0 + ? `Used by ${plural(network.sharedWith.length, 'other workload', 'other workloads')}` + : undefined + } + onCheckedChange={(on) => + setNetworks((prev) => toggle(prev, network.name, on)) + } + /> + ))} +
+ {keptAlb && } +
+ )} + +
+ + setConfirmText(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') submit(); + }} + /> +
+
+ + + + +
+
+ ); +} diff --git a/ui/consumer/src/components/workload-page-chrome.tsx b/ui/consumer/src/components/workload-page-chrome.tsx index 86dc7fdb..d66a621b 100644 --- a/ui/consumer/src/components/workload-page-chrome.tsx +++ b/ui/consumer/src/components/workload-page-chrome.tsx @@ -3,8 +3,9 @@ * 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 { WorkloadStatusBadge } from './workload-status-badge'; +import type { Workload } from '../schema'; +import { Button } from '@datum-cloud/datum-ui/button'; import { Breadcrumb, BreadcrumbItem, @@ -15,7 +16,7 @@ import { } from '@datum-cloud/datum-ui/breadcrumb'; import { PageTitle } from '@datum-cloud/datum-ui/page-title'; import { Icon } from '@datum-cloud/datum-ui/icons'; -import { HomeIcon } from 'lucide-react'; +import { HomeIcon, Trash2Icon } from 'lucide-react'; import { Link } from 'react-router'; export function workloadDetailTabs(overviewHref: string, metricsHref: string): PluginTab[] { @@ -34,6 +35,7 @@ export function WorkloadPageChrome({ metricsHref, titleName, workload, + onDelete, children, }: { projectHref: string; @@ -42,6 +44,8 @@ export function WorkloadPageChrome({ metricsHref: string; titleName: string; workload?: Workload | null; + /** Omitted when the user can't delete this workload — the button is hidden. */ + onDelete?: () => void; children: React.ReactNode; }) { return ( @@ -75,9 +79,20 @@ export function WorkloadPageChrome({ descriptionClassName="break-all" actions={ workload ? ( - - {workload.health} - +
+ + {onDelete && ( + + )} +
) : undefined } /> diff --git a/ui/consumer/src/components/workload-status-badge.tsx b/ui/consumer/src/components/workload-status-badge.tsx new file mode 100644 index 00000000..0593de52 --- /dev/null +++ b/ui/consumer/src/components/workload-status-badge.tsx @@ -0,0 +1,47 @@ +/** + * The workload status pill (dot + badge) shared by the card grid, the table + * and the detail header. A workload being torn down gets its own treatment — + * a spinner and "Deleting" in a neutral tone — instead of whatever health its + * last status reported, so a disappearing workload doesn't look healthy. + */ +import { HEALTH_DOT_CLASS, statusLabel } from '../lib/workload-presenters'; +import { workloadHealthToBadgeType, type Workload } from '../schema'; +import { Badge } from '@datum-cloud/datum-ui/badge'; +import { SpinnerIcon } from '@datum-cloud/datum-ui/icons'; +import { cn } from '@datum-cloud/datum-ui/utils'; + +export function WorkloadStatusBadge({ + workload, + label = statusLabel(workload), + dot = true, +}: { + workload: Workload; + /** Overrides the healthy/degraded wording; ignored while deleting. */ + label?: string; + dot?: boolean; +}) { + if (workload.deleting) { + return ( + + {dot && } + + + Deleting + + + ); + } + return ( + + {dot && ( + + )} + + {label} + + + ); +} diff --git a/ui/consumer/src/components/workload-table.tsx b/ui/consumer/src/components/workload-table.tsx index de2e825e..ddd85543 100644 --- a/ui/consumer/src/components/workload-table.tsx +++ b/ui/consumer/src/components/workload-table.tsx @@ -10,6 +10,7 @@ * identical to the host's, so it picks up the same compiled styles. */ import { CpuMemorySparks, MetricSparkline } from './metric-sparkline'; +import { WorkloadStatusBadge } from './workload-status-badge'; import type { PublishedUrl } from '../lib/api'; import type { LocationIndex } from '../lib/locations'; import { @@ -24,9 +25,8 @@ import { HEALTH_ORDER, imageShortName, regionLabel, - statusLabel, } from '../lib/workload-presenters'; -import { workloadHealthToBadgeType, type Workload } from '../schema'; +import type { Workload } from '../schema'; import { Badge } from '@datum-cloud/datum-ui/badge'; import { DataTable, @@ -126,7 +126,7 @@ export function WorkloadTable({
{row.original.name} @@ -141,17 +141,7 @@ export function WorkloadTable({ id: 'status', accessorFn: (workload) => HEALTH_ORDER[workload.health], header: ({ column }) => , - cell: ({ row }) => ( -
- - - {statusLabel(row.original)} - -
- ), + cell: ({ row }) => , }, { id: 'activity', diff --git a/ui/consumer/src/lib/api.ts b/ui/consumer/src/lib/api.ts index cf4d2b16..129c1af5 100644 --- a/ui/consumer/src/lib/api.ts +++ b/ui/consumer/src/lib/api.ts @@ -590,13 +590,13 @@ function publishedFromAlbs(albs: ConnectedAlb[]): PublishedUrl | null { }; } -async function fetchPublishedUrls(projectId: string): Promise> { - const [services, proxies] = await Promise.all([ - listOrUnavailable(projectId, NETWORKSERVICES_PATH), - listOrUnavailable(projectId, HTTPPROXIES_PATH), - ]); - if (services === null || proxies === null) return {}; - +/** + * Which HTTPProxies reach which workload. Shared by the published-URL lookup + * and the delete dialog so both agree on what "connected" means: a proxy + * labelled with the workload's name, or one whose backend names a + * NetworkService that selects the workload's interfaces. + */ +function indexWorkloadProxies(services: RawNetworkService[], proxies: RawHttpProxy[]) { const serviceNamesByWorkload = new Map>(); for (const svc of services) { const workload = workloadNameForService(svc); @@ -607,26 +607,37 @@ async function fetchPublishedUrls(projectId: string): Promise(); - const addAlb = (workload: string, alb: ConnectedAlb) => { - const list = albsByWorkload.get(workload) ?? []; - if (!list.some((item) => item.proxyName === alb.proxyName)) list.push(alb); - albsByWorkload.set(workload, list); + const proxiesByWorkload = new Map(); + const addProxy = (workload: string, proxy: RawHttpProxy) => { + const list = proxiesByWorkload.get(workload) ?? []; + if (!list.some((item) => item.metadata?.name === proxy.metadata?.name)) list.push(proxy); + proxiesByWorkload.set(workload, list); }; for (const proxy of proxies) { - const alb = toConnectedAlb(proxy); - if (!alb) continue; - const labelled = proxy.metadata?.labels?.[INSTANCE_LABELS.workloadName]; - if (labelled) addAlb(labelled, alb); + if (!proxy.metadata?.name) continue; + const labelled = proxy.metadata.labels?.[INSTANCE_LABELS.workloadName]; + if (labelled) addProxy(labelled, proxy); const nsNames = proxyNetworkServiceNames(proxy); for (const [workload, names] of serviceNamesByWorkload) { - if (nsNames.some((name) => names.has(name))) addAlb(workload, alb); + if (nsNames.some((name) => names.has(name))) addProxy(workload, proxy); } } + return { serviceNamesByWorkload, proxiesByWorkload }; +} + +async function fetchPublishedUrls(projectId: string): Promise> { + const [services, proxies] = await Promise.all([ + listOrUnavailable(projectId, NETWORKSERVICES_PATH), + listOrUnavailable(projectId, HTTPPROXIES_PATH), + ]); + if (services === null || proxies === null) return {}; + + const { proxiesByWorkload } = indexWorkloadProxies(services, proxies); const result: Record = {}; - for (const [workload, albs] of albsByWorkload) { + for (const [workload, workloadProxies] of proxiesByWorkload) { + const albs = workloadProxies.map(toConnectedAlb).filter((alb): alb is ConnectedAlb => !!alb); const published = publishedFromAlbs(albs); if (published) result[workload] = published; } @@ -661,3 +672,283 @@ export function usePublishedUrl( isLoading: all.isLoading, }; } + +// ── Delete workload ────────────────────────────────────────────────────── +// +// Deleting the Workload tears down everything the compute controllers made +// for it (deployments, instances, interface claims, bindings). The ALB +// (HTTPProxy + the NetworkService behind it) and the Network are separate, +// user-created objects that nothing cleans up, so the delete dialog offers +// them as opt-in extras. + +export interface RelatedAlb extends ConnectedAlb { + /** Backend NetworkServices that select this workload — removed with the ALB. */ + serviceNames: string[]; + /** A backend routes to something other than this workload's services. */ + sharedWithOtherWorkloads: boolean; +} + +export interface RelatedNetwork { + name: string; + /** Other workloads whose template attaches to this network. */ + sharedWith: string[]; +} + +export interface WorkloadRelatedResources { + albs: RelatedAlb[]; + networks: RelatedNetwork[]; + /** NetworkService name → every HTTPProxy that backends to it, so a service + * still used by an ALB the user keeps is not deleted out from under it. */ + serviceProxies: Record; +} + +async function fetchWorkloadRelatedResources( + projectId: string, + workload: Workload +): Promise { + const [services, proxies, workloads] = await Promise.all([ + listOrUnavailable(projectId, NETWORKSERVICES_PATH), + listOrUnavailable(projectId, HTTPPROXIES_PATH), + fetchWorkloads(projectId), + ]); + + const networks = workload.networks.map((name) => ({ + name, + sharedWith: workloads + .filter((other) => other.name !== workload.name && other.networks.includes(name)) + .map((other) => other.name), + })); + + if (services === null || proxies === null) { + return { albs: [], networks, serviceProxies: {} }; + } + + const { serviceNamesByWorkload, proxiesByWorkload } = indexWorkloadProxies(services, proxies); + const ownServices = serviceNamesByWorkload.get(workload.name) ?? new Set(); + + const serviceProxies: Record = {}; + for (const proxy of proxies) { + const proxyName = proxy.metadata?.name; + if (!proxyName) continue; + for (const svc of proxyNetworkServiceNames(proxy)) { + (serviceProxies[svc] ??= []).push(proxyName); + } + } + + const albs: RelatedAlb[] = []; + for (const proxy of proxiesByWorkload.get(workload.name) ?? []) { + const alb = toConnectedAlb(proxy); + if (!alb) continue; + const backends = proxyNetworkServiceNames(proxy); + albs.push({ + ...alb, + serviceNames: backends.filter((name) => ownServices.has(name)), + sharedWithOtherWorkloads: backends.some((name) => !ownServices.has(name)), + }); + } + + return { albs, networks, serviceProxies }; +} + +export function useWorkloadRelatedResources( + projectId: string | undefined, + workload: Workload | undefined +): UseQueryResult { + return useQuery({ + queryKey: [PLUGIN_ID, 'workload-related', projectId, workload?.name], + enabled: !!projectId && !!workload, + queryFn: () => fetchWorkloadRelatedResources(projectId as string, workload as Workload), + retry: false, + }); +} + +// ── Delete permissions ─────────────────────────────────────────────────── +// +// Same answers as the portal's `useResourcePermissions({ scope: 'project' })` +// (app/modules/rbac): a resource-level SelfSubjectAccessReview in the +// project's `default` namespace, failing closed. The host's RBAC hooks aren't +// exposed to plugins, so the SSARs go through the control-plane proxy like +// every other call here. One query per page, not per row. + +const SSAR_PATH = '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews'; +const PERMISSION_STALE_MS = 5 * 60_000; + +async function canDelete(projectId: string, group: string, resource: string): Promise { + const res = await fetch(`${getProjectScopedBase(projectId)}${SSAR_PATH}`, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + apiVersion: 'authorization.k8s.io/v1', + kind: 'SelfSubjectAccessReview', + spec: { resourceAttributes: { group, resource, verb: 'delete', namespace: 'default' } }, + }), + }); + if (!res.ok) return false; + const body = (await res.json()) as { status?: { allowed?: boolean; denied?: boolean } }; + return !!body.status?.allowed && !body.status?.denied; +} + +export interface DeletePermissions { + canDeleteWorkload: boolean; + canDeleteAlb: boolean; + canDeleteNetworkService: boolean; + canDeleteNetwork: boolean; +} + +const NO_DELETE_PERMISSIONS: DeletePermissions = { + canDeleteWorkload: false, + canDeleteAlb: false, + canDeleteNetworkService: false, + canDeleteNetwork: false, +}; + +async function fetchDeletePermissions(projectId: string): Promise { + const [canDeleteWorkload, canDeleteAlb, canDeleteNetworkService, canDeleteNetwork] = + await Promise.all([ + canDelete(projectId, 'compute.datumapis.com', 'workloads'), + canDelete(projectId, 'networking.datumapis.com', 'httpproxies'), + canDelete(projectId, 'networking.datumapis.com', 'networkservices'), + canDelete(projectId, 'networking.datumapis.com', 'networks'), + ]); + return { canDeleteWorkload, canDeleteAlb, canDeleteNetworkService, canDeleteNetwork }; +} + +export function useDeletePermissions( + projectId: string | undefined +): DeletePermissions & { isLoading: boolean } { + const query = useQuery({ + queryKey: [PLUGIN_ID, 'permissions', projectId], + enabled: !!projectId, + queryFn: () => fetchDeletePermissions(projectId as string), + staleTime: PERMISSION_STALE_MS, + retry: false, + }); + // Pending (including disabled) reads as loading so nothing flashes in and out. + return { ...(query.data ?? NO_DELETE_PERMISSIONS), isLoading: query.isPending }; +} + +// ── Delete mutation ────────────────────────────────────────────────────── + +/** DELETEs one object; a 404 means it's already gone, which is what we wanted. */ +async function proxyDelete(projectId: string, path: string): Promise { + const res = await fetch(`${getProjectScopedBase(projectId)}${path}`, { + method: 'DELETE', + headers: { Accept: 'application/json' }, + }); + if (!res.ok && res.status !== 404) { + throw new ApiError(res.status, `Request failed (${res.status}): ${path}`); + } +} + +export type RelatedKind = 'alb' | 'network'; + +export interface DeleteWorkloadInput { + workloadName: string; + /** HTTPProxy names the user ticked. */ + albs: string[]; + /** Network names the user ticked. */ + networks: string[]; + related: WorkloadRelatedResources; + canDeleteNetworkService: boolean; +} + +export interface DeleteWorkloadResult { + failed: { kind: RelatedKind; name: string; error: ApiError }[]; +} + +async function deleteWorkload( + projectId: string, + input: DeleteWorkloadInput +): Promise { + // The Workload goes first: if that is refused, nothing else is touched. + await proxyDelete(projectId, `${WORKLOADS_PATH}/${input.workloadName}`); + + const failed: DeleteWorkloadResult['failed'] = []; + const attempt = async (kind: RelatedKind, name: string, run: () => Promise) => { + try { + await run(); + } catch (error) { + failed.push({ + kind, + name, + error: error instanceof ApiError ? error : new ApiError(0, String(error)), + }); + } + }; + + const selected = new Set(input.albs); + for (const alb of input.related.albs.filter((item) => selected.has(item.proxyName))) { + await attempt('alb', alb.proxyName, async () => { + await proxyDelete(projectId, `${HTTPPROXIES_PATH}/${alb.proxyName}`); + if (!input.canDeleteNetworkService) return; + for (const svc of alb.serviceNames) { + // Keep a service another, un-ticked ALB still routes to. + const users = input.related.serviceProxies[svc] ?? []; + if (users.some((proxy) => !selected.has(proxy))) continue; + await proxyDelete(projectId, `${NETWORKSERVICES_PATH}/${svc}`); + } + }); + } + + // The Network's in-use finalizer holds it in Terminating until the workload's + // bindings are released, so it's safe to ask right away. + for (const network of input.networks) { + await attempt('network', network, () => proxyDelete(projectId, `${NETWORKS_PATH}/${network}`)); + } + + return { failed }; +} + +/** + * cloud-portal's own query-key roots for the objects a workload delete can + * remove or orphan. The host's cache is shared with this plugin (see the top + * of this file), so invalidating these makes the ALB pages refetch on their + * next mount instead of showing a deleted ALB, or a workload link that now + * 404s. Mirrors `httpProxyKeys.all`, `networkServiceKeys.all` and + * `computeWorkloadKeys.all` in cloud-portal's `app/resources/*`. + */ +const HOST_QUERY_ROOTS = [['http-proxies'], ['network-services'], ['compute-workloads']] as const; + +type DeleteWorkloadContext = { previous?: Workload[] }; + +export function useDeleteWorkload( + projectId: string | undefined +): UseMutationResult { + const queryClient = useQueryClient(); + const listKey = [PLUGIN_ID, 'workloads', projectId]; + return useMutation({ + mutationFn: (input) => deleteWorkload(projectId as string, input), + // Show "Deleting" straight away: the detail page navigates to the list on + // confirm, before the DELETE has returned for the next poll to pick up. + onMutate: async (input) => { + await queryClient.cancelQueries({ queryKey: listKey }); + const previous = queryClient.getQueryData(listKey); + queryClient.setQueryData(listKey, (list) => + list?.map((item) => (item.name === input.workloadName ? { ...item, deleting: true } : item)) + ); + return { previous }; + }, + onError: (_error, _input, context) => { + if (context?.previous) queryClient.setQueryData(listKey, context.previous); + }, + onSettled: (_data, _error, input) => { + for (const key of ['workloads', 'instances', 'published-urls', 'workload-related']) { + void queryClient.invalidateQueries({ queryKey: [PLUGIN_ID, key, projectId] }); + } + for (const queryKey of HOST_QUERY_ROOTS) { + void queryClient.invalidateQueries({ queryKey: [...queryKey] }); + } + const detailKeys = [ + [PLUGIN_ID, 'workload', projectId, input.workloadName], + [PLUGIN_ID, 'workload-instances', projectId, input.workloadName], + ]; + for (const queryKey of detailKeys) void queryClient.cancelQueries({ queryKey }); + // Drop the detail cache once the page has navigated away, so a later + // visit doesn't render the deleted workload from cache first. Doing it + // now would make the still-mounted detail page refetch into a 404. + setTimeout(() => { + for (const queryKey of detailKeys) queryClient.removeQueries({ queryKey, type: 'inactive' }); + }, 0); + }, + }); +} diff --git a/ui/consumer/src/lib/workload-presenters.ts b/ui/consumer/src/lib/workload-presenters.ts index aa6e5d6f..2ecb6e16 100644 --- a/ui/consumer/src/lib/workload-presenters.ts +++ b/ui/consumer/src/lib/workload-presenters.ts @@ -20,6 +20,7 @@ export const HEALTH_ORDER: Record = { }; export function statusLabel(workload: Workload): string { + if (workload.deleting) return 'Deleting'; if (workload.health === 'Available') { const ready = workload.readyReplicas; const desired = workload.desiredReplicas; diff --git a/ui/consumer/src/pages/workload-detail.tsx b/ui/consumer/src/pages/workload-detail.tsx index a41c06fd..619f6fa3 100644 --- a/ui/consumer/src/pages/workload-detail.tsx +++ b/ui/consumer/src/pages/workload-detail.tsx @@ -14,6 +14,7 @@ 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 { DeleteWorkloadDialog, useDeleteWorkloadDialog } from '../components/delete-workload-dialog'; import { DEFAULT_OVERVIEW_RANGE, OVERVIEW_RANGE_OPTIONS, @@ -23,7 +24,13 @@ import { } 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 { + useDeletePermissions, + usePublishedUrl, + useWorkload, + useWorkloadInstances, + type PublishedUrl, +} from '../lib/api'; import { splitSlashValue } from '../lib/format'; import { formatLocationName, formatLocationNames, formatLocationTooltip, useLocationIndex, type LocationIndex } from '../lib/locations'; import { @@ -414,6 +421,13 @@ function WorkloadLayoutShell({ const metricKeys = useMemo(() => identityValues(instances), [instances]); const instanceNames = useMemo(() => instances.map((instance) => instance.name), [instances]); const proxyId = published.data?.proxyName; + const navigate = useNavigate(); + const permissions = useDeletePermissions(projectId); + const deleteDialog = useDeleteWorkloadDialog(); + // Hidden (not disabled) without permission, per the portal's RBAC conventions. + const canDelete = + !isLoading && !error && !!workload && !workload.deleting && + !permissions.isLoading && permissions.canDeleteWorkload; return ( + workload={workload} + onDelete={canDelete && workload ? () => deleteDialog.show(workload) : undefined}> + navigate(workloadsHref)} + /> + {isLoading && (pathname === metricsHref || pathname.startsWith(`${metricsHref}/`) ? ( diff --git a/ui/consumer/src/pages/workload-list.tsx b/ui/consumer/src/pages/workload-list.tsx index 41621090..fd1bf628 100644 --- a/ui/consumer/src/pages/workload-list.tsx +++ b/ui/consumer/src/pages/workload-list.tsx @@ -10,6 +10,7 @@ import { ComputeEnablementBanner } from "../components/compute-enablement-banner import { formatKpiValue } from "../components/metric-area-chart"; import { CpuMemorySparks } from "../components/metric-sparkline"; import { SparklineStatCard } from "../components/sparkline-stat-card"; +import { WorkloadStatusBadge } from "../components/workload-status-badge"; import { WorkloadListCardsSkeleton, WorkloadListTableSkeleton } from "../components/skeletons"; import { ErrorOrRestrictedState } from "../components/states"; import { @@ -32,8 +33,8 @@ import { import { lastThirtyMinutesRange, usePrometheusCard } from "../lib/prometheus"; import { useOverviewRange } from "../components/overview-range"; import { useLocationIndex, type LocationIndex } from "../lib/locations"; -import { HEALTH_DOT_CLASS, regionLabel, statusLabel } from "../lib/workload-presenters"; -import { workloadHealthToBadgeType, type Workload } from "../schema"; +import { HEALTH_DOT_CLASS, regionLabel } from "../lib/workload-presenters"; +import type { Workload } from "../schema"; import { Badge } from "@datum-cloud/datum-ui/badge"; import { Button, LinkButton } from "@datum-cloud/datum-ui/button"; import { Dialog } from "@datum-cloud/datum-ui/dialog"; @@ -504,17 +505,11 @@ function WorkloadCard({ )} -
- - - {statusLabel(workload)} - -
+
- + {/* Dimmed while deleting: the numbers are the last ones before teardown. */} + - Updated {formatDistanceToNowStrict(updatedAt, { addSuffix: true })} + {workload.deleting + ? "Deleting — instances are being stopped" + : `Updated ${formatDistanceToNowStrict(updatedAt, { addSuffix: true })}`} ;