mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
feat: home dashboard and Settings Hub polish (#506)
* feat(dashboard): drop CPU column and relative timestamp from Stack Health and status bar The Stack Health table's CPU column duplicated data already surfaced in the top ResourceGauges and the CPU Usage historical chart. The health status bar's 'just now' timestamp was cosmetic: no consumer relied on lastUpdated state for polling, staleness detection, or conditional rendering. Removing both tightens the dashboard and eliminates a dead prop chain through useDashboardData. * refactor: remove dead admin_email field from setup flow The Setup form captured an admin email under 'Used for license recovery. Never shared with third parties.' but the value was written to global_settings and read nowhere: no license recovery, SMTP, or support contact flow consumed it. Rather than building UI on top of the dead field, delete the input, the payload key, and the backend persistence. Any orphaned row from prior setups is harmless and the frontend ignores unknown settings keys. * feat(settings): use Radix ScrollArea with per-section scroll memory Settings Hub used a native-scroll div that snapped to the top every time the user switched subsections and exposed the default browser scrollbar. Wrap the nav and content panes with the shadcn ScrollArea (Radix under the hood, type='hover') and expose a viewportRef so the modal can stash each section's scrollTop in a ref and restore it via useLayoutEffect on switch. Style the thumb with translucent foreground tokens so it reads as glass against popovers and dialogs. Replaces a hand-rolled scroll hook and ad-hoc CSS utility.
This commit is contained in:
@@ -537,7 +537,7 @@ app.post('/api/auth/setup', authRateLimiter, async (req: Request, res: Response)
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, password, confirmPassword, admin_email } = req.body;
|
||||
const { username, password, confirmPassword } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!username || !password || !confirmPassword) {
|
||||
@@ -567,10 +567,6 @@ app.post('/api/auth/setup', authRateLimiter, async (req: Request, res: Response)
|
||||
dbSvc.updateGlobalSetting('auth_password_hash', passwordHash);
|
||||
dbSvc.updateGlobalSetting('auth_jwt_secret', jwtSecret);
|
||||
|
||||
if (admin_email && typeof admin_email === 'string') {
|
||||
dbSvc.updateGlobalSetting('admin_email', admin_email.trim());
|
||||
}
|
||||
|
||||
// Create admin user in users table
|
||||
dbSvc.addUser({ username, password_hash: passwordHash, role: 'admin' });
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ The top bar provides an at-a-glance health assessment for the active node. Sench
|
||||
| **Degraded** | At least one resource is above 80%, or there are unread error alerts. |
|
||||
| **Critical** | At least one resource is above 90%, or there are exited containers with unread errors. |
|
||||
|
||||
The bar also shows the active node name, the number of running containers, the current alert count, and a relative timestamp for the last data refresh.
|
||||
The bar also shows the active node name, the number of running containers, and the current alert count.
|
||||
|
||||
## Resource gauges
|
||||
|
||||
@@ -43,10 +43,9 @@ A table listing every stack in your `COMPOSE_DIR` with live status and resource
|
||||
|--------|-------------|
|
||||
| **Stack** | Stack name (derived from the directory name) |
|
||||
| **Status** | `UP` (running) or `DN` (exited) |
|
||||
| **CPU** | Aggregate CPU usage across all containers in the stack, normalized over host cores |
|
||||
| **Memory** | Total memory allocated by the stack's containers |
|
||||
|
||||
Click any row to navigate directly to that stack's editor. Stacks are sorted with running stacks first, then alphabetically. If you have more than 8 stacks, the table paginates automatically.
|
||||
Click any row to navigate directly to that stack's editor. Stacks are sorted with running stacks first, then alphabetically. If you have more than 8 stacks, the table paginates automatically. For fleet-wide CPU usage, see the **CPU** resource gauge card at the top of the dashboard and the **CPU Usage** historical chart below the table.
|
||||
|
||||
## Historical metrics charts
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ export default function HomeDashboard({ onNavigateToStack, notifications, onClea
|
||||
systemStats={data.systemStats}
|
||||
notifications={notifications}
|
||||
activeNodeName={activeNode?.name || 'Local'}
|
||||
lastUpdated={data.lastUpdated}
|
||||
/>
|
||||
|
||||
<ResourceGauges
|
||||
@@ -37,7 +36,6 @@ export default function HomeDashboard({ onNavigateToStack, notifications, onClea
|
||||
<StackHealthTable
|
||||
stackStatuses={data.stackStatuses}
|
||||
metrics={data.metrics}
|
||||
systemStats={data.systemStats}
|
||||
onNavigateToStack={onNavigateToStack || (() => {})}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -38,6 +39,11 @@ import {
|
||||
} from './settings';
|
||||
import type { PatchableSettings, SectionId } from './settings';
|
||||
|
||||
const GLOBAL_ONLY_SECTIONS: ReadonlySet<SectionId> = new Set<SectionId>([
|
||||
'account', 'license', 'users', 'sso', 'api-tokens', 'registries',
|
||||
'labels', 'notifications', 'notification-routing', 'webhooks', 'nodes', 'appstore',
|
||||
]);
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -51,14 +57,30 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const [activeSection, setActiveSection] = useState<SectionId>(initialSection || 'account');
|
||||
|
||||
const contentViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollPositionsRef = useRef<Partial<Record<SectionId, number>>>({});
|
||||
|
||||
const switchSection = (next: SectionId) => {
|
||||
if (contentViewportRef.current) {
|
||||
scrollPositionsRef.current[activeSection] = contentViewportRef.current.scrollTop;
|
||||
}
|
||||
setActiveSection(next);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (contentViewportRef.current) {
|
||||
contentViewportRef.current.scrollTop = scrollPositionsRef.current[activeSection] ?? 0;
|
||||
}
|
||||
}, [activeSection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && initialSection) setActiveSection(initialSection);
|
||||
}, [isOpen, initialSection]);
|
||||
|
||||
// When switching to a remote node, reset to a node-scoped section if on a global-only one
|
||||
// Remote nodes don't expose global-only sections, so bounce to a node-scoped one.
|
||||
useEffect(() => {
|
||||
if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'sso' || activeSection === 'api-tokens' || activeSection === 'registries' || activeSection === 'labels' || activeSection === 'notifications' || activeSection === 'notification-routing' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) {
|
||||
setActiveSection('system');
|
||||
if (isRemote && GLOBAL_ONLY_SECTIONS.has(activeSection)) {
|
||||
switchSection('system');
|
||||
}
|
||||
}, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -221,7 +243,7 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
|
||||
<Button
|
||||
variant={activeSection === section ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium relative"
|
||||
onClick={() => setActiveSection(section)}
|
||||
onClick={() => switchSection(section)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
@@ -319,7 +341,8 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
|
||||
) : (
|
||||
<div className="mb-5" />
|
||||
)}
|
||||
<nav className="space-y-1.5 flex flex-col flex-1 overflow-y-auto">
|
||||
<ScrollArea className="flex-1 -mr-2 pr-2">
|
||||
<nav className="space-y-1.5 flex flex-col">
|
||||
{/* Account / License */}
|
||||
{!isRemote && (
|
||||
<NavButton section="account" icon={<Shield className="w-4 h-4 mr-2" />} label="Account" />
|
||||
@@ -385,13 +408,16 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
|
||||
{/* Support / About */}
|
||||
<NavButton section="support" icon={<LifeBuoy className="w-4 h-4 mr-2" />} label="Support" />
|
||||
<NavButton section="about" icon={<Info className="w-4 h-4 mr-2" />} label="About" />
|
||||
</nav>
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-6">
|
||||
{renderSection()}
|
||||
</div>
|
||||
<ScrollArea viewportRef={contentViewportRef} className="flex-1">
|
||||
<div className="p-6 flex flex-col gap-6">
|
||||
{renderSection()}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,6 @@ export function Setup({
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [adminEmail, setAdminEmail] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -53,7 +52,6 @@ export function Setup({
|
||||
username,
|
||||
password,
|
||||
confirmPassword,
|
||||
admin_email: adminEmail || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -152,21 +150,6 @@ export function Setup({
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="adminEmail">
|
||||
Email <span className="text-muted-foreground font-normal">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="adminEmail"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={adminEmail}
|
||||
onChange={(e) => setAdminEmail(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used for license recovery. Never shared with third parties.
|
||||
</p>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-sm text-red-500 text-center">
|
||||
{error}
|
||||
|
||||
@@ -14,7 +14,6 @@ interface HealthStatusBarProps {
|
||||
systemStats: SystemStats | null;
|
||||
notifications: NotificationItem[];
|
||||
activeNodeName: string;
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
interface HealthResult {
|
||||
@@ -57,16 +56,7 @@ const healthConfig: Record<HealthLevel, { label: string; dotClass: string; textC
|
||||
critical: { label: 'Critical', dotClass: 'bg-destructive animate-pulse', textClass: 'text-destructive' },
|
||||
};
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 5) return 'just now';
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
return `${Math.floor(minutes / 60)}h ago`;
|
||||
}
|
||||
|
||||
export function HealthStatusBar({ stats, systemStats, notifications, activeNodeName, lastUpdated }: HealthStatusBarProps) {
|
||||
export function HealthStatusBar({ stats, systemStats, notifications, activeNodeName }: HealthStatusBarProps) {
|
||||
const { level, reasons } = useMemo(
|
||||
() => deriveHealth(stats, systemStats, notifications),
|
||||
[stats, systemStats, notifications]
|
||||
@@ -124,10 +114,6 @@ export function HealthStatusBar({ stats, systemStats, notifications, activeNodeN
|
||||
<span>alert{unreadAlerts !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<span className="text-xs text-stat-icon font-mono">
|
||||
{formatRelativeTime(lastUpdated)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -3,12 +3,11 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronRight, ChevronLeft, Layers } from 'lucide-react';
|
||||
import type { StackStatusEntry, MetricPoint, SystemStats } from './types';
|
||||
import type { StackStatusEntry, MetricPoint } from './types';
|
||||
|
||||
interface StackHealthTableProps {
|
||||
stackStatuses: Record<string, StackStatusEntry>;
|
||||
metrics: MetricPoint[];
|
||||
systemStats: SystemStats | null;
|
||||
onNavigateToStack: (stackFile: string) => void;
|
||||
}
|
||||
|
||||
@@ -19,8 +18,7 @@ const formatMemory = (mb: number): string => {
|
||||
return `${mb.toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
export function StackHealthTable({ stackStatuses, metrics, systemStats, onNavigateToStack }: StackHealthTableProps) {
|
||||
const cores = systemStats?.cpu.cores || 1;
|
||||
export function StackHealthTable({ stackStatuses, metrics, onNavigateToStack }: StackHealthTableProps) {
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const stackMetrics = useMemo(() => {
|
||||
@@ -35,15 +33,13 @@ export function StackHealthTable({ stackStatuses, metrics, systemStats, onNaviga
|
||||
}
|
||||
}
|
||||
|
||||
const result: Record<string, { cpu: number; mem: number }> = {};
|
||||
const result: Record<string, { mem: number }> = {};
|
||||
for (const [stack, containers] of Object.entries(latestPerContainer)) {
|
||||
let cpu = 0;
|
||||
let mem = 0;
|
||||
for (const m of Object.values(containers)) {
|
||||
cpu += m.cpu_percent;
|
||||
mem += m.memory_mb;
|
||||
}
|
||||
result[stack] = { cpu, mem };
|
||||
result[stack] = { mem };
|
||||
}
|
||||
return result;
|
||||
}, [metrics]);
|
||||
@@ -57,7 +53,6 @@ export function StackHealthTable({ stackStatuses, metrics, systemStats, onNaviga
|
||||
file,
|
||||
name,
|
||||
status: entry.status,
|
||||
cpu: m?.cpu ?? null,
|
||||
memory: m?.mem ?? null,
|
||||
};
|
||||
})
|
||||
@@ -132,7 +127,6 @@ export function StackHealthTable({ stackStatuses, metrics, systemStats, onNaviga
|
||||
<TableRow className="hover:bg-transparent border-b border-border">
|
||||
<TableHead className="text-xs text-stat-icon font-medium h-8">Stack</TableHead>
|
||||
<TableHead className="text-xs text-stat-icon font-medium h-8 w-[60px]">Status</TableHead>
|
||||
<TableHead className="text-xs text-stat-icon font-medium h-8 w-[80px] text-right">CPU</TableHead>
|
||||
<TableHead className="text-xs text-stat-icon font-medium h-8 w-[90px] text-right">Memory</TableHead>
|
||||
<TableHead className="h-8 w-[40px]" />
|
||||
</TableRow>
|
||||
@@ -152,11 +146,6 @@ export function StackHealthTable({ stackStatuses, metrics, systemStats, onNaviga
|
||||
<TableCell className="py-2.5">
|
||||
<span className={`font-mono text-xs font-medium ${sd.className}`}>{sd.label}</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-2.5 text-right">
|
||||
<span className="font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{row.cpu !== null ? `${(row.cpu / cores).toFixed(1)}%` : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-2.5 text-right">
|
||||
<span className="font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{row.memory !== null ? formatMemory(row.memory) : '--'}
|
||||
|
||||
@@ -65,5 +65,4 @@ export interface DashboardData {
|
||||
systemStats: SystemStats | null;
|
||||
metrics: MetricPoint[];
|
||||
stackStatuses: Record<string, StackStatusEntry>;
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ export function useDashboardData(): DashboardData {
|
||||
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
|
||||
const [metrics, setMetrics] = useState<MetricPoint[]>([]);
|
||||
const [stackStatuses, setStackStatuses] = useState<Record<string, StackStatusEntry>>({});
|
||||
const [lastUpdated, setLastUpdated] = useState<number>(0);
|
||||
|
||||
// Keep a ref to the latest nodeId so async callbacks don't write stale data
|
||||
// after a node switch has already triggered a new effect cycle.
|
||||
@@ -76,7 +75,6 @@ export function useDashboardData(): DashboardData {
|
||||
const data = await fetchJson<Stats>('/stats');
|
||||
if (data && nodeIdRef.current === currentNodeId) {
|
||||
setStats(data);
|
||||
setLastUpdated(Date.now());
|
||||
}
|
||||
};
|
||||
fetchStats();
|
||||
@@ -93,7 +91,6 @@ export function useDashboardData(): DashboardData {
|
||||
const data = await fetchJson<SystemStats>('/system/stats');
|
||||
if (nodeIdRef.current === currentNodeId) {
|
||||
setSystemStats(data);
|
||||
if (data) setLastUpdated(Date.now());
|
||||
}
|
||||
};
|
||||
fetchSys();
|
||||
@@ -129,5 +126,5 @@ export function useDashboardData(): DashboardData {
|
||||
return cleanup;
|
||||
}, [nodeId, fetchJson]);
|
||||
|
||||
return { stats, systemStats, metrics, stackStatuses, lastUpdated };
|
||||
return { stats, systemStats, metrics, stackStatuses };
|
||||
}
|
||||
|
||||
@@ -5,14 +5,19 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
|
||||
viewportRef?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
>(({ className, children, viewportRef, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
className="h-full w-full rounded-[inherit]"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
@@ -21,6 +26,8 @@ const ScrollArea = React.forwardRef<
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
// Thumb uses a translucent foreground alpha so it harmonizes with glass
|
||||
// overlays (dropdowns, popovers, dialogs) instead of competing with content.
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
@@ -29,16 +36,16 @@ const ScrollBar = React.forwardRef<
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
"flex touch-none select-none transition-colors bg-transparent",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
"h-full w-2 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
"h-2 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-foreground/20 transition-colors hover:bg-foreground/40" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
Reference in New Issue
Block a user