fix: assorted UI/UX polish fixes (#1670)

* fix(dashboard): replace Stack Health update badge with an icon

The pill badge duplicated space already used by the stack name column.
A CircleArrowUp icon after the name signals an update is available
without competing with the existing ArrowUp/ArrowDown sort indicators
in the same table.

* fix(dashboard): add accessible name to update-available icon

Icon-only indicators need an aria-label directly on the icon; title on
a non-interactive span is not reliably announced by screen readers.

* test(dashboard): cover the update-available icon's accessible name

The icon-only indicator and its aria-label fix had no regression
guard, unlike the equivalent update dot in StackRow.

* refactor(dashboard): compute the update-available label once per row

It was being derived twice (title and aria-label) from the same
row.outdatedServices input.

* fix: drop Community-tier pricing upsells from settings

Community operators no longer see the "See pricing" link in Licensing
or the "Need direct support?" callout in Support. The pricing link now
only shows for an expired paid license needing to renew.

* fix: make Resources images/volumes tables actually scrollable

The tables were wrapped in a Radix ScrollArea sized with max-h-[62vh].
Radix's viewport uses height:100%, which cannot resolve against an
ancestor whose computed height is auto (max-height alone isn't a
definite height), so the viewport silently grew past the visible box
and the extra rows were clipped with no way to reach them. Verified
live: several image rows were permanently unreachable, with no working
internal scrollbar and not enough outer page scroll to compensate.

Switched to an explicit h-[62vh], which the viewport can resolve
correctly, matching every other working ScrollArea in the codebase.
Falls back to h-auto below the md breakpoint so the bespoke mobile
layout keeps shrinking to content and scrolling via the outer page
instead of gaining a fixed-height inner scroll box.

* fix: apply ScrollArea definite-height fix across remaining lists

Radix ScrollArea needs an explicit height, not max-height, or the
viewport collapses and clipped rows become unreachable. Extend the
Resources fix to security, settings, git, and create/import surfaces,
and drop redundant outer wrappers where ModalBody already scrolls.

* fix: migrate Networking tables to Radix ScrollArea

Networks and Findings used native max-h + overflow-auto, which worked
but broke glass scrollbar consistency with Resources and the design
system. Switch them to ScrollArea with a definite height and the same
mobile fallback as the other inventory tables.

* fix: warn Classic bar users that the style is retiring soon

When Appearance Navigation is set to Classic bar, show the same warn
SettingsCallout pattern used for Constrained graphics. Preference is
kept until removal; no alternate style is named in the copy.

* fix: move Channels delivery retries below channel tabs

Put channel configuration first and keep Delivery retries as a shared
footer control under the Discord/Slack/Webhook/Apprise tabs.

* fix: drop redundant More masthead from Smart bar overflow menu

The trigger already reads More, so the dropdown masthead repeated the
same label. Leave titled mastheads on Compact Navigate and Add quick
link menus.

* test: align Smart More E2E with masthead removal

The overflow menu no longer shows a More heading. Assert the menu via
the Logs item and lock that the redundant masthead stays gone.

* fix: consolidate Fleet Map toolbar filters into a single row

Adopt the same retractable search control used on Fleet > Overview and
move the flag filters (missing deps, port conflicts, orphans, shared)
onto the toolbar row right after the Graph/List selector. The node
filter becomes a dropdown instead of individual toggle chips so it
does not clutter the row as fleet size grows.

* fix: move Networking Topology filters onto the search toolbar row

Merge the ownership selector and boolean filter chips (include system,
exposed, drift, missing external, shared) onto the same row as the
stack/network search inputs, matching the Fleet Map toolbar layout.

* fix: default the reclaimable-space banner off

Resources > Docker & Storage's "Show reclaimable-space banner" toggle
now defaults to off instead of on. Also flips the /settings fetch
failure path to fail closed (hide the banner) to match the new
default, instead of failing open.

* fix: raise Compact launcher quick links cap from 5 to 7

* fix: add Discord link to Settings Support Self-serve

Gives users a community chat channel alongside Documentation and
GitHub Issues, using the official Discord mark since lucide-react
has no brand icon for it.

* fix: stop container NET I/O metric row height jump

Give NET I/O more column share than CPU/MEM and keep metric
values on one line with truncate so three-digit rates cannot
grow the strip.

* fix: elevate Doctor tab between Activity and Drift

Make Compose Doctor easier to find in the anatomy strip by placing
it with the ops judgment cluster, ahead of Dossier and inventory tabs.
This commit is contained in:
Anso
2026-07-26 03:25:07 -04:00
committed by GitHub
parent 688767092a
commit cfb42af4e0
51 changed files with 546 additions and 383 deletions
@@ -487,59 +487,57 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
{createMode === 'docker-run' && (
<div role="tabpanel" id={panelId('docker-run')} aria-labelledby={tabId('docker-run')}>
<ScrollArea block className="max-h-[60vh]">
<ModalBody>
<div className="space-y-2">
<Label htmlFor="create-dr-stack-name">Stack Name</Label>
<Input
id="create-dr-stack-name"
placeholder="Stack name (e.g., myapp)"
value={newStackName}
onChange={(e) => setNewStackName(e.target.value)}
disabled={creatingFromDockerRun}
/>
<ModalBody>
<div className="space-y-2">
<Label htmlFor="create-dr-stack-name">Stack Name</Label>
<Input
id="create-dr-stack-name"
placeholder="Stack name (e.g., myapp)"
value={newStackName}
onChange={(e) => setNewStackName(e.target.value)}
disabled={creatingFromDockerRun}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-dr-command">Paste your docker run command</Label>
<textarea
id="create-dr-command"
spellCheck={false}
className="flex w-full rounded-md border border-glass-border bg-input px-3 py-2 text-sm font-mono shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 min-h-[120px] resize-y"
placeholder="docker run -d --name nginx -p 8080:80 nginx:latest"
value={dockerRunInput}
onChange={(e) => {
setDockerRunInput(e.target.value);
if (convertedYaml !== null) setConvertedYaml(null);
}}
disabled={creatingFromDockerRun}
/>
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={handleConvertDockerRun}
disabled={isConverting || creatingFromDockerRun || !dockerRunInput.trim()}
>
{isConverting ? (
<><Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />Converting</>
) : (
<><FileCode2 className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />Convert</>
)}
</Button>
</div>
</div>
{convertedYaml !== null && (
<div className="space-y-2">
<Label htmlFor="create-dr-command">Paste your docker run command</Label>
<textarea
id="create-dr-command"
spellCheck={false}
className="flex w-full rounded-md border border-glass-border bg-input px-3 py-2 text-sm font-mono shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 min-h-[120px] resize-y"
placeholder="docker run -d --name nginx -p 8080:80 nginx:latest"
value={dockerRunInput}
onChange={(e) => {
setDockerRunInput(e.target.value);
if (convertedYaml !== null) setConvertedYaml(null);
}}
disabled={creatingFromDockerRun}
/>
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={handleConvertDockerRun}
disabled={isConverting || creatingFromDockerRun || !dockerRunInput.trim()}
>
{isConverting ? (
<><Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />Converting</>
) : (
<><FileCode2 className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />Convert</>
)}
</Button>
</div>
<Label>compose.yaml preview</Label>
<ScrollArea block className="h-[240px] rounded-md border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
<pre className="px-3 py-2 text-xs font-mono whitespace-pre leading-relaxed">
{convertedYaml}
</pre>
</ScrollArea>
</div>
{convertedYaml !== null && (
<div className="space-y-2">
<Label>compose.yaml preview</Label>
<ScrollArea block className="max-h-[240px] rounded-md border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
<pre className="px-3 py-2 text-xs font-mono whitespace-pre leading-relaxed">
{convertedYaml}
</pre>
</ScrollArea>
</div>
)}
</ModalBody>
</ScrollArea>
)}
</ModalBody>
<ModalFooter
hint={convertedYaml ? 'YAML READY' : 'CONVERT FIRST'}
hintAccent={convertedYaml ? `${convertedYaml.split('\n').length} LINES` : undefined}
@@ -11,7 +11,6 @@ import {
import { ModalBody, ModalFooter } from '../ui/modal';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { ScrollArea } from '../ui/scroll-area';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useAuth } from '@/context/AuthContext';
@@ -142,66 +141,64 @@ export function ImportStackPanel({ onClose, onImported }: ImportStackPanelProps)
return (
<>
<ScrollArea block className="max-h-[60vh]">
<ModalBody>
<div className="rounded-md border border-card-border border-t-card-border-top bg-card/60 px-3 py-2.5 shadow-card-bevel">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Sencho looks for stacks in
</div>
<div className="mt-1 break-all font-mono text-xs text-stat-value">{composeDir || '—'}</div>
<p className="mt-2 text-xs leading-relaxed text-stat-subtitle">
Each stack lives in its own subfolder here. Keep the host mount path the same as the
path inside the container so relative volumes resolve (the 1:1 path rule).{' '}
<a
href="https://docs.sencho.io/getting-started/configuration"
target="_blank"
rel="noreferrer"
className="text-brand hover:underline"
>
Learn more
</a>
<ModalBody>
<div className="rounded-md border border-card-border border-t-card-border-top bg-card/60 px-3 py-2.5 shadow-card-bevel">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Sencho looks for stacks in
</div>
<div className="mt-1 break-all font-mono text-xs text-stat-value">{composeDir || '—'}</div>
<p className="mt-2 text-xs leading-relaxed text-stat-subtitle">
Each stack lives in its own subfolder here. Keep the host mount path the same as the
path inside the container so relative volumes resolve (the 1:1 path rule).{' '}
<a
href="https://docs.sencho.io/getting-started/configuration"
target="_blank"
rel="noreferrer"
className="text-brand hover:underline"
>
Learn more
</a>
</p>
</div>
{loading && !data ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-stat-subtitle">
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} />
Scanning
</div>
) : candidates.length === 0 ? (
<div className="py-10 text-center">
<FolderSearch className="mx-auto h-6 w-6 text-stat-icon" strokeWidth={1.5} />
<p className="mt-3 text-sm text-stat-title">No compose files to import.</p>
<p className="mx-auto mt-1 max-w-sm text-xs leading-relaxed text-stat-subtitle">
Stacks already in their own subfolder show up in the sidebar. Drop a loose compose
file in the compose directory and rescan, or pick another source above to create one.
</p>
</div>
{loading && !data ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-stat-subtitle">
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} />
Scanning
</div>
) : candidates.length === 0 ? (
<div className="py-10 text-center">
<FolderSearch className="mx-auto h-6 w-6 text-stat-icon" strokeWidth={1.5} />
<p className="mt-3 text-sm text-stat-title">No compose files to import.</p>
<p className="mx-auto mt-1 max-w-sm text-xs leading-relaxed text-stat-subtitle">
Stacks already in their own subfolder show up in the sidebar. Drop a loose compose
file in the compose directory and rescan, or pick another source above to create one.
</p>
</div>
) : (
// Keep the list mounted during a rescan (only the Rescan button
// animates) so the modal does not change height. aria-busy + the
// dimmed, click-blocked cue (opacity + pointer-events-none) signal
// the in-flight scan without a layout swap.
<div
className={`space-y-2${loading ? ' pointer-events-none opacity-60' : ''}`}
aria-busy={loading}
>
{candidates.map((c) => (
<CandidateCard
key={c.location}
candidate={c}
composeDir={composeDir}
expanded={expanded.has(c.location)}
canCreate={canCreate}
moving={movingLocation === c.location}
onToggle={() => toggle(c.location)}
onMove={(name) => move(c.location, name)}
/>
))}
</div>
)}
</ModalBody>
</ScrollArea>
) : (
// Keep the list mounted during a rescan (only the Rescan button
// animates) so the modal does not change height. aria-busy + the
// dimmed, click-blocked cue (opacity + pointer-events-none) signal
// the in-flight scan without a layout swap.
<div
className={`space-y-2${loading ? ' pointer-events-none opacity-60' : ''}`}
aria-busy={loading}
>
{candidates.map((c) => (
<CandidateCard
key={c.location}
candidate={c}
composeDir={composeDir}
expanded={expanded.has(c.location)}
canCreate={canCreate}
moving={movingLocation === c.location}
onToggle={() => toggle(c.location)}
onMove={(name) => move(c.location, name)}
/>
))}
</div>
)}
</ModalBody>
<ModalFooter
hint="SCAN IS READ ONLY · MOVING ASKS FIRST"
secondary={
@@ -7,7 +7,7 @@ vi.mock('../../Terminal', () => ({ default: () => null }));
vi.mock('../../StructuredLogViewer', () => ({ default: () => null }));
vi.mock('../../ImageSourceMenu', () => ({ ImageSourceMenu: () => null }));
import { ContainersHealth } from '../editor-view-blocks';
import { ContainersHealth, type ContainersHealthProps } from '../editor-view-blocks';
import { copyToClipboard } from '@/lib/clipboard';
import type { ContainerInfo } from '../EditorView';
import type { Node } from '@/context/NodeContext';
@@ -96,11 +96,14 @@ describe('density toggle and summary strip', () => {
} as unknown as ContainerInfo;
}
function renderMany(containers: ContainerInfo[]) {
function renderMany(
containers: ContainerInfo[],
containerStats: ContainersHealthProps['containerStats'] = {},
) {
return render(
<ContainersHealth
safeContainers={containers}
containerStats={{}}
containerStats={containerStats}
containerStatsError={null}
isAdmin
activeNode={LOCAL_NODE}
@@ -171,6 +174,24 @@ describe('density toggle and summary strip', () => {
expect(screen.getAllByText('cpu')).toHaveLength(2);
});
it('gives NET I/O more column share and keeps the value on one line', () => {
renderMany([makeContainer({ Id: 'abc123def456' })], {
abc123def456: {
cpu: '0.12%',
ram: '12.3 MB',
net: '132 B/s ↓ / 168 B/s ↑',
history: { cpu: [], mem: [], netIn: [], netOut: [] },
},
});
const netValue = screen.getByText('132 B/s ↓ / 168 B/s ↑');
expect(netValue).toHaveClass('truncate');
expect(netValue).toHaveAttribute('title', '132 B/s ↓ / 168 B/s ↑');
const metricsGrid = netValue.closest('.grid');
expect(metricsGrid?.className).toContain('0.85fr');
expect(metricsGrid?.className).toContain('1.3fr');
});
it('keeps header row actions visible in compact mode', () => {
renderMany([
makeContainer({ Id: 'a', State: 'running', Service: 'web' }),
@@ -648,34 +648,30 @@ export function ContainersHealth({
</div>
</div>
{isActive && density === 'detailed' ? (
<div className="mt-2 grid grid-cols-3 gap-2">
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
<div className="flex flex-col">
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">cpu</span>
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.cpu ?? '-'}</span>
<div className="mt-2 grid grid-cols-[minmax(0,0.85fr)_minmax(0,0.85fr)_minmax(0,1.3fr)] gap-2">
{[
{ label: 'cpu', value: stats?.cpu ?? '-', points: history?.cpu ?? [] },
{ label: 'mem', value: stats?.ram ?? '-', points: history?.mem ?? [] },
{ label: 'net i/o', value: stats?.net ?? '-', points: history?.netIn ?? [] },
].map(({ label, value, points }) => (
<div
key={label}
className="flex min-w-0 items-center gap-2 rounded-md bg-background/60 px-2 py-1.5"
>
<div className="min-w-0 flex flex-col">
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">{label}</span>
<span
className="font-mono text-xs tabular-nums truncate text-foreground"
title={value === '-' ? undefined : value}
>
{value}
</span>
</div>
<div className="ml-auto h-5 w-16 shrink min-w-8">
<Sparkline points={points} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
</div>
</div>
<div className="ml-auto h-5 w-16">
<Sparkline points={history?.cpu ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
</div>
</div>
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
<div className="flex flex-col">
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">mem</span>
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.ram ?? '-'}</span>
</div>
<div className="ml-auto h-5 w-16">
<Sparkline points={history?.mem ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
</div>
</div>
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
<div className="flex flex-col">
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">net i/o</span>
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.net ?? '-'}</span>
</div>
<div className="ml-auto h-5 w-16">
<Sparkline points={history?.netIn ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
</div>
</div>
))}
</div>
) : null}
</div>