Files
sencho/frontend/src/context/NodeContext.tsx
T
SaelixCode eb0c0263c7 fix: Distributed API UI & metrics polish + DEP0060 suppression
Add Node modal — type selector & state reset:
- Restored a Local/Remote <Select> dropdown in renderFormFields so users can
  explicitly choose the node type instead of it defaulting silently to 'remote'.
- Switching type clears api_url and api_token so no stale remote credentials
  carry over if a user switches from Remote to Local mid-form.
- Replaced the static "Add Remote Node" title with a dynamic one that reflects
  the currently selected type ("Add Local Node" / "Add Remote Node").
- onOpenChange now resets formData to defaultFormData whenever the dialog
  opens, preventing stale values from a previous session leaking in.

Remote connection details — real metrics:
- testRemoteConnection previously returned hard-coded '-' for containers,
  images, and cpus after a successful auth/check ping.
- Now fires three parallel requests (Promise.allSettled) after auth passes:
    /api/stats          → containers total + running count
    /api/system/stats   → cpu.cores
    /api/system/images  → image list length
- Each field falls back to '-' gracefully if an endpoint is unavailable,
  so a slow or older remote instance never breaks the connection test.

DEP0060 util._extend suppression:
- http-proxy@1.18.1 calls util._extend when createProxyServer() is first
  invoked at runtime (NOT at import time). A process.emitWarning override
  placed before the proxy instantiations intercepts only DEP0060 without
  suppressing any other warnings. No package version changes needed.

Also includes linter/formatter normalisation across multiple files.
2026-03-19 16:06:47 -04:00

103 lines
3.1 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) {
const defaultNode = data.find((n: Node) => n.is_default);
if (defaultNode) {
setActiveNodeState(defaultNode);
} else if (data.length > 0) {
setActiveNodeState(data[0]);
}
} else {
const updatedActive = data.find((n: Node) => n.id === currentActive.id);
if (updatedActive) {
setActiveNodeState(updatedActive);
} else {
const defaultNode = data.find((n: Node) => n.is_default);
if (defaultNode) {
setActiveNodeState(defaultNode);
} else if (data.length > 0) {
setActiveNodeState(data[0]);
} else {
setActiveNodeState(null);
}
}
}
}
} 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>
);
}
export function useNodes() {
const context = useContext(NodeContext);
if (!context) {
throw new Error('useNodes must be used within a NodeProvider');
}
return context;
}