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 4f89763a..2a75bb84 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 72b5c374..63539eb1 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 2b994774..4d76c195 100644 --- a/ui/consumer/src/components/workload-page-chrome.tsx +++ b/ui/consumer/src/components/workload-page-chrome.tsx @@ -3,6 +3,7 @@ * Used by the splat layout at `:workloadName/*`. */ import { PluginTabs, type PluginTab } from './plugin-tabs'; +import { Button } from '@datum-cloud/datum-ui/button'; import { Breadcrumb, BreadcrumbItem, @@ -13,7 +14,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( @@ -37,6 +38,7 @@ export function WorkloadPageChrome({ metricsHref, logsHref, titleName, + onDelete, children, }: { projectHref: string; @@ -45,6 +47,8 @@ export function WorkloadPageChrome({ metricsHref: string; logsHref: string; titleName: string; + /** Omitted when the user can't delete this workload — the button is hidden. */ + onDelete?: () => void; children: React.ReactNode; }) { return ( @@ -76,6 +80,19 @@ export function WorkloadPageChrome({ className="flex-col items-start gap-3 sm:flex-row sm:items-center" description={titleName} descriptionClassName="break-all" + actions={ + onDelete ? ( + + ) : undefined + } /> + {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 7c81bb58..03b6eb80 100644 --- a/ui/consumer/src/lib/api.ts +++ b/ui/consumer/src/lib/api.ts @@ -606,13 +606,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); @@ -623,26 +623,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; } @@ -677,3 +688,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 0299bb15..720f49a5 100644 --- a/ui/consumer/src/pages/workload-detail.tsx +++ b/ui/consumer/src/pages/workload-detail.tsx @@ -15,6 +15,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, @@ -29,6 +30,7 @@ import { } from "../components/skeletons"; import { ErrorOrRestrictedState } from "../components/states"; import { + useDeletePermissions, usePublishedUrl, useWorkload, useWorkloadInstances, @@ -506,6 +508,13 @@ function WorkloadLayoutShell({ [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 ( + onDelete={canDelete && workload ? () => deleteDialog.show(workload) : undefined}> + navigate(workloadsHref)} + /> + {isLoading && (pathname === logsHref || pathname.startsWith(`${logsHref}/`) ? ( 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 })}`} ;