mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +00:00
refactor(frontend): extract NodeCardStackList from NodeCard (F5-9) (#923)
* refactor(frontend): extract NodeCardStackList from NodeCard (F5-9) * refactor(frontend): align NodeCard stacks cache guard with StackSection (F5-9)
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Server, Cpu, MemoryStick, HardDrive, ChevronDown, ChevronRight,
|
Server, Cpu, MemoryStick, HardDrive, ChevronDown, ChevronRight,
|
||||||
Layers, Wifi, WifiOff, AlertTriangle, ExternalLink, Download, Loader2,
|
Layers, Wifi, WifiOff, AlertTriangle, Download, Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -10,23 +10,15 @@ import { formatBytes } from '@/lib/utils';
|
|||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { useLicense } from '@/context/LicenseContext';
|
import { useLicense } from '@/context/LicenseContext';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { LabelDot } from '../LabelPill';
|
|
||||||
import { formatVersion } from '@/lib/version';
|
import { formatVersion } from '@/lib/version';
|
||||||
import { UpdateStatusBadge } from './UpdateStatusBadge';
|
import { UpdateStatusBadge } from './UpdateStatusBadge';
|
||||||
|
import { StackSection } from './NodeCardStackList';
|
||||||
import type { Label as StackLabel } from '../label-types';
|
import type { Label as StackLabel } from '../label-types';
|
||||||
import type { FleetNode, NodeUpdateStatus } from './types';
|
import type { FleetNode, NodeUpdateStatus } from './types';
|
||||||
import { getNodeCpu, getNodeMem, getNodeDisk, isCritical } from './nodeUtils';
|
import { getNodeCpu, getNodeMem, getNodeDisk, isCritical } from './nodeUtils';
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
interface StackContainer {
|
|
||||||
Id?: string;
|
|
||||||
Names?: string[];
|
|
||||||
Image?: string;
|
|
||||||
State?: string;
|
|
||||||
Status?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NodeCardProps {
|
export interface NodeCardProps {
|
||||||
node: FleetNode;
|
node: FleetNode;
|
||||||
onNavigate: (nodeId: number, stackName: string) => void;
|
onNavigate: (nodeId: number, stackName: string) => void;
|
||||||
@@ -38,13 +30,6 @@ export interface NodeCardProps {
|
|||||||
onDismissUpdate?: (nodeId: number) => void;
|
onDismissUpdate?: (nodeId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function containerName(c: StackContainer): string {
|
|
||||||
if (c.Names && c.Names.length > 0) {
|
|
||||||
return c.Names[0].replace(/^\//, '');
|
|
||||||
}
|
|
||||||
return c.Id?.slice(0, 12) ?? 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Sub-Components ---
|
// --- Sub-Components ---
|
||||||
|
|
||||||
function UsageBar({ percent, color }: { percent: number; color: string }) {
|
function UsageBar({ percent, color }: { percent: number; color: string }) {
|
||||||
@@ -58,129 +43,6 @@ function UsageBar({ percent, color }: { percent: number; color: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContainerRow({ container, onNavigate }: {
|
|
||||||
container: StackContainer;
|
|
||||||
onNavigate: () => void;
|
|
||||||
}) {
|
|
||||||
const name = containerName(container);
|
|
||||||
const state = container.State?.toLowerCase() ?? 'unknown';
|
|
||||||
const image = container.Image;
|
|
||||||
const status = container.Status ?? '';
|
|
||||||
|
|
||||||
const stateColor = state === 'running' ? 'bg-success' :
|
|
||||||
state === 'restarting' ? 'bg-warning' : 'bg-destructive';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors group">
|
|
||||||
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${stateColor}`} />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs font-medium truncate">{name}</span>
|
|
||||||
<Badge variant="outline" className="text-[9px] px-1 py-0 h-3.5 shrink-0">{state}</Badge>
|
|
||||||
</div>
|
|
||||||
{(image || status) && (
|
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
|
||||||
{image && <span className="text-[10px] text-muted-foreground truncate">{image}</span>}
|
|
||||||
{status && <span className="text-[10px] text-muted-foreground shrink-0">{image ? '· ' : ''}{status}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
onClick={onNavigate}
|
|
||||||
title="Open in editor"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StackSection({ stackName, nodeId, onNavigate, labelMap }: {
|
|
||||||
stackName: string;
|
|
||||||
nodeId: number;
|
|
||||||
onNavigate: (nodeId: number, stackName: string) => void;
|
|
||||||
labelMap?: Record<string, StackLabel[]>;
|
|
||||||
}) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [containers, setContainers] = useState<StackContainer[] | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleExpand = async () => {
|
|
||||||
if (loading) return;
|
|
||||||
const next = !expanded;
|
|
||||||
setExpanded(next);
|
|
||||||
|
|
||||||
if (next) {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await apiFetch(`/fleet/node/${nodeId}/stacks/${encodeURIComponent(stackName)}/containers`, { localOnly: true });
|
|
||||||
if (res.ok) {
|
|
||||||
setContainers(await res.json());
|
|
||||||
} else {
|
|
||||||
toast.error('Failed to load containers for ' + stackName);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load containers for', stackName, error);
|
|
||||||
toast.error('Failed to load containers for ' + stackName);
|
|
||||||
setExpanded(false);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const runningCount = containers?.filter(c => c.State?.toLowerCase() === 'running').length ?? 0;
|
|
||||||
const totalCount = containers?.length ?? 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={handleExpand}
|
|
||||||
className="flex items-center gap-2 w-full px-3 py-1.5 rounded-md text-xs hover:bg-muted/50 transition-colors text-left group"
|
|
||||||
>
|
|
||||||
{expanded ? <ChevronDown className="w-3 h-3 shrink-0" /> : <ChevronRight className="w-3 h-3 shrink-0" />}
|
|
||||||
<Layers className="w-3 h-3 text-muted-foreground shrink-0" />
|
|
||||||
<span className="truncate flex-1">{stackName}</span>
|
|
||||||
{labelMap?.[stackName]?.length ? (
|
|
||||||
<span className="flex items-center gap-0.5 shrink-0">
|
|
||||||
{labelMap[stackName].map(l => (
|
|
||||||
<LabelDot key={l.id} color={l.color} />
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{containers !== null && (
|
|
||||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
|
||||||
{runningCount}/{totalCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{expanded && (
|
|
||||||
<div className="ml-4 mt-1 space-y-0.5">
|
|
||||||
{loading ? (
|
|
||||||
<div className="space-y-2 px-3 py-1">
|
|
||||||
<Skeleton className="h-5 w-full" />
|
|
||||||
<Skeleton className="h-5 w-3/4" />
|
|
||||||
</div>
|
|
||||||
) : containers && containers.length > 0 ? (
|
|
||||||
containers.map(c => (
|
|
||||||
<ContainerRow
|
|
||||||
key={c.Id ?? containerName(c)}
|
|
||||||
container={c}
|
|
||||||
onNavigate={() => onNavigate(nodeId, stackName)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-[10px] text-muted-foreground px-3 py-1">No containers</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Main Export ---
|
// --- Main Export ---
|
||||||
|
|
||||||
export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate }: NodeCardProps) {
|
export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate }: NodeCardProps) {
|
||||||
@@ -202,7 +64,7 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
|||||||
const next = !expanded;
|
const next = !expanded;
|
||||||
setExpanded(next);
|
setExpanded(next);
|
||||||
|
|
||||||
if (next && !stacks) {
|
if (next && stacks === null) {
|
||||||
setLoadingStacks(true);
|
setLoadingStacks(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(`/fleet/node/${node.id}/stacks`, { localOnly: true });
|
const res = await apiFetch(`/fleet/node/${node.id}/stacks`, { localOnly: true });
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ChevronDown, ChevronRight, Layers, ExternalLink,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { apiFetch } from '@/lib/api';
|
||||||
|
import { toast } from '@/components/ui/toast-store';
|
||||||
|
import { LabelDot } from '../LabelPill';
|
||||||
|
import type { Label as StackLabel } from '../label-types';
|
||||||
|
|
||||||
|
interface StackContainer {
|
||||||
|
Id?: string;
|
||||||
|
Names?: string[];
|
||||||
|
Image?: string;
|
||||||
|
State?: string;
|
||||||
|
Status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function containerName(c: StackContainer): string {
|
||||||
|
if (c.Names && c.Names.length > 0) {
|
||||||
|
return c.Names[0].replace(/^\//, '');
|
||||||
|
}
|
||||||
|
return c.Id?.slice(0, 12) ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContainerRow({ container, onNavigate }: {
|
||||||
|
container: StackContainer;
|
||||||
|
onNavigate: () => void;
|
||||||
|
}) {
|
||||||
|
const name = containerName(container);
|
||||||
|
const state = container.State?.toLowerCase() ?? 'unknown';
|
||||||
|
const image = container.Image;
|
||||||
|
const status = container.Status ?? '';
|
||||||
|
|
||||||
|
const stateColor = state === 'running' ? 'bg-success' :
|
||||||
|
state === 'restarting' ? 'bg-warning' : 'bg-destructive';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors group">
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${stateColor}`} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium truncate">{name}</span>
|
||||||
|
<Badge variant="outline" className="text-[9px] px-1 py-0 h-3.5 shrink-0">{state}</Badge>
|
||||||
|
</div>
|
||||||
|
{(image || status) && (
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
{image && <span className="text-[10px] text-muted-foreground truncate">{image}</span>}
|
||||||
|
{status && <span className="text-[10px] text-muted-foreground shrink-0">{image ? '· ' : ''}{status}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={onNavigate}
|
||||||
|
title="Open in editor"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StackSection({ stackName, nodeId, onNavigate, labelMap }: {
|
||||||
|
stackName: string;
|
||||||
|
nodeId: number;
|
||||||
|
onNavigate: (nodeId: number, stackName: string) => void;
|
||||||
|
labelMap?: Record<string, StackLabel[]>;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [containers, setContainers] = useState<StackContainer[] | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleExpand = async () => {
|
||||||
|
if (loading) return;
|
||||||
|
const next = !expanded;
|
||||||
|
setExpanded(next);
|
||||||
|
|
||||||
|
if (next && containers === null) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/fleet/node/${nodeId}/stacks/${encodeURIComponent(stackName)}/containers`, { localOnly: true });
|
||||||
|
if (res.ok) {
|
||||||
|
setContainers(await res.json());
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to load containers for ' + stackName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load containers for', stackName, error);
|
||||||
|
toast.error('Failed to load containers for ' + stackName);
|
||||||
|
setExpanded(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runningCount = containers?.filter(c => c.State?.toLowerCase() === 'running').length ?? 0;
|
||||||
|
const totalCount = containers?.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={handleExpand}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-1.5 rounded-md text-xs hover:bg-muted/50 transition-colors text-left group"
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown className="w-3 h-3 shrink-0" /> : <ChevronRight className="w-3 h-3 shrink-0" />}
|
||||||
|
<Layers className="w-3 h-3 text-muted-foreground shrink-0" />
|
||||||
|
<span className="truncate flex-1">{stackName}</span>
|
||||||
|
{labelMap?.[stackName]?.length ? (
|
||||||
|
<span className="flex items-center gap-0.5 shrink-0">
|
||||||
|
{labelMap[stackName].map(l => (
|
||||||
|
<LabelDot key={l.id} color={l.color} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{containers !== null && (
|
||||||
|
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||||
|
{runningCount}/{totalCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="ml-4 mt-1 space-y-0.5">
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2 px-3 py-1">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
<Skeleton className="h-5 w-3/4" />
|
||||||
|
</div>
|
||||||
|
) : containers && containers.length > 0 ? (
|
||||||
|
containers.map(c => (
|
||||||
|
<ContainerRow
|
||||||
|
key={c.Id ?? containerName(c)}
|
||||||
|
container={c}
|
||||||
|
onNavigate={() => onNavigate(nodeId, stackName)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-[10px] text-muted-foreground px-3 py-1">No containers</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user