mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 12:49:03 +00:00
c8a54a988b
- Replace catch (error: any) with catch (error) + (error as Error).message cast in EditorLayout, NodeManager, HomeDashboard, AppStoreView - Define TemplateVolume interface in AppStoreView; replace volumes any[] with typed array - Define MetricPoint interface in HomeDashboard; replace metrics any[] with MetricPoint[] - Define TerminalContainer type in BashExecModal; replace as any DOM property casts - Define NodeTestInfo interface in NodeManager; replace info: any with typed shape - Fix DockerNetworkStats cast in EditorLayout container stats WebSocket handler - Remove unused catch variable (e) in api.ts and other components - Cast streamFilter onValueChange val to union type in GlobalObservabilityView - Add eslint-disable-next-line react-refresh/only-export-components to badge.tsx, button.tsx, AuthContext, NodeContext, use-data-state, use-is-in-view - Add eslint-disable-next-line react-hooks/set-state-in-effect in LogViewer - Add /* eslint-disable */ to animate-ui third-party primitive files
109 lines
3.8 KiB
TypeScript
109 lines
3.8 KiB
TypeScript
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
|
import { apiFetch } from '@/lib/api';
|
|
|
|
export interface Node {
|
|
id: number;
|
|
name: string;
|
|
type: 'local' | 'remote';
|
|
compose_dir: string;
|
|
is_default: boolean;
|
|
status: 'online' | 'offline' | 'unknown';
|
|
created_at: number;
|
|
api_url?: string;
|
|
api_token?: string;
|
|
}
|
|
|
|
interface NodeContextType {
|
|
nodes: Node[];
|
|
activeNode: Node | null;
|
|
setActiveNode: (node: Node) => void;
|
|
refreshNodes: () => Promise<void>;
|
|
isLoading: boolean;
|
|
}
|
|
|
|
const NodeContext = createContext<NodeContextType | undefined>(undefined);
|
|
|
|
export function NodeProvider({ children }: { children: React.ReactNode }) {
|
|
const [nodes, setNodes] = useState<Node[]>([]);
|
|
const [activeNode, setActiveNodeState] = useState<Node | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
// Ref lets refreshNodes read current activeNode without being a dep (breaks infinite loop)
|
|
const activeNodeRef = useRef<Node | null>(null);
|
|
activeNodeRef.current = activeNode;
|
|
|
|
const refreshNodes = useCallback(async () => {
|
|
try {
|
|
const res = await apiFetch('/nodes');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setNodes(data);
|
|
|
|
const currentActive = activeNodeRef.current;
|
|
if (!currentActive) {
|
|
// On initial load, restore from localStorage before falling back to default.
|
|
// This keeps the UI dropdown in sync with the node ID that apiFetch is already
|
|
// injecting via x-node-id (read directly from localStorage on every request).
|
|
const storedId = localStorage.getItem('sencho-active-node');
|
|
const storedNode = storedId ? data.find((n: Node) => n.id === parseInt(storedId, 10)) : null;
|
|
const nodeToActivate = storedNode ?? data.find((n: Node) => n.is_default) ?? data[0] ?? null;
|
|
if (nodeToActivate) {
|
|
setActiveNodeState(nodeToActivate);
|
|
localStorage.setItem('sencho-active-node', String(nodeToActivate.id));
|
|
}
|
|
} else {
|
|
const updatedActive = data.find((n: Node) => n.id === currentActive.id);
|
|
if (updatedActive) {
|
|
setActiveNodeState(updatedActive);
|
|
localStorage.setItem('sencho-active-node', String(updatedActive.id));
|
|
} else {
|
|
const fallback = data.find((n: Node) => n.is_default) ?? data[0] ?? null;
|
|
if (fallback) {
|
|
setActiveNodeState(fallback);
|
|
localStorage.setItem('sencho-active-node', String(fallback.id));
|
|
} else {
|
|
setActiveNodeState(null);
|
|
localStorage.removeItem('sencho-active-node');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch nodes:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []); // stable - reads activeNode via ref, not closure capture
|
|
|
|
const setActiveNode = useCallback((node: Node) => {
|
|
setActiveNodeState(node);
|
|
localStorage.setItem('sencho-active-node', String(node.id));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
refreshNodes();
|
|
|
|
const handleNodeNotFound = () => {
|
|
console.warn('[NodeContext] Active node is unreachable or deleted. Forcing sync...');
|
|
refreshNodes();
|
|
};
|
|
|
|
window.addEventListener('node-not-found', handleNodeNotFound);
|
|
return () => window.removeEventListener('node-not-found', handleNodeNotFound);
|
|
}, [refreshNodes]);
|
|
|
|
return (
|
|
<NodeContext.Provider value={{ nodes, activeNode, setActiveNode, refreshNodes, isLoading }}>
|
|
{children}
|
|
</NodeContext.Provider>
|
|
);
|
|
}
|
|
|
|
// eslint-disable-next-line react-refresh/only-export-components
|
|
export function useNodes() {
|
|
const context = useContext(NodeContext);
|
|
if (!context) {
|
|
throw new Error('useNodes must be used within a NodeProvider');
|
|
}
|
|
return context;
|
|
}
|