Files
sencho/frontend/src/context/NodeContext.tsx
T
SaelixCode c8a54a988b fix(lint): resolve all ESLint errors to pass CI lint step
- 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
2026-03-22 00:27:43 -04:00

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;
}