mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
Merge pull request #39 from AnsoCode/feature/remote-context
feat(ui): Phase 57 - Remote Context Navigation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user