feat(settings): group sections, add ⌘K search, scope breadcrumb (#680)

* feat(settings): group sections, add ⌘K search, scope breadcrumb

Restructures the Settings Hub sidebar into four labelled groups
(Identity, System, Alerts, Advanced), adds a ⌘K command palette for
section search, and surfaces the active scope (global vs node-scoped)
in the content breadcrumb.

- New `settings/registry.ts` centralises group/item metadata, tier gates,
  glyph assignments, visibility rules, and keyword hints consumed by both
  the sidebar and the command palette
- Cyan 2px left rail + gradient on the active sidebar item; mono-uppercase
  group headers; tier chips inline for locked items
- Scoped ⌘K handler via onKeyDownCapture on DialogContent so the hub no
  longer hijacks the global sidebar shortcut while open
- ScrollArea gains an opt-in `block` prop so the Nodes management table
  can overflow horizontally without Radix's default `display: table`
  wrapper clipping action buttons
- Docs reference updated with the grouped sidebar, scope breadcrumb, and
  ⌘K walkthrough plus refreshed screenshots

* refactor(settings): drop duplicate section headers, redesign system limits, always-visible tier chips

- Remove redundant section titles in every settings page; the dialog header now owns the title and description
- Rework System Limits into a compact row panel with inline-edit chips (warn state, focus ring) and an ON/OFF toggle pill
- Show tier chips on sidebar and command palette whether locked or unlocked, so Skipper/Admiral scope is always legible
- Keep right-aligned action buttons on pages that had a title+button header (Users, Labels, Nodes, API Tokens, Registries)

* fix(settings): seed NumberChip draft on edit instead of via effect

ESLint rule react-hooks/set-state-in-effect flagged the sync effect that
mirrored the external value into local draft state. Replace it with a
startEdit handler that seeds draft from value at click time, so the
button path always reads value directly and no cascading render is
triggered on prop change.

* fix(settings): restore heading role and clean sidebar accessible names

- Wrap the settings dialog title in an h2 so screen readers and E2E locators see a heading again after the in-section headers were removed
- Mark the sidebar glyph aria-hidden so the button's accessible name is just the item label (fixes anchored name matchers)
- Align the MFA E2E helper with the renamed Account section heading
This commit is contained in:
Anso
2026-04-18 16:17:24 -04:00
committed by GitHub
parent ec7620675e
commit 0bf061a745
26 changed files with 796 additions and 449 deletions
@@ -0,0 +1,233 @@
import type { SectionId } from './types';
export type SettingsGroupId = 'identity' | 'system' | 'alerts' | 'advanced';
export interface SettingsGroupMeta {
id: SettingsGroupId;
label: string;
kicker?: string;
glyph: string;
}
export const SETTINGS_GROUPS: readonly SettingsGroupMeta[] = [
{ id: 'identity', label: 'Identity', glyph: '\u25C8' },
{ id: 'system', label: 'System', kicker: 'node-scoped', glyph: '\u25C6' },
{ id: 'alerts', label: 'Alerts', glyph: '\u25C7' },
{ id: 'advanced', label: 'Advanced', glyph: '\u25C7' },
];
export type TierGate = 'skipper' | 'admiral' | null;
export type Scope = 'global' | 'node';
export interface SettingsItemMeta {
id: SectionId;
group: SettingsGroupId;
label: string;
description: string;
keywords: string[];
tier: TierGate;
scope: Scope;
adminOnly?: boolean;
hiddenOnRemote?: boolean;
}
export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
{
id: 'account',
group: 'identity',
label: 'Account',
description: 'Password, MFA, and session controls for the signed-in operator.',
keywords: ['password', 'mfa', 'two-factor', 'session', 'profile'],
tier: null,
scope: 'global',
hiddenOnRemote: true,
},
{
id: 'license',
group: 'identity',
label: 'License',
description: 'Activation key, plan tier, and seat allocation.',
keywords: ['key', 'activation', 'tier', 'plan', 'seats', 'billing'],
tier: null,
scope: 'global',
hiddenOnRemote: true,
},
{
id: 'users',
group: 'identity',
label: 'Users',
description: 'Operators, role assignments, and access scopes.',
keywords: ['operators', 'team', 'rbac', 'roles', 'permissions'],
tier: 'skipper',
scope: 'global',
adminOnly: true,
hiddenOnRemote: true,
},
{
id: 'sso',
group: 'identity',
label: 'SSO',
description: 'Single sign-on via SAML or OIDC identity providers.',
keywords: ['saml', 'oidc', 'okta', 'entra', 'azure', 'login'],
tier: null,
scope: 'global',
adminOnly: true,
hiddenOnRemote: true,
},
{
id: 'api-tokens',
group: 'identity',
label: 'API Tokens',
description: 'Long-lived bearer tokens for CI, scripts, and remote nodes.',
keywords: ['bearer', 'automation', 'ci', 'scripts', 'scopes'],
tier: 'admiral',
scope: 'global',
adminOnly: true,
hiddenOnRemote: true,
},
{
id: 'system',
group: 'system',
label: 'System Limits',
description: 'Threshold percentages for host CPU, RAM, disk, and crash-loop alerts.',
keywords: ['cpu', 'ram', 'disk', 'limits', 'thresholds', 'alerts'],
tier: null,
scope: 'node',
},
{
id: 'registries',
group: 'system',
label: 'Registries',
description: 'Private Docker registries and pull credentials.',
keywords: ['docker', 'ghcr', 'ecr', 'private', 'pull', 'auth'],
tier: 'admiral',
scope: 'global',
adminOnly: true,
hiddenOnRemote: true,
},
{
id: 'nodes',
group: 'system',
label: 'Nodes',
description: 'Remote Sencho instances proxied through this control plane.',
keywords: ['fleet', 'remote', 'proxy', 'node', 'cluster'],
tier: null,
scope: 'global',
hiddenOnRemote: true,
},
{
id: 'notifications',
group: 'alerts',
label: 'Notifications',
description: 'In-app toasts and browser push for stack, container, and system events.',
keywords: ['toasts', 'push', 'events', 'alerts', 'inbox'],
tier: null,
scope: 'global',
},
{
id: 'notification-routing',
group: 'alerts',
label: 'Routing',
description: 'Rules that steer alerts to the right channel based on severity or label.',
keywords: ['rules', 'routing', 'channels', 'severity', 'labels'],
tier: 'admiral',
scope: 'global',
adminOnly: true,
hiddenOnRemote: true,
},
{
id: 'webhooks',
group: 'alerts',
label: 'Webhooks',
description: 'Outbound HTTP hooks to Slack, Discord, Teams, or custom endpoints.',
keywords: ['slack', 'discord', 'teams', 'webhook', 'outbound'],
tier: 'skipper',
scope: 'global',
hiddenOnRemote: true,
},
{
id: 'labels',
group: 'advanced',
label: 'Labels',
description: 'Shared labels for stacks, containers, and nodes.',
keywords: ['labels', 'tags', 'palette', 'organisation'],
tier: 'skipper',
scope: 'global',
hiddenOnRemote: true,
},
{
id: 'security',
group: 'advanced',
label: 'Security',
description: 'Image scanning, suppressions, and posture defaults.',
keywords: ['scan', 'cve', 'trivy', 'suppressions', 'hardening'],
tier: 'skipper',
scope: 'global',
adminOnly: true,
hiddenOnRemote: true,
},
{
id: 'developer',
group: 'advanced',
label: 'Developer',
description: 'Retention windows, log refresh cadence, and debug modes.',
keywords: ['retention', 'logs', 'metrics', 'debug', 'developer'],
tier: null,
scope: 'node',
},
{
id: 'appstore',
group: 'advanced',
label: 'App Store',
description: 'Template registry URL and featured-catalog source.',
keywords: ['templates', 'registry', 'catalog', 'featured'],
tier: null,
scope: 'global',
hiddenOnRemote: true,
},
{
id: 'support',
group: 'advanced',
label: 'Support',
description: 'Diagnostics bundle, docs links, and contact channels.',
keywords: ['help', 'diagnostics', 'bundle', 'docs', 'contact'],
tier: null,
scope: 'global',
},
{
id: 'about',
group: 'advanced',
label: 'About',
description: 'Build metadata, release notes, and licence attributions.',
keywords: ['version', 'build', 'release', 'attributions'],
tier: null,
scope: 'global',
},
];
export function getSettingsItem(id: SectionId): SettingsItemMeta | undefined {
return SETTINGS_ITEMS.find(item => item.id === id);
}
export function getSettingsGroup(id: SettingsGroupId): SettingsGroupMeta | undefined {
return SETTINGS_GROUPS.find(group => group.id === id);
}
export interface VisibilityContext {
isRemote: boolean;
isAdmin: boolean;
isPaid: boolean;
isAdmiral: boolean;
}
export function isItemVisible(item: SettingsItemMeta, ctx: VisibilityContext): boolean {
if (ctx.isRemote && item.hiddenOnRemote) return false;
if (item.adminOnly && !ctx.isAdmin) return false;
return true;
}
export function isItemLocked(item: SettingsItemMeta, ctx: VisibilityContext): boolean {
if (item.tier === 'skipper') return !ctx.isPaid;
if (item.tier === 'admiral') return !ctx.isAdmiral;
return false;
}