mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-16 13:38:33 +00:00
feat: add Reduced motion setting and polish chrome, files, and stack-detail (#1501)
A batch of UI/UX polish: - New independent "Reduced motion" appearance setting (separate from Reduced effects). Drives framer-motion via MotionConfig and clamps CSS transitions via data-motion on <html>; toasts are unaffected. Defaults off (OS preference still honored). - Stack-detail Files tab: rename "Files & Volumes" to "Files", add a persisted word-wrap toggle to the file viewer (default on), and add a fullscreen toggle that collapses the Command Center + Logs column so the editor fills the width. - Create Stack > From Git: remove the nested scroll clamp so the deploy toggle and footer are reachable. - Fleet: full-width tab band with icon-only Refresh / Export Dossier, icon-only Check-for-updates / Add-node on the Overview toolbar, theme-aware empty-state headings (calm drops the italic), and fix the Actions card body overlapping the action-row divider. - Snapshots: restyle Restore and Restore all to the ghost button design used by View / Preview / Download, and right-align the per-stack Restore. - Settings sidebar: App Store gradient active style and standard font size. - Compose Doctor: dismiss the high-risk banner (and clear the tab dot) until the findings change, via a shared fingerprint-keyed hook. - Stack-detail Storage: link the "no recent fleet snapshot" warning to the Fleet Snapshots tab (FleetView tabs are now controlled to support the deep link).
This commit is contained in:
@@ -42,7 +42,7 @@ function jsonRes(body: unknown, ok = true) {
|
||||
return { ok, status: ok ? 200 : 500, json: async () => body, text: async () => '' } as unknown as Response;
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); });
|
||||
|
||||
describe('PreflightPanel', () => {
|
||||
it('shows the never-run empty state', async () => {
|
||||
@@ -75,6 +75,19 @@ describe('PreflightPanel', () => {
|
||||
expect(screen.getByText('Image uses a moving tag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dismisses only the result banner, keeping the finding rows', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
|
||||
status: 'high', highestSeverity: 'high',
|
||||
findings: [{ ruleId: 'privileged', severity: 'high', title: 'Privileged container', message: 'runs privileged', service: 'web' }],
|
||||
})));
|
||||
render(<PreflightPanel stackName="web" />);
|
||||
expect(await screen.findByTestId('preflight-status')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('preflight-dismiss-btn'));
|
||||
expect(screen.queryByTestId('preflight-status')).not.toBeInTheDocument();
|
||||
// Only the summary banner is dismissed; the finding row remains.
|
||||
expect(screen.getByText('Privileged container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces the unrenderable state with the render error', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
|
||||
renderable: false, status: 'unrenderable', highestSeverity: 'blocker',
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Check, TriangleAlert, ShieldAlert, Info, RefreshCw, Stethoscope, type LucideIcon,
|
||||
Check, TriangleAlert, ShieldAlert, Info, RefreshCw, Stethoscope, X, type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { usePreflightDismiss } from '@/hooks/usePreflightDismiss';
|
||||
|
||||
// Mirrors the backend payload shape (the frontend never imports backend).
|
||||
type PreflightSeverity = 'blocker' | 'high' | 'warning' | 'info';
|
||||
@@ -152,6 +153,10 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
|
||||
const SummaryIcon = summary?.icon;
|
||||
const busy = loading || running;
|
||||
|
||||
// Dismiss the result banner (and the Doctor tab dot) until the findings change.
|
||||
const { dismissed, dismiss } = usePreflightDismiss(stackName, nodeId, report?.findings);
|
||||
const hasFindings = (report?.findings.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div data-testid="preflight-panel" className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -192,9 +197,21 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{summary && SummaryIcon && (
|
||||
<div data-testid="preflight-status" data-status={report.status} className={cn(CARD_CLASS, summary.tone)}>
|
||||
<div className="flex items-center gap-2">
|
||||
{summary && SummaryIcon && !dismissed && (
|
||||
<div data-testid="preflight-status" data-status={report.status} className={cn(CARD_CLASS, summary.tone, 'relative')}>
|
||||
{hasFindings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
data-testid="preflight-dismiss-btn"
|
||||
aria-label="Dismiss until findings change"
|
||||
title="Dismiss until findings change"
|
||||
className="absolute right-2 top-2 inline-flex h-5 w-5 items-center justify-center rounded text-current/70 hover:bg-current/10 hover:text-current"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 pr-6">
|
||||
<SummaryIcon className="h-4 w-4 shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-mono text-[11px] uppercase tracking-wide">{summary.label}</span>
|
||||
{report.ranAt && (
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toast } from '@/components/ui/toast-store';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from '@/components/NodeManager';
|
||||
|
||||
// Mirrors the backend /storage payload (the frontend never imports backend).
|
||||
type PortabilityStatus = 'portable' | 'partially-portable' | 'node-bound' | 'unknown';
|
||||
@@ -221,6 +222,17 @@ export default function StoragePanel({ stackName }: { stackName: string }) {
|
||||
This stack has persistent storage but no fleet snapshot in the last 7 days.
|
||||
</span>
|
||||
</div>
|
||||
{activeNode?.type !== 'remote' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent<SenchoNavigateDetail>(SENCHO_NAVIGATE_EVENT, {
|
||||
detail: { view: 'fleet', fleetTab: 'snapshots' },
|
||||
}))}
|
||||
className="mt-1.5 text-[12px] font-medium text-brand hover:underline"
|
||||
>
|
||||
Take a fleet snapshot →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && inventory.stateful && snapshot.recent && snapshot.at && (
|
||||
|
||||
Reference in New Issue
Block a user