mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
581 lines
18 KiB
TypeScript
Executable File
581 lines
18 KiB
TypeScript
Executable File
"use client";
|
|
|
|
import React, { useMemo } from "react";
|
|
import {
|
|
Package,
|
|
AlertTriangle,
|
|
CheckCircle,
|
|
RefreshCw,
|
|
AlertCircle,
|
|
Zap,
|
|
} from "lucide-react";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
|
import { Separator } from "~/components/ui/separator";
|
|
import { cn } from "~/lib/utils";
|
|
import type {
|
|
ExperimentStep,
|
|
ActionDefinition,
|
|
} from "~/lib/experiment-designer/types";
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Types */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
export interface PluginDependency {
|
|
pluginId: string;
|
|
version: string;
|
|
robotId?: string;
|
|
name?: string;
|
|
status: "available" | "missing" | "outdated" | "error";
|
|
installedVersion?: string;
|
|
actionCount: number;
|
|
driftedActionCount: number;
|
|
}
|
|
|
|
export interface ActionSignatureDrift {
|
|
actionId: string;
|
|
actionName: string;
|
|
stepId: string;
|
|
stepName: string;
|
|
type: string;
|
|
pluginId?: string;
|
|
pluginVersion?: string;
|
|
driftType: "missing_definition" | "schema_changed" | "version_mismatch";
|
|
details?: string;
|
|
}
|
|
|
|
export interface DependencyInspectorProps {
|
|
steps: ExperimentStep[];
|
|
/**
|
|
* Map of action instance ID to signature drift information
|
|
*/
|
|
actionSignatureDrift: Set<string>;
|
|
/**
|
|
* Available action definitions from registry
|
|
*/
|
|
actionDefinitions: ActionDefinition[];
|
|
/**
|
|
* Study plugins with name and metadata
|
|
*/
|
|
studyPlugins?: Array<{
|
|
id: string;
|
|
robotId: string;
|
|
name: string;
|
|
version: string;
|
|
}>;
|
|
/**
|
|
* Called when user wants to reconcile a drifted action
|
|
*/
|
|
onReconcileAction?: (actionId: string) => void;
|
|
/**
|
|
* Called when user wants to refresh plugin dependencies
|
|
*/
|
|
onRefreshDependencies?: () => void;
|
|
/**
|
|
* Called when user wants to install a missing plugin
|
|
*/
|
|
onInstallPlugin?: (pluginId: string) => void;
|
|
className?: string;
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Utility Functions */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
function extractPluginDependencies(
|
|
steps: ExperimentStep[],
|
|
actionDefinitions: ActionDefinition[],
|
|
driftedActions: Set<string>,
|
|
studyPlugins?: Array<{
|
|
id: string;
|
|
robotId: string;
|
|
name: string;
|
|
version: string;
|
|
}>,
|
|
): PluginDependency[] {
|
|
const dependencyMap = new Map<string, PluginDependency>();
|
|
|
|
// Collect all plugin actions used in the experiment
|
|
steps.forEach((step) => {
|
|
step.actions.forEach((action) => {
|
|
if (action.source.kind === "plugin" && action.source.pluginId) {
|
|
const key = `${action.source.pluginId}@${action.source.pluginVersion}`;
|
|
|
|
if (!dependencyMap.has(key)) {
|
|
dependencyMap.set(key, {
|
|
pluginId: action.source.pluginId,
|
|
version: action.source.pluginVersion ?? "unknown",
|
|
status: "available", // Will be updated below
|
|
actionCount: 0,
|
|
driftedActionCount: 0,
|
|
});
|
|
}
|
|
|
|
const dep = dependencyMap.get(key)!;
|
|
dep.actionCount++;
|
|
|
|
if (driftedActions.has(action.id)) {
|
|
dep.driftedActionCount++;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Update status based on available definitions
|
|
dependencyMap.forEach((dep) => {
|
|
const availableActions = actionDefinitions.filter(
|
|
(def) =>
|
|
def.source.kind === "plugin" && def.source.pluginId === dep.pluginId,
|
|
);
|
|
|
|
if (availableActions.length === 0) {
|
|
dep.status = "missing";
|
|
} else {
|
|
// Check if we have the exact version
|
|
const exactVersion = availableActions.find(
|
|
(def) => def.source.pluginVersion === dep.version,
|
|
);
|
|
|
|
if (!exactVersion) {
|
|
dep.status = "outdated";
|
|
// Get the installed version
|
|
const anyVersion = availableActions[0];
|
|
dep.installedVersion = anyVersion?.source.pluginVersion;
|
|
} else {
|
|
dep.status = "available";
|
|
dep.installedVersion = dep.version;
|
|
}
|
|
|
|
// Set plugin name from studyPlugins if available
|
|
if (availableActions[0]) {
|
|
const pluginMeta = studyPlugins?.find(
|
|
(p) => p.robotId === dep.pluginId,
|
|
);
|
|
dep.name = pluginMeta?.name ?? dep.pluginId;
|
|
}
|
|
}
|
|
});
|
|
|
|
return Array.from(dependencyMap.values()).sort((a, b) =>
|
|
a.pluginId.localeCompare(b.pluginId),
|
|
);
|
|
}
|
|
|
|
function extractActionDrifts(
|
|
steps: ExperimentStep[],
|
|
actionDefinitions: ActionDefinition[],
|
|
driftedActions: Set<string>,
|
|
): ActionSignatureDrift[] {
|
|
const drifts: ActionSignatureDrift[] = [];
|
|
|
|
steps.forEach((step) => {
|
|
step.actions.forEach((action) => {
|
|
if (driftedActions.has(action.id)) {
|
|
const definition = actionDefinitions.find(
|
|
(def) => def.type === action.type,
|
|
);
|
|
|
|
let driftType: ActionSignatureDrift["driftType"] = "missing_definition";
|
|
let details = "";
|
|
|
|
if (!definition) {
|
|
driftType = "missing_definition";
|
|
details = `Action definition for type '${action.type}' not found`;
|
|
} else if (
|
|
action.source.pluginId &&
|
|
action.source.pluginVersion !== definition.source.pluginVersion
|
|
) {
|
|
driftType = "version_mismatch";
|
|
details = `Expected v${action.source.pluginVersion}, found v${definition.source.pluginVersion}`;
|
|
} else {
|
|
driftType = "schema_changed";
|
|
details = "Action schema or execution parameters have changed";
|
|
}
|
|
|
|
drifts.push({
|
|
actionId: action.id,
|
|
actionName: action.name,
|
|
stepId: step.id,
|
|
stepName: step.name,
|
|
type: action.type,
|
|
pluginId: action.source.pluginId,
|
|
pluginVersion: action.source.pluginVersion,
|
|
driftType,
|
|
details,
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
return drifts;
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Plugin Dependency Item */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
interface PluginDependencyItemProps {
|
|
dependency: PluginDependency;
|
|
onInstall?: (pluginId: string) => void;
|
|
}
|
|
|
|
function PluginDependencyItem({
|
|
dependency,
|
|
onInstall,
|
|
}: PluginDependencyItemProps) {
|
|
const statusConfig = {
|
|
available: {
|
|
icon: CheckCircle,
|
|
color: "text-green-600 dark:text-green-400",
|
|
badgeVariant: "outline" as const,
|
|
badgeColor: "border-green-300 text-green-700 dark:text-green-300",
|
|
},
|
|
missing: {
|
|
icon: AlertCircle,
|
|
color: "text-red-600 dark:text-red-400",
|
|
badgeVariant: "destructive" as const,
|
|
badgeColor: "",
|
|
},
|
|
outdated: {
|
|
icon: AlertTriangle,
|
|
color: "text-amber-600 dark:text-amber-400",
|
|
badgeVariant: "secondary" as const,
|
|
badgeColor: "",
|
|
},
|
|
error: {
|
|
icon: AlertTriangle,
|
|
color: "text-red-600 dark:text-red-400",
|
|
badgeVariant: "destructive" as const,
|
|
badgeColor: "",
|
|
},
|
|
};
|
|
|
|
const config = statusConfig[dependency.status];
|
|
const IconComponent = config.icon;
|
|
|
|
return (
|
|
<div className="flex items-center justify-between rounded-md border p-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-shrink-0">
|
|
<IconComponent className={cn("h-4 w-4", config.color)} />
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium">
|
|
{dependency.name ?? dependency.pluginId}
|
|
</span>
|
|
<Badge
|
|
variant={config.badgeVariant}
|
|
className={cn("h-4 text-[10px]", config.badgeColor)}
|
|
>
|
|
{dependency.status}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="text-muted-foreground mt-1 text-xs">
|
|
v{dependency.version}
|
|
{dependency.installedVersion &&
|
|
dependency.installedVersion !== dependency.version && (
|
|
<span> (installed: v{dependency.installedVersion})</span>
|
|
)}
|
|
• {dependency.actionCount} actions
|
|
{dependency.driftedActionCount > 0 && (
|
|
<span className="text-amber-600 dark:text-amber-400">
|
|
• {dependency.driftedActionCount} drifted
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{dependency.status === "missing" && onInstall && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-6 text-xs"
|
|
onClick={() => onInstall(dependency.pluginId)}
|
|
>
|
|
Install
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Action Drift Item */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
interface ActionDriftItemProps {
|
|
drift: ActionSignatureDrift;
|
|
onReconcile?: (actionId: string) => void;
|
|
}
|
|
|
|
function ActionDriftItem({ drift, onReconcile }: ActionDriftItemProps) {
|
|
const driftConfig = {
|
|
missing_definition: {
|
|
icon: AlertCircle,
|
|
color: "text-red-600 dark:text-red-400",
|
|
badgeVariant: "destructive" as const,
|
|
label: "Missing",
|
|
},
|
|
schema_changed: {
|
|
icon: AlertTriangle,
|
|
color: "text-amber-600 dark:text-amber-400",
|
|
badgeVariant: "secondary" as const,
|
|
label: "Schema Changed",
|
|
},
|
|
version_mismatch: {
|
|
icon: AlertTriangle,
|
|
color: "text-blue-600 dark:text-blue-400",
|
|
badgeVariant: "outline" as const,
|
|
label: "Version Mismatch",
|
|
},
|
|
};
|
|
|
|
const config = driftConfig[drift.driftType];
|
|
const IconComponent = config.icon;
|
|
|
|
return (
|
|
<div className="flex items-start justify-between rounded-md border p-3">
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex-shrink-0">
|
|
<IconComponent className={cn("h-4 w-4", config.color)} />
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-start gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium">{drift.actionName}</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
in {drift.stepName} • {drift.type}
|
|
</p>
|
|
</div>
|
|
<Badge
|
|
variant={config.badgeVariant}
|
|
className="h-4 flex-shrink-0 text-[10px]"
|
|
>
|
|
{config.label}
|
|
</Badge>
|
|
</div>
|
|
|
|
{drift.details && (
|
|
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">
|
|
{drift.details}
|
|
</p>
|
|
)}
|
|
|
|
{drift.pluginId && (
|
|
<div className="mt-1 flex flex-wrap gap-1">
|
|
<Badge variant="outline" className="h-4 text-[10px]">
|
|
{drift.pluginId}
|
|
{drift.pluginVersion && `@${drift.pluginVersion}`}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{onReconcile && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-6 text-xs"
|
|
onClick={() => onReconcile(drift.actionId)}
|
|
>
|
|
Fix
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* DependencyInspector Component */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
export function DependencyInspector({
|
|
steps,
|
|
actionSignatureDrift,
|
|
actionDefinitions,
|
|
studyPlugins,
|
|
onReconcileAction,
|
|
onRefreshDependencies,
|
|
onInstallPlugin,
|
|
className,
|
|
}: DependencyInspectorProps) {
|
|
const dependencies = useMemo(
|
|
() =>
|
|
extractPluginDependencies(
|
|
steps,
|
|
actionDefinitions,
|
|
actionSignatureDrift,
|
|
studyPlugins,
|
|
),
|
|
[steps, actionDefinitions, actionSignatureDrift, studyPlugins],
|
|
);
|
|
|
|
const drifts = useMemo(
|
|
() => extractActionDrifts(steps, actionDefinitions, actionSignatureDrift),
|
|
[steps, actionDefinitions, actionSignatureDrift],
|
|
);
|
|
|
|
// Count core vs plugin actions
|
|
const actionCounts = useMemo(() => {
|
|
let core = 0;
|
|
let plugin = 0;
|
|
|
|
steps.forEach((step) => {
|
|
step.actions.forEach((action) => {
|
|
if (action.source.kind === "plugin") {
|
|
plugin++;
|
|
} else {
|
|
core++;
|
|
}
|
|
});
|
|
});
|
|
|
|
return { core, plugin, total: core + plugin };
|
|
}, [steps]);
|
|
|
|
const hasIssues =
|
|
dependencies.some((d) => d.status !== "available") || drifts.length > 0;
|
|
|
|
return (
|
|
<Card className={cn("h-full", className)}>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="flex items-center justify-between text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<Package className="h-4 w-4" />
|
|
Dependencies
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{hasIssues ? (
|
|
<Badge variant="destructive" className="h-4 text-[10px]">
|
|
Issues
|
|
</Badge>
|
|
) : (
|
|
<Badge
|
|
variant="outline"
|
|
className="h-4 border-green-300 text-[10px] text-green-700 dark:text-green-300"
|
|
>
|
|
Healthy
|
|
</Badge>
|
|
)}
|
|
{onRefreshDependencies && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0"
|
|
onClick={onRefreshDependencies}
|
|
>
|
|
<RefreshCw className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
|
|
<CardContent className="p-0">
|
|
<ScrollArea className="h-full">
|
|
<div className="space-y-4 p-3">
|
|
{/* Action Summary */}
|
|
<div className="space-y-2">
|
|
<h4 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
|
Action Summary
|
|
</h4>
|
|
<div className="flex flex-wrap gap-1">
|
|
<Badge variant="outline" className="h-4 text-[10px]">
|
|
<Zap className="mr-1 h-2 w-2" />
|
|
{actionCounts.core} core
|
|
</Badge>
|
|
<Badge variant="outline" className="h-4 text-[10px]">
|
|
<Package className="mr-1 h-2 w-2" />
|
|
{actionCounts.plugin} plugin
|
|
</Badge>
|
|
<Badge variant="secondary" className="h-4 text-[10px]">
|
|
{actionCounts.total} total
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Plugin Dependencies */}
|
|
{dependencies.length > 0 && (
|
|
<>
|
|
<Separator />
|
|
<div className="space-y-2">
|
|
<h4 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
|
Plugin Dependencies ({dependencies.length})
|
|
</h4>
|
|
<div className="space-y-2">
|
|
{dependencies.map((dep) => (
|
|
<PluginDependencyItem
|
|
key={`${dep.pluginId}@${dep.version}`}
|
|
dependency={dep}
|
|
onInstall={onInstallPlugin}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Action Signature Drifts */}
|
|
{drifts.length > 0 && (
|
|
<>
|
|
<Separator />
|
|
<div className="space-y-2">
|
|
<h4 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
|
Action Drift ({drifts.length})
|
|
</h4>
|
|
<div className="space-y-2">
|
|
{drifts.map((drift) => (
|
|
<ActionDriftItem
|
|
key={drift.actionId}
|
|
drift={drift}
|
|
onReconcile={onReconcileAction}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Empty State */}
|
|
{dependencies.length === 0 && drifts.length === 0 && (
|
|
<div className="py-8 text-center">
|
|
<div className="bg-muted mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full">
|
|
<Package className="h-4 w-4" />
|
|
</div>
|
|
<p className="text-sm font-medium">No plugin dependencies</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
This experiment uses only core actions
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Healthy State */}
|
|
{dependencies.length > 0 && !hasIssues && (
|
|
<div className="py-4 text-center">
|
|
<div className="mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-950/20">
|
|
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
|
|
</div>
|
|
<p className="text-sm font-medium text-green-700 dark:text-green-300">
|
|
All dependencies healthy
|
|
</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
No drift or missing plugins detected
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|