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 (
-
-
+
);
}
+
+/** 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 && (
<>