Merge pull request #39 from AnsoCode/feature/remote-context

feat(ui): Phase 57 - Remote Context Navigation
This commit is contained in:
Anso
2026-03-19 20:45:34 -04:00
committed by GitHub
7 changed files with 117 additions and 40 deletions
+5
View File
@@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
- **Fixed:** `AppStoreView` and `GlobalObservabilityView` using raw `fetch()` instead of `apiFetch()` — all calls now inject the `x-node-id` header so templates, deploys, stacks, and logs are correctly proxied to the active remote node.
- **Fixed:** `HostConsole` WebSocket URL missing `?nodeId=` query parameter — the upgrade handler now receives the active node ID and routes the PTY session to the correct node.
- **Added:** Two-tier Option A scoped navigation UX — a context pill in the top header bar always shows the active node name (pulsing blue for remote, green for local).
- **Added:** Remote-aware headers in `HostConsole` ("Host Console — [Node Name]"), `ResourcesView` ("Resources Hub — [Node Name]"), `GlobalObservabilityView` (floating node badge), and `AppStoreView` deploy sheet ("Deploying to: [Node Name]").
- **Added:** `SettingsModal` now scopes its sidebar to the active node type — when a remote node is selected, global-only tabs (Account, Appearance, Notifications, Nodes) are hidden, and the header subtitle shows the remote node name.
- **Fixed:** A massive memory leak (browser Out of Memory crash) by throttling historical metrics polling down to 60s and downsampling SQLite metrics payload sizes by 12x.
- **Fixed:** A bug where the active node UI dropdown would desync from the actual API requests on initial page load by properly hydrating state from localStorage.
- **Fixed:** Remote node proxy forwarding the browser's `sencho_token` cookie to the remote Sencho instance — the remote's `authMiddleware` evaluates `cookieToken || bearerToken` and the cookie (signed with the local JWT secret) was validated before the valid Bearer token, causing 401 on all proxied API calls. Fixed by stripping the `cookie` header in `proxyReq` so only the Bearer token is used for remote authentication.
+12 -3
View File
@@ -8,6 +8,8 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Search, Rocket, Loader2, Info, ExternalLink, Github, Star } from "lucide-react";
import { toast } from "sonner";
import { apiFetch } from '@/lib/api';
import { useNodes } from '@/context/NodeContext';
export interface TemplateEnv {
name: string;
@@ -36,6 +38,7 @@ interface AppStoreViewProps {
}
export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
const { activeNode } = useNodes();
const [templates, setTemplates] = useState<Template[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
@@ -59,7 +62,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
const fetchTemplates = async () => {
try {
const res = await fetch('/api/templates');
const res = await apiFetch('/templates');
if (!res.ok) throw new Error('Failed to fetch templates');
const data = await res.json();
setTemplates(data || []);
@@ -156,9 +159,8 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
});
try {
const res = await fetch('/api/templates/deploy', {
const res = await apiFetch('/templates/deploy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
stackName: stackName.trim(),
template: modifiedTemplate,
@@ -255,6 +257,13 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
{selectedTemplate && (
<div className="flex flex-col h-full">
<SheetHeader className="mb-6 text-left">
{activeNode?.type === 'remote' && (
<div className="mb-2">
<Badge variant="secondary" className="text-xs font-normal">
Deploying to: {activeNode.name}
</Badge>
</div>
)}
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 rounded bg-white p-1 flex-shrink-0 flex items-center justify-center overflow-hidden border">
{!imgErrors[selectedTemplate.title] && selectedTemplate.logo ? (
+17 -1
View File
@@ -796,7 +796,22 @@ export default function EditorLayout() {
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Top Header Bar */}
<div className="h-16 flex items-center justify-end px-6 border-b border-border gap-4">
<div className="h-16 flex items-center justify-between px-6 border-b border-border gap-4">
{/* Node Context Pill — visible only when a remote node is active */}
<div className="flex-shrink-0">
{activeNode?.type === 'remote' ? (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 text-sm font-medium">
<span className="w-2 h-2 rounded-full bg-blue-400 animate-pulse shrink-0" />
{activeNode.name}
</div>
) : (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-muted/50 border border-border text-muted-foreground text-sm">
<span className="w-2 h-2 rounded-full bg-green-500 shrink-0" />
{activeNode?.name ?? 'Local'}
</div>
)}
</div>
<div className="flex items-center gap-4">
{/* Home Button */}
<Button
variant="outline"
@@ -942,6 +957,7 @@ export default function EditorLayout() {
</ScrollArea>
</PopoverContent>
</Popover>
</div>{/* end right-side buttons */}
</div>
{/* Main Workspace */}
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input';
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { RefreshCw, Download, Trash2, Search, Filter } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { useNodes } from '@/context/NodeContext';
interface LogEntry {
@@ -17,6 +18,7 @@ interface LogEntry {
}
export function GlobalObservabilityView() {
const { activeNode } = useNodes();
const [logs, setLogs] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [allStacks, setAllStacks] = useState<string[]>([]);
@@ -57,7 +59,7 @@ export function GlobalObservabilityView() {
useEffect(() => {
const fetchStacks = async () => {
try {
const res = await fetch('/api/stacks');
const res = await apiFetch('/stacks');
if (res.ok) {
const stacks: string[] = await res.json();
setAllStacks(stacks.sort());
@@ -111,7 +113,7 @@ export function GlobalObservabilityView() {
const fetchData = async () => {
setLoading(true);
try {
const logsRes = await fetch('/api/logs/global');
const logsRes = await apiFetch('/logs/global');
if (logsRes.ok) {
setLogs(await logsRes.json());
}
@@ -180,6 +182,13 @@ export function GlobalObservabilityView() {
return (
<div className="flex flex-col h-full w-full relative group bg-[#0A0A0A] text-gray-300">
{/* Node Context Indicator */}
{activeNode?.type === 'remote' && (
<div className="absolute top-2 left-4 z-10 flex items-center gap-1.5 bg-background/90 backdrop-blur-sm border border-border shadow-md rounded-md px-2.5 py-1 text-xs text-muted-foreground">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
{activeNode.name}
</div>
)}
{/* Floating Action Bar */}
<div className="absolute top-2 right-6 z-10 flex gap-2 transition-opacity duration-200 opacity-0 group-hover:opacity-100 focus-within:opacity-100 bg-background/90 backdrop-blur-sm border border-border shadow-md rounded-md p-1 pr-1">
<div className="relative flex items-center">
+12 -1
View File
@@ -4,6 +4,7 @@ import { FitAddon } from '@xterm/addon-fit';
import { Terminal as TerminalIcon, X } from 'lucide-react';
import { Button } from './ui/button';
import '@xterm/xterm/css/xterm.css';
import { useNodes } from '@/context/NodeContext';
interface HostConsoleProps {
stackName?: string | null;
@@ -11,6 +12,7 @@ interface HostConsoleProps {
}
export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
const { activeNode } = useNodes();
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
@@ -65,7 +67,11 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
});
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/api/system/host-console${stackName ? `?stack=${encodeURIComponent(stackName)}` : ''}`;
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
const nodeParam = activeNodeId ? `nodeId=${activeNodeId}` : '';
const stackParam = stackName ? `stack=${encodeURIComponent(stackName)}` : '';
const queryString = [nodeParam, stackParam].filter(Boolean).join('&');
const wsUrl = `${wsProtocol}//${window.location.host}/api/system/host-console${queryString ? `?${queryString}` : ''}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
@@ -153,6 +159,11 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
<div className="flex items-center gap-2 font-medium">
<TerminalIcon className="w-4 h-4 text-muted-foreground" />
<span>Host Console</span>
{activeNode && (
<span className="text-muted-foreground font-normal text-sm">
{activeNode.name}
</span>
)}
{stackName && (
<span className="text-muted-foreground font-normal text-sm">
({stackName})
@@ -8,6 +8,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { apiFetch } from '@/lib/api';
import { toast } from 'sonner';
import { Trash2, HardDrive, Network, PackageMinus, MonitorX, PieChart as ChartIcon } from 'lucide-react';
import { useNodes } from '@/context/NodeContext';
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
import { formatBytes } from '@/lib/utils';
@@ -42,6 +43,7 @@ interface OrphanContainer {
}
export default function ResourcesView() {
const { activeNode } = useNodes();
const [usage, setUsage] = useState<UsageData | null>(null);
const [images, setImages] = useState<DockerImage[]>([]);
const [volumes, setVolumes] = useState<DockerVolume[]>([]);
@@ -188,6 +190,9 @@ export default function ResourcesView() {
<div className="flex items-center gap-2">
<HardDrive className="w-6 h-6" />
<h1 className="text-2xl font-bold">Resources Hub</h1>
{activeNode?.type === 'remote' && (
<span className="text-sm font-normal text-muted-foreground"> {activeNode.name}</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
+55 -33
View File
@@ -14,6 +14,7 @@ import { apiFetch } from '@/lib/api';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server } from 'lucide-react';
import { NodeManager } from './NodeManager';
import { useNodes } from '@/context/NodeContext';
interface Agent {
type: 'discord' | 'slack' | 'webhook';
@@ -29,8 +30,17 @@ interface SettingsModalProps {
}
export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: SettingsModalProps) {
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes'>('account');
// When switching to a remote node, reset to a node-scoped section if on a global-only one
useEffect(() => {
if (isRemote && (activeSection === 'account' || activeSection === 'notifications' || activeSection === 'appearance' || activeSection === 'nodes')) {
setActiveSection('system');
}
}, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps
// Auth State
const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' });
@@ -225,16 +235,22 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
<DialogContent className="sm:max-w-[900px] h-[650px] flex p-0 font-sans shadow-lg bg-background border-border overflow-hidden gap-0">
{/* Sidebar */}
<div className="w-[200px] bg-muted/20 border-r border-border flex flex-col p-4 shrink-0">
<div className="font-semibold text-lg mb-6 text-foreground tracking-tight">Settings Hub</div>
<div className="font-semibold text-lg mb-1 text-foreground tracking-tight">Settings Hub</div>
{isRemote && (
<div className="text-xs text-muted-foreground mb-5 truncate">{activeNode!.name}</div>
)}
{!isRemote && <div className="mb-5" />}
<nav className="space-y-1.5 flex flex-col">
<Button
variant={activeSection === 'account' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
onClick={() => setActiveSection('account')}
>
<Shield className="w-4 h-4 mr-2" />
Account
</Button>
{!isRemote && (
<Button
variant={activeSection === 'account' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
onClick={() => setActiveSection('account')}
>
<Shield className="w-4 h-4 mr-2" />
Account
</Button>
)}
<Button
variant={activeSection === 'system' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
@@ -243,22 +259,26 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
<Activity className="w-4 h-4 mr-2" />
System Limits
</Button>
<Button
variant={activeSection === 'notifications' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
onClick={() => setActiveSection('notifications')}
>
<Bell className="w-4 h-4 mr-2" />
Notifications
</Button>
<Button
variant={activeSection === 'appearance' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
onClick={() => setActiveSection('appearance')}
>
<Palette className="w-4 h-4 mr-2" />
Appearance
</Button>
{!isRemote && (
<Button
variant={activeSection === 'notifications' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
onClick={() => setActiveSection('notifications')}
>
<Bell className="w-4 h-4 mr-2" />
Notifications
</Button>
)}
{!isRemote && (
<Button
variant={activeSection === 'appearance' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
onClick={() => setActiveSection('appearance')}
>
<Palette className="w-4 h-4 mr-2" />
Appearance
</Button>
)}
<Button
variant={activeSection === 'developer' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
@@ -267,14 +287,16 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
<Code className="w-4 h-4 mr-2" />
Developer
</Button>
<Button
variant={activeSection === 'nodes' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
onClick={() => setActiveSection('nodes')}
>
<Server className="w-4 h-4 mr-2" />
Nodes
</Button>
{!isRemote && (
<Button
variant={activeSection === 'nodes' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
onClick={() => setActiveSection('nodes')}
>
<Server className="w-4 h-4 mr-2" />
Nodes
</Button>
)}
</nav>
</div>