Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 56 additions & 22 deletions ui/consumer/src/components/cli-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="bg-primary/5 border-primary/20 flex flex-col gap-4 rounded-xl border p-4 sm:flex-row sm:items-center">
<SquareTerminalIcon className="text-primary size-8 shrink-0" />
<div
className="bg-primary/5 border-primary/20 flex flex-col gap-4 rounded-xl border p-4 sm:flex-row sm:items-center"
data-testid={testId}>
{icon}
<div className="min-w-0 flex-1">
<p className="text-primary font-semibold">{title}</p>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<a
href="https://docs.datum.net/cli/install"
target="_blank"
rel="noreferrer"
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors">
<DownloadIcon className="size-4" />
Install CLI
</a>
<a
href="https://docs.datum.net/cli"
target="_blank"
rel="noreferrer"
className="border-border hover:bg-muted inline-flex items-center justify-center gap-1.5 rounded-md border px-3 py-2 text-sm font-medium transition-colors">
<BookOpenIcon className="size-4" />
CLI Docs
</a>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">{actions}</div>
</div>
);
}

/** 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 (
<Banner
icon={<SquareTerminalIcon className="text-primary size-8 shrink-0" />}
title={title}
description={description}
actions={
<>
<a
href="https://docs.datum.net/cli/install"
target="_blank"
rel="noreferrer"
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors">
<DownloadIcon className="size-4" />
Install CLI
</a>
<a
href="https://docs.datum.net/cli"
target="_blank"
rel="noreferrer"
className="border-border hover:bg-muted inline-flex items-center justify-center gap-1.5 rounded-md border px-3 py-2 text-sm font-medium transition-colors">
<BookOpenIcon className="size-4" />
CLI Docs
</a>
</>
}
/>
);
}
72 changes: 72 additions & 0 deletions ui/consumer/src/components/compute-enablement-banner.tsx
Original file line number Diff line number Diff line change
@@ -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<BannerState, { title: string; description: string; icon: React.ReactNode; cta: string }> = {
NotRequested: {
title: 'This project does not have Compute enabled',
description: 'You need to enable Compute to create Workloads in this project.',
icon: <ShieldAlertIcon className="text-primary size-8 shrink-0" />,
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: <ClockIcon className="text-primary size-8 shrink-0" />,
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: <XCircleIcon className="text-destructive size-8 shrink-0" />,
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 (
<Banner
testId="compute-plugin-enablement-banner"
icon={copy.icon}
title={copy.title}
description={copy.description}
actions={
state !== 'PendingApproval' && (
<Button loading={isPending} disabled={isPending} onClick={handleClick}>
{copy.cta}
</Button>
)
}
/>
);
}
107 changes: 104 additions & 3 deletions ui/consumer/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,16 +84,111 @@ async function fetchWorkload(projectId: string, name: string): Promise<Workload>
return toWorkload(raw);
}

export function useWorkloads(projectId: string | undefined): UseQueryResult<Workload[], ApiError> {
export function useWorkloads(
projectId: string | undefined,
enabled = true
): UseQueryResult<Workload[], ApiError> {
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<ComputeEntitlement> {
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<ComputeEntitlement, ApiError> {
return useQuery({
queryKey: [PLUGIN_ID, 'compute-entitlement', projectId],
enabled: !!projectId,
queryFn: () => fetchComputeEntitlement(projectId as string),
retry: false,
});
}

async function requestComputeEntitlement(projectId: string): Promise<void> {
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<void, ApiError, void> {
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
Expand Down
Loading
Loading