Files
pieced-portal/src/components/onboarding/provisioning-status.tsx

528 lines
18 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card";
import { Modal } from "@/components/ui/modal";
import { StatusBadge } from "@/components/ui/status-badge";
import { formatDateTime, formatRelative } from "@/lib/format";
interface RequestSummary {
id: string;
instanceName?: string | null;
agentName: string;
packages: string[];
status: string;
adminNotes?: string;
tenantName?: string;
dismissedAt?: string | null;
createdAt?: string;
updatedAt?: string;
}
interface TenantSummary {
name: string;
displayName: string;
phase: string;
conditions: Array<{
type: string;
status: string;
reason?: string;
message?: string;
lastTransitionTime?: string;
}>;
}
interface SingleRequestState {
request: RequestSummary;
tenant: TenantSummary | null;
}
interface Props {
requestId: string;
/**
* Whether the viewer can act on this request — cancel a pending one,
* dismiss a rejected one, etc. True for owner + platform; false for
* `user`-role customers (who shouldn't see in-flight requests at all,
* but defence in depth — `canSeeInflightRequests` already gates the
* dashboard side).
*/
canAct: boolean;
}
/**
* ProvisioningStatus
*
* Polls /api/onboarding?id=<requestId> every 5s until the request reaches
* a terminal state. Slice 3: takes a `requestId` prop so multiple of
* these can render on the same dashboard for different in-flight
* requests.
*
* Slice 7 / Bug 6 + 13:
* - pending → cancel + edit buttons
* - rejected → admin notes block + dismiss button
* - cancelled → small acknowledgement card + dismiss button
* - terminal Ready/Active states unchanged
*/
export function ProvisioningStatus({ requestId, canAct }: Props) {
const t = useTranslations("onboarding");
const tCommon = useTranslations("common");
const f = useFormatter();
const router = useRouter();
const [data, setData] = useState<SingleRequestState | null>(null);
const [error, setError] = useState("");
const [actionPending, setActionPending] = useState(false);
const [confirmCancel, setConfirmCancel] = useState(false);
const poll = useCallback(async () => {
try {
const res = await fetch(
`/api/onboarding?id=${encodeURIComponent(requestId)}`
);
if (!res.ok) throw new Error("Failed to fetch status");
const json = await res.json();
setData(json);
} catch (err: any) {
setError(err.message);
}
}, [requestId]);
useEffect(() => {
poll();
const status = data?.request?.status;
const phase = data?.tenant?.phase;
const terminal =
status === "rejected" ||
status === "cancelled" ||
status === "active" ||
phase === "Ready" ||
phase === "Running";
if (terminal) return;
const interval = setInterval(poll, 5000);
return () => clearInterval(interval);
}, [poll, data?.request?.status, data?.tenant?.phase]);
const handleCancel = async () => {
setActionPending(true);
setError("");
try {
const res = await fetch(
`/api/onboarding/${encodeURIComponent(requestId)}`,
{ method: "DELETE" }
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || t("cancelFailed"));
}
setConfirmCancel(false);
// Re-poll so the card transitions to "cancelled" state without a
// full route refresh — the dashboard's surrounding tenant cards
// are unaffected.
await poll();
router.refresh();
} catch (err: any) {
setError(err.message);
} finally {
setActionPending(false);
}
};
const handleDismiss = async () => {
setActionPending(true);
setError("");
try {
const res = await fetch(
`/api/onboarding/${encodeURIComponent(requestId)}/dismiss`,
{ method: "POST" }
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || t("dismissFailed"));
}
// Server-rendered list query (`listActiveTenantRequestsByOrgId`)
// filters out dismissed rows — refresh to drop this card.
router.refresh();
} catch (err: any) {
setError(err.message);
} finally {
setActionPending(false);
}
};
if (error && !data) {
return (
<Card>
<div className="text-xs text-red-400">{error}</div>
</Card>
);
}
if (!data) {
return (
<Card>
<div className="flex items-center gap-2 text-sm text-text-muted">
<div className="h-4 w-4 border-2 border-accent border-t-transparent rounded-full animate-spin" />
{t("loading")}
</div>
</Card>
);
}
const status = data.request.status;
const label =
data.request.instanceName ||
data.request.tenantName ||
data.request.agentName;
// ─── Pending: awaiting admin approval ───────────────────────────────
if (status === "pending") {
return (
<Card className="animate-in">
<div className="text-center py-6">
<div className="h-14 w-14 rounded-xl bg-amber-500/15 flex items-center justify-center mx-auto mb-4">
<svg
className="h-7 w-7 text-amber-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("pendingTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("pendingDescription")}
</p>
{data.request.createdAt && (
<p
className="text-xs text-text-muted mt-4"
title={formatDateTime(data.request.createdAt, f)}
>
{t("submittedAt")}{" "}
<span className="text-text-secondary">
{formatRelative(data.request.createdAt, f)}
</span>{" "}
<span className="text-text-muted/60">
({formatDateTime(data.request.createdAt, f)})
</span>
</p>
)}
{/* Bug 6 — owner-only edit + cancel actions while still
pending. Once admin acts, both buttons disappear (the
status branch changes). */}
{canAct && (
<div className="flex justify-center gap-2 mt-5">
<Link
href={`/dashboard/edit/${encodeURIComponent(requestId)}`}
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
>
{t("editRequest")}
</Link>
<button
type="button"
onClick={() => setConfirmCancel(true)}
className="text-sm font-medium px-4 py-2 rounded-lg border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors"
>
{t("cancelRequest")}
</button>
</div>
)}
{error && (
<p className="text-xs text-red-400 mt-3">{error}</p>
)}
</div>
{confirmCancel && (
<Modal
open={confirmCancel}
onClose={() => setConfirmCancel(false)}
ariaLabel={t("cancelConfirmRequestTitle")}
>
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("cancelConfirmRequestTitle")}
</h3>
<p className="text-sm text-text-secondary mb-5">
{t("cancelConfirmRequestDescription")}
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmCancel(false)}
disabled={actionPending}
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
>
{tCommon("cancel")}
</button>
<button
type="button"
onClick={handleCancel}
disabled={actionPending}
className="text-sm px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors disabled:opacity-50"
>
{actionPending
? tCommon("loading")
: t("cancelRequestConfirm")}
</button>
</div>
</Modal>
)}
</Card>
);
}
// ─── Rejected: admin declined ───────────────────────────────────────
if (status === "rejected") {
return (
<Card className="animate-in">
<div className="text-center py-6">
<div className="h-14 w-14 rounded-xl bg-red-500/15 flex items-center justify-center mx-auto mb-4">
<svg
className="h-7 w-7 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("rejectedTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("rejectedDescription")}
</p>
{data.request.adminNotes && (
<div className="text-left text-xs text-text-secondary mt-4 bg-surface-2 border border-border rounded-lg p-3 max-w-sm mx-auto">
<div className="font-semibold uppercase tracking-wider text-text-muted text-[10px] mb-1.5">
{t("rejectionReason")}
</div>
<div className="whitespace-pre-wrap">
{data.request.adminNotes}
</div>
</div>
)}
{/* Bug 13: dismiss removes this card from the dashboard but
keeps the row in the DB for audit. The customer can also
just resubmit via the wizard — both paths are valid. */}
{canAct && (
<div className="flex justify-center mt-5">
<button
type="button"
onClick={handleDismiss}
disabled={actionPending}
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors disabled:opacity-50"
>
{actionPending ? tCommon("loading") : t("dismiss")}
</button>
</div>
)}
{error && <p className="text-xs text-red-400 mt-3">{error}</p>}
</div>
</Card>
);
}
// ─── Cancelled: customer cancelled before admin acted (Bug 6) ──────
if (status === "cancelled") {
return (
<Card className="animate-in">
<div className="text-center py-6">
<div className="h-14 w-14 rounded-xl bg-text-muted/15 flex items-center justify-center mx-auto mb-4">
<svg
className="h-7 w-7 text-text-muted"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("cancelledTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("cancelledDescription")}
</p>
{canAct && (
<div className="flex justify-center mt-5">
<button
type="button"
onClick={handleDismiss}
disabled={actionPending}
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors disabled:opacity-50"
>
{actionPending ? tCommon("loading") : t("dismiss")}
</button>
</div>
)}
{error && <p className="text-xs text-red-400 mt-3">{error}</p>}
</div>
</Card>
);
}
// ─── Provisioning: approved, operator working ──────────────────────
if (
status === "approved" ||
status === "provisioning" ||
(status === "active" && data.tenant && data.tenant.phase !== "Ready")
) {
const phase = data.tenant?.phase ?? "Pending";
const conditions = data.tenant?.conditions ?? [];
return (
<Card className="animate-in">
<div className="text-center py-4 mb-4">
<div className="h-14 w-14 rounded-xl bg-accent/15 flex items-center justify-center mx-auto mb-4">
<div className="h-6 w-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("provisioningTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)}
<p className="text-sm text-text-secondary">
{t("provisioningDescription")}
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between bg-surface-2 border border-border rounded-lg px-4 py-2">
<span className="text-xs text-text-muted">{t("phase")}</span>
<StatusBadge phase={phase} />
</div>
{/* Setup progress. The operator reports readiness as a list of
internal K8s conditions (OpenBao policy, LiteLLM key, network
policy, …) — meaningful to operators, jargon to customers.
We surface the *shape* of that progress (how many steps are
done) without leaking the internal names. */}
{conditions.length > 0 &&
(() => {
const done = conditions.filter((c) => c.status === "True").length;
const total = conditions.length;
const pct = Math.round((done / total) * 100);
return (
<div className="bg-surface-2 border border-border rounded-lg px-4 py-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-text-muted">
{t("setupProgress")}
</span>
<span className="text-xs font-medium text-text-secondary tabular-nums">
{t("setupStepsComplete", { done, total })}
</span>
</div>
<div className="h-1.5 w-full rounded-full bg-surface-3 overflow-hidden">
<div
className="h-full bg-accent transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})()}
</div>
</Card>
);
}
// ─── Active / Ready ─────────────────────────────────────────────────
if (status === "active") {
return (
<Card className="animate-in">
<div className="text-center py-6">
<div className="h-14 w-14 rounded-xl bg-emerald-500/15 flex items-center justify-center mx-auto mb-4">
<svg
className="h-7 w-7 text-emerald-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("readyTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto mb-4">
{t("readyDescription")}
</p>
{(() => {
// Prefer deep-linking straight to the tenant page, where the
// ConnectPanel shows how to start chatting. Fall back to a
// reload only if we somehow don't have a tenant name yet.
const tenantName = data.tenant?.name || data.request.tenantName;
return tenantName ? (
<Link
href={`/tenants/${tenantName}`}
className="inline-block py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
>
{t("connectCta")}
</Link>
) : (
<button
onClick={() => window.location.reload()}
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
>
{t("goToDashboard")}
</button>
);
})()}
</div>
</Card>
);
}
return null;
}