259 lines
9.2 KiB
TypeScript
259 lines
9.2 KiB
TypeScript
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import { Badge } from '@renderer/components/ui/badge';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@renderer/components/ui/dialog';
|
|
import { TeamProvisioningPanel } from '@renderer/components/team/TeamProvisioningPanel';
|
|
import { StepProgressBar } from '@renderer/components/team/StepProgressBar';
|
|
import { useTeamProvisioningPresentation } from '@renderer/components/team/useTeamProvisioningPresentation';
|
|
import { DISPLAY_STEPS } from '@renderer/components/team/provisioningSteps';
|
|
import { cn } from '@renderer/lib/utils';
|
|
import { AlertTriangle, CheckCircle2, ExternalLink, Loader2, X } from 'lucide-react';
|
|
|
|
import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
|
import type { CSSProperties } from 'react';
|
|
|
|
const MINI_STEPS = DISPLAY_STEPS.map((step) => ({ key: step.key, label: step.label }));
|
|
const HUD_STEPPER_STYLE: CSSProperties = {
|
|
['--stepper-done' as string]: '#22c55e',
|
|
['--stepper-done-glow' as string]: 'rgba(34, 197, 94, 0.24)',
|
|
['--stepper-current' as string]: '#22c55e',
|
|
['--stepper-current-ring' as string]: 'rgba(34, 197, 94, 0.18)',
|
|
['--stepper-pending' as string]: 'rgba(148, 163, 184, 0.08)',
|
|
['--stepper-pending-text' as string]: '#cbd5e1',
|
|
['--stepper-pending-border' as string]: 'rgba(148, 163, 184, 0.2)',
|
|
['--stepper-line' as string]: 'rgba(148, 163, 184, 0.14)',
|
|
['--stepper-line-done' as string]: '#22c55e',
|
|
['--stepper-label' as string]: '#94a3b8',
|
|
['--stepper-label-active' as string]: '#e2e8f0',
|
|
['--stepper-error' as string]: '#ef4444',
|
|
['--stepper-error-glow' as string]: 'rgba(239, 68, 68, 0.22)',
|
|
['--stepper-label-error' as string]: '#fca5a5',
|
|
};
|
|
|
|
function shouldRenderLaunchHud(presentation: TeamProvisioningPresentation | null): boolean {
|
|
return presentation != null;
|
|
}
|
|
|
|
function getToneClasses(tone: TeamProvisioningPresentation['compactTone']): {
|
|
border: string;
|
|
badge: string;
|
|
icon: React.ReactNode;
|
|
iconClassName: string;
|
|
} {
|
|
switch (tone) {
|
|
case 'error':
|
|
return {
|
|
border: 'border-red-400/35 bg-[rgba(26,10,16,0.92)]',
|
|
badge: 'border-red-500/30 text-red-300',
|
|
icon: <AlertTriangle size={13} />,
|
|
iconClassName: 'text-red-400',
|
|
};
|
|
case 'warning':
|
|
return {
|
|
border: 'border-amber-400/35 bg-[rgba(31,18,8,0.92)]',
|
|
badge: 'border-amber-500/30 text-amber-200',
|
|
icon: <AlertTriangle size={13} />,
|
|
iconClassName: 'text-amber-400',
|
|
};
|
|
case 'success':
|
|
return {
|
|
border: 'border-emerald-400/35 bg-[rgba(8,24,18,0.92)]',
|
|
badge: 'border-emerald-500/30 text-emerald-200',
|
|
icon: <CheckCircle2 size={13} />,
|
|
iconClassName: 'text-emerald-400',
|
|
};
|
|
default:
|
|
return {
|
|
border: 'border-cyan-400/25 bg-[rgba(8,14,26,0.92)]',
|
|
badge: 'border-cyan-500/20 text-cyan-200',
|
|
icon: <Loader2 size={13} className="animate-spin" />,
|
|
iconClassName: 'text-cyan-300',
|
|
};
|
|
}
|
|
}
|
|
|
|
export interface GraphProvisioningHudProps {
|
|
teamName: string;
|
|
leadNodeId: string | null;
|
|
getLaunchAnchorScreenPlacement: (
|
|
leadNodeId: string
|
|
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
|
enabled?: boolean;
|
|
}
|
|
|
|
export function GraphProvisioningHud({
|
|
teamName,
|
|
leadNodeId,
|
|
getLaunchAnchorScreenPlacement,
|
|
enabled = true,
|
|
}: GraphProvisioningHudProps): React.JSX.Element | null {
|
|
const { presentation, runInstanceKey } = useTeamProvisioningPresentation(teamName);
|
|
const shellRef = useRef<HTMLDivElement>(null);
|
|
const lastActiveStepRef = useRef(-1);
|
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
const [dismissed, setDismissed] = useState(false);
|
|
const shouldRender =
|
|
enabled && shouldRenderLaunchHud(presentation) && !dismissed && Boolean(leadNodeId);
|
|
const tone = presentation ? getToneClasses(presentation.compactTone) : null;
|
|
const errorStepIndex = presentation?.isFailed
|
|
? lastActiveStepRef.current >= 0
|
|
? lastActiveStepRef.current
|
|
: 0
|
|
: undefined;
|
|
|
|
useEffect(() => {
|
|
setDetailsOpen(false);
|
|
setDismissed(false);
|
|
lastActiveStepRef.current = -1;
|
|
}, [runInstanceKey, teamName]);
|
|
|
|
useEffect(() => {
|
|
if (!shouldRender || !leadNodeId) {
|
|
setDetailsOpen(false);
|
|
}
|
|
}, [leadNodeId, shouldRender]);
|
|
|
|
useEffect(() => {
|
|
if (presentation && !presentation.isFailed && presentation.currentStepIndex >= 0) {
|
|
lastActiveStepRef.current = presentation.currentStepIndex;
|
|
}
|
|
}, [presentation]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!shouldRender || !leadNodeId) {
|
|
return;
|
|
}
|
|
let frameId = 0;
|
|
const updatePosition = (): void => {
|
|
const shell = shellRef.current;
|
|
if (!shell) {
|
|
frameId = window.requestAnimationFrame(updatePosition);
|
|
return;
|
|
}
|
|
const placement = getLaunchAnchorScreenPlacement(leadNodeId);
|
|
if (!placement) {
|
|
shell.style.opacity = '0';
|
|
frameId = window.requestAnimationFrame(updatePosition);
|
|
return;
|
|
}
|
|
|
|
if (!placement.visible) {
|
|
shell.style.opacity = '0';
|
|
frameId = window.requestAnimationFrame(updatePosition);
|
|
return;
|
|
}
|
|
|
|
shell.style.opacity = '1';
|
|
shell.style.transform = `translate(${Math.round(placement.x)}px, ${Math.round(placement.y)}px) scale(${placement.scale.toFixed(3)})`;
|
|
frameId = window.requestAnimationFrame(updatePosition);
|
|
};
|
|
|
|
updatePosition();
|
|
return () => {
|
|
window.cancelAnimationFrame(frameId);
|
|
};
|
|
}, [getLaunchAnchorScreenPlacement, leadNodeId, shouldRender]);
|
|
|
|
const compactLabel = useMemo(() => {
|
|
if (!presentation?.compactDetail) {
|
|
return null;
|
|
}
|
|
return presentation.compactDetail.length > 88
|
|
? `${presentation.compactDetail.slice(0, 88)}...`
|
|
: presentation.compactDetail;
|
|
}, [presentation?.compactDetail]);
|
|
|
|
if (!shouldRender || !presentation || !tone) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={shellRef}
|
|
className="pointer-events-auto absolute z-10 w-[336px] origin-top-left opacity-0 transition-opacity"
|
|
>
|
|
<div
|
|
className={cn(
|
|
'rounded-xl border px-3 py-3 text-slate-100 shadow-[0_18px_48px_rgba(5,5,16,0.38)] backdrop-blur-xl',
|
|
tone.border
|
|
)}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className={cn('shrink-0', tone.iconClassName)}>{tone.icon}</span>
|
|
<div className="truncate text-sm font-semibold text-slate-50">
|
|
{presentation.compactTitle}
|
|
</div>
|
|
<Badge variant="outline" className={cn('px-1.5 py-0 text-[10px]', tone.badge)}>
|
|
{presentation.isFailed
|
|
? 'Issue'
|
|
: presentation.hasMembersStillJoining
|
|
? 'Joining'
|
|
: presentation.isActive
|
|
? 'Live'
|
|
: 'Ready'}
|
|
</Badge>
|
|
</div>
|
|
{compactLabel ? (
|
|
<div className="mt-1 text-[11px] leading-5 text-slate-300">{compactLabel}</div>
|
|
) : null}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
<button
|
|
type="button"
|
|
className="inline-flex size-7 items-center justify-center rounded-md border border-white/10 bg-white/5 text-slate-400 transition-colors hover:bg-white/10 hover:text-slate-100"
|
|
onClick={() => setDetailsOpen(true)}
|
|
aria-label="Open launch details"
|
|
>
|
|
<ExternalLink size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="inline-flex size-7 items-center justify-center rounded-md border border-white/10 bg-white/5 text-slate-400 transition-colors hover:bg-white/10 hover:text-slate-100"
|
|
onClick={() => setDismissed(true)}
|
|
aria-label="Dismiss launch overlay"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="border-cyan-300/12 mt-3 w-full rounded-xl border bg-[rgba(4,10,20,0.58)] px-3 py-3 text-left transition-colors hover:bg-[rgba(8,18,32,0.76)]"
|
|
style={HUD_STEPPER_STYLE}
|
|
onClick={() => setDetailsOpen(true)}
|
|
aria-label="Open full launch details"
|
|
>
|
|
<StepProgressBar
|
|
steps={MINI_STEPS}
|
|
currentIndex={presentation.currentStepIndex}
|
|
errorIndex={errorStepIndex}
|
|
className="w-full"
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
|
<DialogContent className="w-[min(1120px,92vw)] max-w-5xl p-0">
|
|
<DialogHeader className="sr-only">
|
|
<DialogTitle>Launch details</DialogTitle>
|
|
<DialogDescription>
|
|
Detailed team launch progress, live output and CLI logs.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="max-h-[85vh] overflow-y-auto p-4">
|
|
<TeamProvisioningPanel teamName={teamName} surface="flat" defaultLogsOpen />
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|