diff --git a/ui/consumer/src/components/cli-section.tsx b/ui/consumer/src/components/cli-section.tsx index 9d247b20..3478611e 100644 --- a/ui/consumer/src/components/cli-section.tsx +++ b/ui/consumer/src/components/cli-section.tsx @@ -79,33 +79,67 @@ export function SectionCard({ ); } -/** 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 }) { +/** + * Shared shell for the plugin's full-width banners: icon + title/description + * on the left, an actions slot on the right. Used by `CliBanner` below and by + * `ComputeEnablementBanner` (compute-enablement-banner.tsx) so the two share + * one place to adjust spacing/colors/responsive behavior instead of drifting + * apart as separately hand-rolled markup. + */ +export function Banner({ + icon, + title, + description, + actions, + testId, +}: { + icon: React.ReactNode; + title: string; + description: string; + actions: React.ReactNode; + testId?: string; +}) { return ( -
- +
+ {icon}

{title}

{description}

-
- - - Install CLI - - - - CLI Docs - -
+
{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 }) { + return ( + } + title={title} + description={description} + actions={ + <> + + + Install CLI + + + + CLI Docs + + + } + /> + ); +} diff --git a/ui/consumer/src/components/compute-enablement-banner.tsx b/ui/consumer/src/components/compute-enablement-banner.tsx new file mode 100644 index 00000000..ded13372 --- /dev/null +++ b/ui/consumer/src/components/compute-enablement-banner.tsx @@ -0,0 +1,72 @@ +/** + * Shown on the Workloads page in place of the CLI empty-state when the + * project doesn't (yet) have an Active `compute` ServiceEntitlement — see + * `lib/api.ts`'s `useComputeEntitlement`/`useRequestComputeAccess`. The + * "Enable Compute" button creates the same ServiceEntitlement object that + * `datumctl compute deploy`'s "Would you like to request access?" prompt + * does, so a request made here shows up identically to one made via the CLI. + */ +import { Banner } from './cli-section'; +import { useRequestComputeAccess, type EntitlementPhase } from '../lib/api'; +import { Button } from '@datum-cloud/datum-ui/button'; +import { toast } from '@datum-cloud/datum-ui/toast'; +import { ClockIcon, ShieldAlertIcon, XCircleIcon } from 'lucide-react'; + +type BannerState = 'NotRequested' | 'PendingApproval' | 'Rejected'; + +const COPY: Record = { + NotRequested: { + title: 'This project does not have Compute enabled', + description: 'You need to enable Compute to create Workloads in this project.', + icon: , + cta: 'Enable Compute', + }, + PendingApproval: { + title: 'Compute access is pending approval', + description: + 'A request to enable Compute for this project has been sent to the service provider and is awaiting approval.', + icon: , + cta: 'Enable Compute', + }, + Rejected: { + title: 'Compute access was declined', + description: 'The request to enable Compute for this project was declined. You can request access again.', + icon: , + cta: 'Request access again', + }, +}; + +function bannerState(phase: EntitlementPhase | null): BannerState { + if (phase === 'PendingApproval') return 'PendingApproval'; + if (phase === 'Rejected') return 'Rejected'; + return 'NotRequested'; +} + +export function ComputeEnablementBanner({ projectId, phase }: { projectId: string; phase: EntitlementPhase | null }) { + const { mutate, isPending } = useRequestComputeAccess(projectId); + const state = bannerState(phase); + const copy = COPY[state]; + + const handleClick = () => { + mutate(undefined, { + onSuccess: () => toast.success('Requested Compute access for this project'), + onError: () => toast.error('Failed to request Compute access'), + }); + }; + + return ( + + {copy.cta} + + ) + } + /> + ); +} diff --git a/ui/consumer/src/lib/api.ts b/ui/consumer/src/lib/api.ts index cda829cf..9f4a13a6 100644 --- a/ui/consumer/src/lib/api.ts +++ b/ui/consumer/src/lib/api.ts @@ -22,7 +22,13 @@ import { toInstance, toInstanceList, toWorkload, toWorkloadList, INSTANCE_LABELS } from '../adapter'; import type { RawInstance, RawInstanceList, RawWorkload, RawWorkloadList } from '../adapter'; import type { Instance, Workload } from '../schema'; -import { useQuery, type UseQueryResult } from '@tanstack/react-query'; +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationResult, + type UseQueryResult, +} from '@tanstack/react-query'; /** * Query keys are NAMESPACED under the canonical plugin id. Plugin queries @@ -78,16 +84,111 @@ async function fetchWorkload(projectId: string, name: string): Promise return toWorkload(raw); } -export function useWorkloads(projectId: string | undefined): UseQueryResult { +export function useWorkloads( + projectId: string | undefined, + enabled = true +): UseQueryResult { return useQuery({ queryKey: [PLUGIN_ID, 'workloads', projectId], - enabled: !!projectId, + enabled: !!projectId && enabled, queryFn: () => fetchWorkloads(projectId as string), refetchInterval: REFETCH_INTERVAL_MS, retry: false, // RBAC/entitlement failures shouldn't retry-storm }); } +// ── Compute service entitlement ───────────────────────────────────────── +// +// Mirrors datumctl's `serviceactivation` gate: a project must have an Active +// `ServiceEntitlement` named "compute" before the Compute API is usable. The +// entitlement is a cluster-scoped resource in the project's own control +// plane (services.miloapis.com/v1alpha1), fetched/created through the same +// proxy as everything else above. + +const SERVICE_ENTITLEMENTS_PATH = '/apis/services.miloapis.com/v1alpha1/serviceentitlements'; + +/** metadata.name of the compute ServiceEntitlement — one per project, named after the service. */ +const COMPUTE_SERVICE_NAME = 'compute'; + +export type EntitlementPhase = 'PendingApproval' | 'Active' | 'Rejected'; + +const ENTITLEMENT_PHASES: readonly EntitlementPhase[] = ['PendingApproval', 'Active', 'Rejected']; + +function isEntitlementPhase(value: unknown): value is EntitlementPhase { + return typeof value === 'string' && (ENTITLEMENT_PHASES as readonly string[]).includes(value); +} + +interface RawServiceEntitlement { + status?: { + phase?: string; + }; +} + +export interface ComputeEntitlement { + /** `null` means no ServiceEntitlement has been requested for this project yet. */ + phase: EntitlementPhase | null; +} + +async function fetchComputeEntitlement(projectId: string): Promise { + const url = `${getProjectScopedBase(projectId)}${SERVICE_ENTITLEMENTS_PATH}/${COMPUTE_SERVICE_NAME}`; + const res = await fetch(url, { headers: { Accept: 'application/json' } }); + if (res.status === 404) { + return { phase: null }; + } + if (!res.ok) { + throw new ApiError(res.status, `Request failed (${res.status}): ${SERVICE_ENTITLEMENTS_PATH}`); + } + const body = (await res.json()) as RawServiceEntitlement; + const rawPhase = body.status?.phase; + // The entitlement exists — a missing or unrecognized phase (a just-created + // object has no status yet; an unrecognized one means a phase this UI + // doesn't know about) is treated as PendingApproval rather than passed + // through raw, so an unknown value can never be mistaken for "not + // requested" and re-trigger a request. + return { phase: isEntitlementPhase(rawPhase) ? rawPhase : 'PendingApproval' }; +} + +export function useComputeEntitlement( + projectId: string | undefined +): UseQueryResult { + return useQuery({ + queryKey: [PLUGIN_ID, 'compute-entitlement', projectId], + enabled: !!projectId, + queryFn: () => fetchComputeEntitlement(projectId as string), + retry: false, + }); +} + +async function requestComputeEntitlement(projectId: string): Promise { + const url = `${getProjectScopedBase(projectId)}${SERVICE_ENTITLEMENTS_PATH}`; + const res = await fetch(url, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + apiVersion: 'services.miloapis.com/v1alpha1', + kind: 'ServiceEntitlement', + metadata: { name: COMPUTE_SERVICE_NAME }, + spec: { serviceRef: { name: COMPUTE_SERVICE_NAME } }, + }), + }); + // 409 AlreadyExists is a benign race (e.g. a second click) — not a failure. + if (!res.ok && res.status !== 409) { + throw new ApiError(res.status, `Request failed (${res.status}): ${SERVICE_ENTITLEMENTS_PATH}`); + } +} + +export function useRequestComputeAccess( + projectId: string | undefined +): UseMutationResult { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => requestComputeEntitlement(projectId as string), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: [PLUGIN_ID, 'compute-entitlement', projectId] }); + }, + }); +} + export function useWorkload( projectId: string | undefined, name: string | undefined diff --git a/ui/consumer/src/pages/workload-list.tsx b/ui/consumer/src/pages/workload-list.tsx index e72361a3..af800674 100644 --- a/ui/consumer/src/pages/workload-list.tsx +++ b/ui/consumer/src/pages/workload-list.tsx @@ -6,9 +6,10 @@ * telemetry (Requests, Avg CPU) shows muted "Coming soon". */ import { CliBanner, SectionCard } from "../components/cli-section"; +import { ComputeEnablementBanner } from "../components/compute-enablement-banner"; import { StatStrip } from "../components/stat-strip"; import { ErrorOrRestrictedState, LoadingSkeleton } from "../components/states"; -import { useWorkloads } from "../lib/api"; +import { useComputeEntitlement, useWorkloads } from "../lib/api"; import { workloadHealthToBadgeType, type Workload, @@ -137,6 +138,34 @@ function MetricCell({ ); } +/** The "Deploy a workload" / "List & inspect workloads" cards — datumctl handles enabling + * Compute itself (the same "Would you like to request access?" prompt the banner's button + * triggers), so these commands work whether or not Compute is enabled yet. */ +function WorkloadCliSections({ projectId }: { projectId: string | undefined }) { + return ( +
+ } + title="Deploy a workload" + description="Create a workload manifest and deploy it to your project. The dashboard will reflect the new workload within seconds." + commands={[ + "datumctl compute deploy -f workload.yaml", + `datumctl compute deploy --project=${projectId ?? ""} -f workload.yaml`, + ]} + /> + } + title="List & inspect workloads" + description="Confirm your workload deployed successfully and inspect its current health and placement status." + commands={[ + "datumctl compute workloads list", + "datumctl compute workloads describe ", + ]} + /> +
+ ); +} + function WorkloadCard({ workload, onClick, @@ -244,12 +273,23 @@ export default function WorkloadList() { const { projectId } = useParams<{ projectId: string; serviceSlug: string }>(); const navigate = useNavigate(); const location = useLocation(); + + const { + data: entitlement, + isLoading: isEntitlementLoading, + error: entitlementError, + refetch: refetchEntitlement, + } = useComputeEntitlement(projectId); + const computeEnabled = entitlement?.phase === "Active"; + const { data: workloads, - isLoading, + isLoading: isWorkloadsLoading, error, refetch, - } = useWorkloads(projectId); + } = useWorkloads(projectId, computeEnabled); + + const isLoading = isEntitlementLoading || (computeEnabled && isWorkloadsLoading); // Build the child route path from the current URL rather than the portal's // internal `paths.config.ts` (unavailable to plugins) — the host mounts this @@ -284,7 +324,25 @@ export default function WorkloadList() { {isLoading && } - {!isLoading && error && ( + {!isEntitlementLoading && entitlementError && ( + void refetchEntitlement()} + /> + )} + + {!isEntitlementLoading && !entitlementError && !computeEnabled && projectId && ( +
+ + +
+ )} + + {!isLoading && computeEnabled && error && ( )} - {!isLoading && !error && (workloads?.length ?? 0) === 0 && ( + {!isLoading && computeEnabled && !error && (workloads?.length ?? 0) === 0 && (
-
- } - title="Deploy a workload" - description="Create a workload manifest and deploy it to your project. The dashboard will reflect the new workload within seconds." - commands={[ - "datumctl compute deploy -f workload.yaml", - `datumctl compute deploy --project=${projectId ?? ""} -f workload.yaml`, - ]} - /> - } - title="List & inspect workloads" - description="Confirm your workload deployed successfully and inspect its current health and placement status." - commands={[ - "datumctl compute workloads list", - "datumctl compute workloads describe ", - ]} - /> -
+
)} - {!isLoading && !error && workloads && workloads.length > 0 && ( + {!isLoading && computeEnabled && !error && workloads && workloads.length > 0 && ( <>