fix(settings): restore image tab scrolling and dev websocket routing

- route dashboard WebSocket traffic through /ws to avoid Vite HMR collisions

- run the dev entrypoint with node so bun run dev stays stable in this repo

- restore the Image settings scroll container contract and add regression coverage
This commit is contained in:
Tam Nhu Tran
2026-04-01 16:57:30 -04:00
parent 36acf9f190
commit 7a783d0714
6 changed files with 132 additions and 188 deletions
+1 -1
View File
@@ -81,7 +81,7 @@
"test:native": "bash tests/native/unix/edge-cases.sh",
"test:e2e": "bun test tests/e2e/ --bail --timeout 60000",
"report:hardening": "node scripts/hardening-inventory.js",
"dev": "bun run build:server && bun dist/ccs.js config --dev",
"dev": "bun run build:server && node dist/ccs.js config --dev",
"dev:symlink": "bash scripts/dev-symlink.sh",
"dev:unlink": "bash scripts/dev-symlink.sh --restore",
"ui:build": "cd ui && bun run build",
+6 -1
View File
@@ -36,6 +36,7 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
const server = http.createServer(app);
const wss = new WebSocketServer({
server,
path: '/ws',
maxPayload: 1024 * 1024, // 1MB hard limit to prevent DoS
perMessageDeflate: false, // Prevent zip bomb attacks
});
@@ -88,7 +89,11 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
const { createServer: createViteServer } = await import('vite');
const vite = await createViteServer({
root: path.join(__dirname, '../../ui'),
server: { middlewareMode: true },
server: {
middlewareMode: true,
// Reuse the dashboard HTTP server for HMR in middleware mode.
hmr: { server },
},
appType: 'spa',
});
app.use(vite.middlewares);
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'bun:test';
import { afterEach, describe, expect, it, mock } from 'bun:test';
import type { AddressInfo } from 'net';
import { startServer } from '../../../src/web-server';
@@ -15,6 +15,8 @@ afterEach(async () => {
instance.cleanup();
await new Promise<void>((resolve) => instance.server.close(() => resolve()));
}
mock.restore();
});
describe('startServer host binding', () => {
@@ -41,4 +43,27 @@ describe('startServer host binding', () => {
const address = instance.server.address() as AddressInfo;
expect(['0.0.0.0', '::']).toContain(address.address);
});
it('attaches Vite HMR to the existing HTTP server in dev mode', async () => {
let viteConfig: Record<string, unknown> | undefined;
mock.module('vite', () => ({
createServer: async (config: Record<string, unknown>) => {
viteConfig = config;
return {
middlewares: (_req: unknown, _res: unknown, next: () => void) => next(),
};
},
}));
const instance = await startServer({ port: 0, dev: true });
instances.push(instance);
expect(viteConfig).toBeDefined();
const serverConfig = viteConfig?.server as
| { middlewareMode?: boolean; hmr?: { server?: unknown } }
| undefined;
expect(serverConfig?.middlewareMode).toBe(true);
expect(serverConfig?.hmr?.server).toBe(instance.server);
});
});
+1 -1
View File
@@ -71,7 +71,7 @@ export function useWebSocket() {
setStatus('connecting');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}`);
const ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
wsRef.current = ws;
ws.onopen = () => {
@@ -210,79 +210,8 @@ function routeSourceLabel(source: ImageProfile['resolutionSource']): string {
type SectionTone = 'sky' | 'amber' | 'emerald' | 'cyan' | 'slate';
function getSectionShellClass(tone: SectionTone): string {
switch (tone) {
case 'sky':
return 'border-sky-500/20 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.12),transparent_45%),linear-gradient(180deg,rgba(255,255,255,0.88),rgba(248,250,252,0.92))] dark:bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_45%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(15,23,42,0.88))]';
case 'amber':
return 'border-amber-500/20 bg-[radial-gradient(circle_at_top_left,rgba(245,158,11,0.14),transparent_44%),linear-gradient(180deg,rgba(255,251,235,0.96),rgba(255,247,237,0.92))] dark:bg-[radial-gradient(circle_at_top_left,rgba(245,158,11,0.16),transparent_44%),linear-gradient(180deg,rgba(35,26,17,0.96),rgba(28,22,17,0.92))]';
case 'emerald':
return 'border-emerald-500/20 bg-[radial-gradient(circle_at_top_left,rgba(16,185,129,0.14),transparent_46%),linear-gradient(180deg,rgba(236,253,245,0.96),rgba(240,253,250,0.92))] dark:bg-[radial-gradient(circle_at_top_left,rgba(16,185,129,0.18),transparent_46%),linear-gradient(180deg,rgba(16,32,28,0.96),rgba(15,28,25,0.92))]';
case 'cyan':
return 'border-cyan-500/20 bg-[radial-gradient(circle_at_top_left,rgba(6,182,212,0.14),transparent_44%),linear-gradient(180deg,rgba(236,254,255,0.96),rgba(240,249,255,0.92))] dark:bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,0.16),transparent_44%),linear-gradient(180deg,rgba(13,28,34,0.96),rgba(15,25,32,0.92))]';
case 'slate':
return 'border-slate-400/20 bg-[radial-gradient(circle_at_top_right,rgba(148,163,184,0.14),transparent_44%),linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.96))] dark:bg-[radial-gradient(circle_at_top_right,rgba(148,163,184,0.16),transparent_44%),linear-gradient(180deg,rgba(15,23,42,0.98),rgba(17,24,39,0.96))]';
}
}
function getSectionRuleClass(tone: SectionTone): string {
switch (tone) {
case 'sky':
return 'from-sky-500/60 via-sky-500/15';
case 'amber':
return 'from-amber-500/60 via-amber-500/15';
case 'emerald':
return 'from-emerald-500/60 via-emerald-500/15';
case 'cyan':
return 'from-cyan-500/60 via-cyan-500/15';
case 'slate':
return 'from-slate-400/55 via-slate-400/15';
}
}
function getSectionIconClass(tone: SectionTone): string {
switch (tone) {
case 'sky':
return 'border-sky-500/20 bg-sky-500/10 text-sky-700 dark:text-sky-300';
case 'amber':
return 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300';
case 'emerald':
return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300';
case 'cyan':
return 'border-cyan-500/20 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300';
case 'slate':
return 'border-slate-400/25 bg-slate-500/10 text-slate-700 dark:text-slate-300';
}
}
function getSectionEyebrowClass(tone: SectionTone): string {
switch (tone) {
case 'sky':
return 'text-sky-700/80 dark:text-sky-300/80';
case 'amber':
return 'text-amber-700/80 dark:text-amber-300/80';
case 'emerald':
return 'text-emerald-700/80 dark:text-emerald-300/80';
case 'cyan':
return 'text-cyan-700/80 dark:text-cyan-300/80';
case 'slate':
return 'text-slate-600/80 dark:text-slate-300/80';
}
}
function getInsetPanelClass(tone: SectionTone): string {
switch (tone) {
case 'sky':
return 'border-sky-500/15 bg-background/75';
case 'amber':
return 'border-amber-500/15 bg-background/80';
case 'emerald':
return 'border-emerald-500/15 bg-background/78';
case 'cyan':
return 'border-cyan-500/15 bg-background/78';
case 'slate':
return 'border-slate-400/15 bg-background/78';
}
function getInsetPanelClass(_tone?: SectionTone): string {
return 'border-border/50 bg-background/40';
}
function getBackendRowClass(state: ImageBackend['state'] | undefined): string {
@@ -381,8 +310,8 @@ function normalizeTimeoutDraft(rawValue: string, fallbackValue: string): string
}
interface ImageSectionPanelProps {
tone: SectionTone;
eyebrow: string;
tone?: SectionTone;
eyebrow?: string;
title: string;
description: string;
icon: ReactNode;
@@ -393,8 +322,6 @@ interface ImageSectionPanelProps {
}
function ImageSectionPanel({
tone,
eyebrow,
title,
description,
icon,
@@ -404,55 +331,32 @@ function ImageSectionPanel({
className,
}: ImageSectionPanelProps) {
return (
<section
<div
className={cn(
'relative overflow-hidden rounded-[24px] border p-4 shadow-sm',
getSectionShellClass(tone),
'relative overflow-hidden rounded-2xl border bg-gradient-to-br from-background via-background to-muted/25 p-4 shadow-sm',
className
)}
>
<div
className={cn(
'absolute inset-x-0 top-0 h-px bg-gradient-to-r to-transparent',
getSectionRuleClass(tone)
)}
/>
<div className="pointer-events-none absolute -right-12 top-0 h-28 w-28 rounded-full bg-white/35 blur-3xl dark:bg-white/6" />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2.5">
<div
className={cn(
'inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border',
getSectionIconClass(tone)
)}
>
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-primary/40 via-primary/10 to-transparent" />
<div className="relative space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="flex items-start gap-3">
<div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-primary/20 bg-primary/10 text-primary">
{icon}
</div>
<div className="min-w-0">
<div
className={cn(
'text-[10px] font-semibold uppercase tracking-[0.16em]',
getSectionEyebrowClass(tone)
)}
>
{eyebrow}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">{title}</h3>
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-base font-semibold tracking-tight">{title}</h3>
{meta}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{description}</p>
<p className="mt-0.5 text-sm leading-5 text-muted-foreground">{description}</p>
</div>
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
{action && <div className="shrink-0">{action}</div>}
<div className="space-y-3">{children}</div>
</div>
<div className="mt-4">{children}</div>
</section>
</div>
);
}
@@ -758,86 +662,89 @@ export default function ImageAnalysisSection() {
}
return (
<ScrollArea className="flex-1">
<div className="relative isolate overflow-hidden p-4 md:p-5">
<div className="pointer-events-none absolute -top-20 left-8 h-48 w-48 rounded-full bg-sky-500/10 blur-3xl dark:bg-sky-500/12" />
<div className="pointer-events-none absolute right-4 top-[28rem] h-52 w-52 rounded-full bg-emerald-500/10 blur-3xl dark:bg-emerald-500/12" />
<div className="pointer-events-none absolute bottom-20 left-12 h-44 w-44 rounded-full bg-amber-500/10 blur-3xl dark:bg-amber-500/10" />
<div className="relative flex min-h-0 flex-1 flex-col">
<div
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
error || success
? 'translate-y-0 opacity-100'
: 'pointer-events-none -translate-y-2 opacity-0'
}`}
>
{error && (
<Alert variant="destructive" className="py-2 shadow-lg">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<div className="flex items-center gap-2 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">{success}</span>
</div>
)}
</div>
<div className="relative z-10 space-y-4">
{(error || success) && (
<div className="space-y-2">
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<div className="flex items-center gap-2 rounded-md border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 text-emerald-900 dark:text-emerald-200">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">{success}</span>
</div>
)}
</div>
)}
<section className="relative overflow-hidden rounded-[24px] border border-sky-500/20 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.14),transparent_44%),linear-gradient(180deg,rgba(255,255,255,0.88),rgba(248,250,252,0.92))] p-4 shadow-sm dark:bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_44%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(15,23,42,0.88))]">
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-sky-500/50 via-sky-500/10 to-transparent" />
<div className="pointer-events-none absolute -right-8 top-0 h-24 w-24 rounded-full bg-white/35 blur-3xl dark:bg-white/6" />
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-2.5">
<div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-sky-500/20 bg-sky-500/10 text-sky-700 dark:text-sky-300">
<ImageIcon className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-700/80 dark:text-sky-300/80">
Image control plane
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-6 p-5">
<div className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-background via-background to-muted/30 p-4 shadow-sm">
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-primary/70 via-primary/20 to-transparent" />
<div
className="pointer-events-none absolute inset-0 opacity-[0.035]"
style={{
backgroundImage:
'repeating-linear-gradient(0deg, transparent, transparent 2px, currentColor 2px, currentColor 3px)',
}}
/>
<div className="relative">
<div className="mb-3 flex items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-primary/20 bg-primary/8 text-primary">
<ImageIcon className="h-4 w-4" />
</div>
<p className="text-sm font-semibold tracking-tight">Image</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-semibold tracking-tight">Image</h2>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Badge className={cn('border', summaryToneClass(data.summary.state))}>
{data.summary.title}
</Badge>
<span>{summaryCompactDetail(data.summary)}</span>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{summaryCompactDetail(data.summary)}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={loading || saving}
aria-label="Refresh"
title="Refresh"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={loading || saving}
className="h-9 self-start rounded-xl border-sky-500/15 bg-background/72 backdrop-blur-sm"
>
<RefreshCw className={cn('mr-1 h-4 w-4', loading && 'animate-spin')} />
Refresh
</Button>
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
<div className="rounded-[18px] border bg-background/72 px-3 py-2.5 backdrop-blur-sm">
<div className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Active routes
<div className="mt-3 grid gap-2 sm:grid-cols-2">
<div className="rounded-[18px] border bg-background/72 px-3 py-2.5 backdrop-blur-sm">
<div className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Active routes
</div>
<div className="mt-1.5 text-xl font-semibold text-foreground">
{data.summary.activeProfileCount}
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">Current target path</p>
</div>
<div className="mt-1.5 text-xl font-semibold text-foreground">
{data.summary.activeProfileCount}
<div className="rounded-[18px] border bg-background/72 px-3 py-2.5 backdrop-blur-sm">
<div className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Native path
</div>
<div className="mt-1.5 text-xl font-semibold text-foreground">
{data.summary.nativeProfileCount}
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">Skip transformer</p>
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">Current target path</p>
</div>
<div className="rounded-[18px] border bg-background/72 px-3 py-2.5 backdrop-blur-sm">
<div className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Native path
</div>
<div className="mt-1.5 text-xl font-semibold text-foreground">
{data.summary.nativeProfileCount}
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">Skip transformer</p>
</div>
</div>
</section>
</div>
<ImageSectionPanel
tone="amber"
@@ -1387,7 +1294,7 @@ export default function ImageAnalysisSection() {
</div>
</ImageSectionPanel>
</div>
</div>
</ScrollArea>
</ScrollArea>
</div>
);
}
@@ -276,11 +276,18 @@ describe('ImageAnalysisSection', () => {
});
it('auto-saves edits without rendering a dedicated save button', async () => {
render(<ImageAnalysisSection />, { withSettingsProvider: true });
const { container } = render(<ImageAnalysisSection />, { withSettingsProvider: true });
expect(await screen.findByText('Image')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument();
expect(container.firstElementChild).toHaveClass(
'relative',
'flex',
'min-h-0',
'flex-1',
'flex-col'
);
const timeoutInput = screen.getByDisplayValue('60');
await userEvent.clear(timeoutInput);