mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -663,19 +663,19 @@ describe('Paid-only setting keys (audit_retention_days)', () => {
|
||||
});
|
||||
|
||||
describe('reclaim_hero setting', () => {
|
||||
it('is allowlisted and seeds to "1" (banner on by default)', async () => {
|
||||
it('is allowlisted and seeds to "0" (banner off by default)', async () => {
|
||||
const res = await request(app).get('/api/settings').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reclaim_hero).toBe('1');
|
||||
expect(res.body.reclaim_hero).toBe('0');
|
||||
});
|
||||
|
||||
it('accepts a well-formed write and rejects a non-enum value', async () => {
|
||||
const ok = await request(app)
|
||||
.patch('/api/settings')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ reclaim_hero: '0' });
|
||||
.send({ reclaim_hero: '1' });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().reclaim_hero).toBe('0');
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().reclaim_hero).toBe('1');
|
||||
|
||||
const bad = await request(app)
|
||||
.patch('/api/settings')
|
||||
@@ -683,9 +683,9 @@ describe('reclaim_hero setting', () => {
|
||||
.send({ reclaim_hero: 'banana' });
|
||||
expect(bad.status).toBe(400);
|
||||
expect(bad.body.error).toBe('Validation failed');
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().reclaim_hero).toBe('0');
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().reclaim_hero).toBe('1');
|
||||
|
||||
// Reset for any later reads of the shared test DB.
|
||||
DatabaseService.getInstance().updateGlobalSetting('reclaim_hero', '1');
|
||||
DatabaseService.getInstance().updateGlobalSetting('reclaim_hero', '0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2012,7 +2012,7 @@ export class DatabaseService {
|
||||
stmt.run('cve_intel_enabled', '1');
|
||||
stmt.run('mesh_auto_recreate', '0');
|
||||
stmt.run('prune_on_update', '1');
|
||||
stmt.run('reclaim_hero', '1');
|
||||
stmt.run('reclaim_hero', '0');
|
||||
stmt.run('health_gate_enabled', '1');
|
||||
stmt.run('health_gate_window_seconds', '90');
|
||||
stmt.run('image_update_check_interval_minutes', '120');
|
||||
|
||||
@@ -7,14 +7,14 @@ description: Threshold and event alerts for your fleet, dispatched to Discord, S
|
||||
Sencho watches each node it manages for container crashes, host pressure, scheduled-task results, and update availability, then surfaces every signal in two places: the in-app notification bell at the top of the shell and an external channel you configure. This page covers everything from configuring channels to writing stack and service threshold rules, routing alerts to dedicated channels with routing rules, and tuning retention.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/alerts-notifications/notifications-settings.png" alt="Settings · Notifications · Channels panel with NODE Local in the header, a Delivery retries row showing Extra attempts 0 and a Save retries button, Discord Slack Webhook and Apprise tabs with Apprise selected, Enabled off, an empty Apprise endpoint placeholder, and Test beside Save." />
|
||||
<img src="/images/alerts-notifications/notifications-settings.png" alt="Settings · Notifications · Channels panel with NODE Local in the header, Discord Slack Webhook and Apprise tabs with Apprise selected, Enabled off, an empty Apprise endpoint placeholder, Test beside Save, and a Delivery retries section below showing Extra attempts 0 and a Save retries button." />
|
||||
</Frame>
|
||||
|
||||
## Notification channels
|
||||
|
||||
Open **Settings · Notifications · Channels** to configure Discord, Slack, custom webhook, and Apprise channels. Each channel is per-node, so switching the active node via the node picker reloads the panel against that node's stored settings. The masthead carries a `CHANNELS` stat showing how many of the four slots are enabled.
|
||||
|
||||
Above the channel tabs, **Delivery retries** sets how many extra in-process attempts (0 to 3) Sencho makes after a transient delivery failure on that node. The default is `0` (single-shot). Extra attempts wait a fixed one second between tries. Admin role is required to change the value.
|
||||
Below the channel tabs, **Delivery retries** sets how many extra in-process attempts (0 to 3) Sencho makes after a transient delivery failure on that node. The default is `0` (single-shot). Extra attempts wait a fixed one second between tries. Admin role is required to change the value.
|
||||
|
||||
Each Discord, Slack, and Webhook tab carries an **Enabled** toggle, a **Webhook URL** input (HTTPS only), and **Test** / **Save**. The Apprise tab uses an **Apprise endpoint** instead: keyed `/notify/{key}` shows optional **Tags**; stateless `/notify` shows **Destination URLs**. The kicker on each tab toggles between `enabled` and `off` so you can see at a glance which slots are wired up.
|
||||
|
||||
|
||||
@@ -97,10 +97,10 @@ The **Navigation** group chooses how the desktop top bar presents page destinati
|
||||
|
||||
- **Navigation style**
|
||||
- **Smart bar** (recommended default): keeps a short set of primary destinations visible and moves the rest into a grouped **More** menu.
|
||||
- **Classic bar**: shows the full horizontal destination strip.
|
||||
- **Compact launcher**: puts destinations in a left-side launcher menu and optionally pins up to five **quick links** on the bar.
|
||||
- **Classic bar**: shows the full horizontal destination strip. Choosing Classic shows a callout that Classic bar will be removed soon; the preference is kept until then.
|
||||
- **Compact launcher**: puts destinations in a left-side launcher menu and optionally pins up to seven **quick links** on the bar.
|
||||
- **Top navigation labels** (Classic and Smart): shows text beside top navigation icons. Turn it off for an icon-only bar; destinations stay reachable by tooltip, accessible name, and the command palette. Phone layout always keeps labels. With labels off, **Top navigation alignment** places the icon-only bar left or centered.
|
||||
- **Quick links** (Compact launcher): labeled pins after the launcher, with a trailing **+** that opens reachable unpinned destinations. Right-click a pin and choose Remove, or manage the full list under Appearance. Up to five pins; recommended defaults start you with four.
|
||||
- **Quick links** (Compact launcher): labeled pins after the launcher, with a trailing **+** that opens reachable unpinned destinations. Right-click a pin and choose Remove, or manage the full list under Appearance. Up to seven pins; recommended defaults start you with four.
|
||||
|
||||
Deploy-progress behavior and the diff-preview-before-save step are stack workflow preferences, so they live in **Settings → Infrastructure → Stacks**, not here.
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ On a phone, `/nodes/local/stacks/<stack>/files` opens the compose editor instead
|
||||
|
||||
Some in-app state is intentionally not encoded:
|
||||
|
||||
- **Stack Anatomy sub-tabs** (Anatomy, Activity, Dossier, Drift, Environment, and the rest) stay in memory only. Refresh returns you to the default Anatomy tab for that stack. A [Networking](/features/networking) finding's action can open a stack directly on its Doctor, Dossier, or Drift tab; this is in-app navigation, not a separate URL, so the same refresh behavior applies.
|
||||
- **Stack Anatomy sub-tabs** (Anatomy, Activity, Doctor, Drift, Dossier, Environment, and the rest) stay in memory only. Refresh returns you to the default Anatomy tab for that stack. A [Networking](/features/networking) finding's action can open a stack directly on its Doctor, Dossier, or Drift tab; this is in-app navigation, not a separate URL, so the same refresh behavior applies.
|
||||
|
||||
## Tips
|
||||
|
||||
|
||||
@@ -79,21 +79,21 @@ Stacks with more than one container gain a summary strip above the container lis
|
||||
The right column shows the **Anatomy panel** by default: a read-only summary of the compose file alongside a scrollable tab row for other stack views.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/editor/anatomy-tabs.png" alt="Anatomy panel header strip showing the Anatomy, Activity, Dossier, Drift, Environment, Networking, Doctor, and Storage tab row with Files and Edit compose shortcuts on the right" />
|
||||
<img src="/images/editor/anatomy-tabs.png" alt="Anatomy panel header strip showing the Anatomy, Activity, Doctor, Drift, Dossier, Environment, Networking, and Storage tab row with Files and Edit compose shortcuts on the right" />
|
||||
</Frame>
|
||||
|
||||
The tab row always shows four tabs: **Anatomy**, **Activity**, **Dossier**, and **Drift**. Four more tabs appear when the active node advertises the matching capability.
|
||||
The tab row always shows four tabs: **Anatomy**, **Activity**, **Drift**, and **Dossier**. When Compose Doctor is available on the node, **Doctor** sits between Activity and Drift. Four more capability-gated tabs appear when the active node advertises them.
|
||||
|
||||
| Tab | Always present? | What it shows |
|
||||
|-----|----------------|--------------|
|
||||
| **Anatomy** | Yes | Read-only compose file summary. |
|
||||
| **Activity** | Yes | Operational event timeline for this stack. See [Stack Activity](/features/stack-activity). |
|
||||
| **Dossier** | Yes | Exportable Markdown of the anatomy combined with operator notes. See [Stack Dossier](/features/stack-dossier). |
|
||||
| **Doctor** | When `compose-doctor` capability is present | Preflight check results grouped by severity. The tab gains a red dot for blocker findings and an amber dot for high-risk findings. See [Compose Doctor](/features/compose-doctor). |
|
||||
| **Drift** | Yes | Live comparison of the declared compose against the running containers. See [Stack Drift](/features/stack-drift). |
|
||||
| **Dossier** | Yes | Exportable Markdown of the anatomy combined with operator notes. See [Stack Dossier](/features/stack-dossier). |
|
||||
| **Environment** | When `env-inventory` capability is present | Variable inventory across all env files, with status for each variable. See [Environment Guardrails](/features/environment-guardrails). |
|
||||
| **Compose Labels** | When `container-label-inventory` capability is present | Declared Compose labels vs runtime container labels per service. See [Docker Label Audit](/features/docker-label-audit). |
|
||||
| **Networking** | When `compose-networking` capability is present | Port exposure summary per service with intent classification. See [Compose Networking](/features/compose-networking). |
|
||||
| **Doctor** | When `compose-doctor` capability is present | Preflight check results grouped by severity. The tab gains a red dot for blocker findings and an amber dot for high-risk findings. See [Compose Doctor](/features/compose-doctor). |
|
||||
| **Storage** | When `compose-storage` capability is present | Mount inventory with portability assessment and snapshot coverage. See [Compose Storage](/features/compose-storage). |
|
||||
|
||||
The **Files** shortcut and the **Edit compose** button sit at the right end of the strip and stay available regardless of which tab is active.
|
||||
|
||||
@@ -13,7 +13,7 @@ Sencho is a self-hosted cockpit for Docker Compose. The catalog below groups Sen
|
||||
## Core workflow
|
||||
|
||||
<Frame>
|
||||
<img src="/images/overview/stack-anatomy.png" alt="A stack open in the editor: the action toolbar, the container card with CPU and memory sparklines, the live log stream, and the right-hand panel on the Networking tab showing exposure intent, network memberships, published ports, and a runtime drift check. The full tab strip (Anatomy, Activity, Dossier, Drift, Environment, Networking, Doctor, Storage) is visible across the top of the panel." />
|
||||
<img src="/images/overview/stack-anatomy.png" alt="A stack open in the editor: the action toolbar, the container card with CPU and memory sparklines, the live log stream, and the right-hand panel on the Networking tab showing exposure intent, network memberships, published ports, and a runtime drift check. The full tab strip (Anatomy, Activity, Doctor, Drift, Dossier, Environment, Networking, Storage) is visible across the top of the panel." />
|
||||
</Frame>
|
||||
|
||||
### Stack management
|
||||
|
||||
@@ -11,13 +11,13 @@ The **Resources** tab shows everything Docker is storing on your host, broken do
|
||||
|
||||
## Reclaim hero
|
||||
|
||||
When there is reclaimable disk space (unused images, stopped containers, or dangling volumes), an amber banner leads the view with the total amount you can free and a `·`-separated breakdown of what contributes to it (for example, `2 unused images · 10 dangling volumes`). Click **Review & prune** to open a confirmation dialog that lists the exact items that will be removed (capped in the preview with an "and N more" note when the list is long). Confirm only after the plan is ready; Sencho rechecks the list at execute time and skips anything that is no longer eligible.
|
||||
Once enabled for a node, an amber banner leads the view whenever there is reclaimable disk space (unused images, stopped containers, or dangling volumes), showing the total amount you can free and a `·`-separated breakdown of what contributes to it (for example, `2 unused images · 10 dangling volumes`). Click **Review & prune** to open a confirmation dialog that lists the exact items that will be removed (capped in the preview with an "and N more" note when the list is long). Confirm only after the plan is ready; Sencho rechecks the list at execute time and skips anything that is no longer eligible.
|
||||
|
||||
The hero stays hidden when there is nothing to reclaim, keeping the view focused on the rest of your inventory.
|
||||
|
||||
To set the banner aside without pruning, use the **×** in its top-right corner. It stays hidden on that browser until the reclaimable total grows past the amount it held when you dismissed it, so a small, stubborn remainder will not keep reappearing while a genuine new build-up still surfaces.
|
||||
|
||||
To keep the banner off for a node entirely, open **Settings → Monitoring → Docker & Storage** and switch off **Show reclaimable-space banner**. It is on by default and applies per node.
|
||||
The banner is off by default and applies per node. To turn it on, open **Settings → Monitoring → Docker & Storage** and switch on **Show reclaimable-space banner**.
|
||||
|
||||
<Note>
|
||||
The banner and the **Review & prune** action are admin-only. Read-only roles still see the rest of the page but cannot trigger destructive operations.
|
||||
|
||||
@@ -121,11 +121,11 @@ Drift events also appear in the stack's **Activity** tab alongside deploys, rest
|
||||
|
||||
<img
|
||||
src="/images/stack-drift/drift-tab-location.png"
|
||||
alt="The tab bar in the stack detail view showing Anatomy, Activity, Dossier, Drift, Environment, and Compose Labels tabs with Drift selected, and separate Files and Edit compose buttons to the right"
|
||||
alt="The tab bar in the stack detail view showing Anatomy, Activity, Drift, Dossier, Environment, and Compose Labels tabs with Drift selected, and separate Files and Edit compose buttons to the right"
|
||||
/>
|
||||
|
||||
1. Click any stack in the sidebar to open it.
|
||||
2. In the right-hand panel, switch to the **Drift** tab. If the tab row doesn't fit the panel width, scroll it to reveal Drift alongside the stack's other tabs (Anatomy, Activity, Dossier, Environment, Networking, and so on).
|
||||
2. In the right-hand panel, switch to the **Drift** tab. If the tab row doesn't fit the panel width, scroll it to reveal Drift alongside the stack's other tabs (Anatomy, Activity, Doctor, Dossier, Environment, Networking, and so on).
|
||||
3. Read the status badge and any findings.
|
||||
4. Click **re-check** after editing the Compose file or after a manual Docker operation to refresh the ledger.
|
||||
|
||||
|
||||
@@ -257,7 +257,7 @@ The structured viewer holds up to 10,000 lines; older entries are dropped from t
|
||||
|
||||
## Anatomy panel
|
||||
|
||||
The right column of the stack view is a tabbed panel: Anatomy, Activity, Dossier, Drift, Environment, Compose Labels, Networking, Doctor, and Storage. Tabs appear only when the data they need is available, and the row scrolls horizontally (with a chevron button) when there are more tabs than fit.
|
||||
The right column of the stack view is a tabbed panel: Anatomy, Activity, Doctor, Drift, Dossier, Environment, Compose Labels, Networking, and Storage. Tabs appear only when the data they need is available, and the row scrolls horizontally (with a chevron button) when there are more tabs than fit.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/stack-view/anatomy-panel.png" alt="Anatomy panel with the Anatomy tab active, showing services, ports, volumes, restart, env_file, network, and source rows plus the tab row" />
|
||||
|
||||
@@ -52,7 +52,7 @@ Opening a stack gives you the day-to-day workspace:
|
||||
- Running stacks expose **Restart**, **Stop**, **Take down**, and **Update**; stopped stacks expose **Start** and **Update**. The overflow menu holds less frequent actions such as rollback, config scan, and delete.
|
||||
- Container rows show health, uptime, published ports, live CPU and memory, network activity, logs, and service actions.
|
||||
- The logs panel can run in **Structured** mode or **Raw terminal** mode.
|
||||
- The right panel provides tabs for **Anatomy**, **Activity**, **Dossier**, **Drift**, **Environment**, **Compose Labels**, **Networking**, **Doctor**, and **Storage**, with **Files** and **Edit** controls for browsing stack files and editing compose or env content.
|
||||
- The right panel provides tabs for **Anatomy**, **Activity**, **Doctor**, **Drift**, **Dossier**, **Environment**, **Compose Labels**, **Networking**, and **Storage**, with **Files** and **Edit** controls for browsing stack files and editing compose or env content.
|
||||
|
||||
## Fleet operations
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Sencho provides dedicated email channels for different types of inquiries. Use t
|
||||
Priority email support, reserved for Admiral license holders: Monday to Friday, 09:00-17:00 America/New_York, with a one-business-day first-response target. This is not a contractual SLA or 24/7 service.
|
||||
</Card>
|
||||
|
||||
Every user, Community and Admiral alike, can reach [Documentation](https://docs.sencho.io) and [GitHub Issues](https://github.com/studio-saelix/sencho/issues) from **Settings → Help → Support** in the app. Admiral adds the priority email channel above on the same page.
|
||||
Every user, Community and Admiral alike, can reach [Documentation](https://docs.sencho.io), [GitHub Issues](https://github.com/studio-saelix/sencho/issues), and [Discord](https://discord.gg/rvXAszRGSc) from **Settings → Help → Support** in the app. Admiral adds the priority email channel above on the same page.
|
||||
|
||||
## General inquiries
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ A live preview card shows a sample fleet-status tile so you can see a color choi
|
||||
|
||||
| Control | What it does |
|
||||
|---------|--------------|
|
||||
| **Navigation style** | **Smart bar** (recommended default): primary destinations stay visible in the top bar and the rest live under **More**. **Classic bar** keeps the full horizontal strip of destinations. **Compact launcher** puts every destination in a menu, with optional quick links. |
|
||||
| **Navigation style** | **Smart bar** (recommended default): primary destinations stay visible in the top bar and the rest live under **More**. **Classic bar** keeps the full horizontal strip of destinations (retiring soon; a callout appears while it is selected). **Compact launcher** puts every destination in a menu, with optional quick links. |
|
||||
| **Top navigation labels** | On by default. Shows text labels beside the top navigation icons; turn off for a more compact bar with icons only. |
|
||||
|
||||
Deploy-progress behavior and the diff-preview-before-save step are stack workflow preferences and live in their own [Stacks](#stacks) section under Infrastructure.
|
||||
@@ -293,7 +293,7 @@ Configure the reclaimable-space alert, the reclaimable-space banner, and automat
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| **Reclaimable Docker data threshold** | 5 GiB | Alert when reclaimable Docker data (images, volumes, build cache that `docker prune` could free) exceeds this size. Set to `0` to disable the alert. |
|
||||
| **Show reclaimable-space banner** | On | Show the reclaimable-space banner at the top of the Resource Hub when this node has unused images, stopped containers, or dangling volumes to clear. |
|
||||
| **Show reclaimable-space banner** | Off | Show the reclaimable-space banner at the top of the Resource Hub when this node has unused images, stopped containers, or dangling volumes to clear. |
|
||||
|
||||
### Image cleanup
|
||||
|
||||
@@ -403,7 +403,7 @@ Quick reference:
|
||||
|
||||
Configure external destinations for alert notifications. Four agent types are available on separate tabs: **Discord**, **Slack**, **Webhook**, and **Apprise**. The masthead publishes a **CHANNELS** pill showing how many agents are enabled (for example, `2/4`).
|
||||
|
||||
**Delivery retries** (admin-only) sets how many extra in-process attempts (0 to 3, default 0) this node makes after a transient channel failure, with a fixed one-second delay between attempts. There is no durable queue; ambiguous network failures can produce duplicate notifications.
|
||||
Below the channel tabs, **Delivery retries** (admin-only) sets how many extra in-process attempts (0 to 3, default 0) this node makes after a transient channel failure, with a fixed one-second delay between attempts. There is no durable queue; ambiguous network failures can produce duplicate notifications.
|
||||
|
||||
For each agent:
|
||||
|
||||
@@ -721,6 +721,7 @@ Links to help resources, with an additional channel for Admiral operators.
|
||||
|----------|-------------|
|
||||
| **Documentation** | Opens docs.sencho.io. |
|
||||
| **GitHub Issues** | Report bugs and request features on GitHub. |
|
||||
| **Discord** | Chat with the community and the team. |
|
||||
|
||||
### Admiral support
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ test.describe('Desktop navigation styles', () => {
|
||||
await setTopNavMode(page, 'smart');
|
||||
await expect(page.locator('[data-sn-chrome="topbar"]')).toHaveAttribute('data-sn-nav-mode', 'smart');
|
||||
await page.getByRole('button', { name: 'More navigation' }).click();
|
||||
await expect(page.locator('.font-heading').filter({ hasText: 'More' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: /Logs/i })).toBeVisible();
|
||||
await expect(page.locator('.font-heading').filter({ hasText: 'More' })).toHaveCount(0);
|
||||
await page.getByRole('menuitem', { name: /Logs/i }).click();
|
||||
await expect(page.locator('body')).toContainText(/Logs|Central|Observability/i);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -421,9 +421,9 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
const [planError, setPlanError] = useState<string | null>(null);
|
||||
const planFetchGenRef = useRef(0);
|
||||
|
||||
// Reclaim banner visibility: the per-node opt-out setting (loaded in
|
||||
// Reclaim banner visibility: the per-node opt-in setting (loaded in
|
||||
// fetchAllData) and the per-browser dismiss snapshot for the active node.
|
||||
const [reclaimHeroEnabled, setReclaimHeroEnabled] = useState(true);
|
||||
const [reclaimHeroEnabled, setReclaimHeroEnabled] = useState(false);
|
||||
const [heroDismissedBytes, setHeroDismissedBytes] = useState<number | null>(null);
|
||||
|
||||
// Classified image selection is node-bound so a node switch cannot leave
|
||||
@@ -471,11 +471,10 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
|
||||
if (fetchGenerationRef.current !== generation) return;
|
||||
|
||||
// Set unconditionally: a failed /settings (settingsData null) must
|
||||
// reset to the default-on state for this node, not inherit the
|
||||
// previously active node's value. undefined !== '0' is true, so a
|
||||
// missing key or failed fetch fails open toward showing the banner.
|
||||
setReclaimHeroEnabled(settingsData?.reclaim_hero !== '0');
|
||||
// Set unconditionally: a failed /settings (settingsData null) or a
|
||||
// missing key falls back to the default-off state for this node
|
||||
// rather than inheriting the previously active node's value.
|
||||
setReclaimHeroEnabled(settingsData?.reclaim_hero === '1');
|
||||
if (usageData) setUsage(usageData);
|
||||
if (resourcesData) {
|
||||
setImages(resourcesData.images ?? []);
|
||||
@@ -777,7 +776,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
+ (usage?.reclaimableContainers ?? 0)
|
||||
+ (usage?.reclaimableVolumes ?? 0);
|
||||
|
||||
// Banner shows while the opt-out is on and the operator has not dismissed
|
||||
// Banner shows while the opt-in is on and the operator has not dismissed
|
||||
// this (or a larger) reclaimable total. A stable residue stays hidden; a
|
||||
// fresh pile pushes the total past the snapshot and the banner returns.
|
||||
const heroVisible = isAdmin && reclaimHeroEnabled
|
||||
@@ -1027,7 +1026,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<ScrollArea className="max-h-[62vh]">
|
||||
<ScrollArea className="h-[62vh] max-md:h-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
@@ -1200,7 +1199,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<ScrollArea className="max-h-[62vh]">
|
||||
<ScrollArea className="h-[62vh] max-md:h-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
|
||||
@@ -315,7 +315,7 @@ export function ScanComparisonSheet({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea block className="max-h-[60vh]">
|
||||
<ScrollArea block className="h-[60vh] max-md:h-auto">
|
||||
{pageItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center text-center py-16 gap-2">
|
||||
<ShieldCheck className="w-8 h-8 text-success" strokeWidth={1.5} />
|
||||
|
||||
@@ -71,6 +71,21 @@ describe('StackAnatomyPanel Doctor tab (capability on)', () => {
|
||||
expect(screen.queryByTestId('doctor-tab-dot')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('places Doctor between Activity and Drift when the capability is present', async () => {
|
||||
badgeSeverity = 'warning';
|
||||
render(panel());
|
||||
await screen.findByTestId('doctor-tab');
|
||||
const labels = screen.getAllByRole('tab').map((t) => t.textContent?.replace(/\s+/g, ' ').trim());
|
||||
const activity = labels.indexOf('Activity');
|
||||
const doctor = labels.findIndex((l) => l?.startsWith('Doctor'));
|
||||
const drift = labels.indexOf('Drift');
|
||||
const dossier = labels.indexOf('Dossier');
|
||||
expect(activity).toBeGreaterThanOrEqual(0);
|
||||
expect(doctor).toBe(activity + 1);
|
||||
expect(drift).toBe(doctor + 1);
|
||||
expect(dossier).toBe(drift + 1);
|
||||
});
|
||||
|
||||
it('renders the Networking tab when the capability is present', async () => {
|
||||
badgeSeverity = 'warning';
|
||||
render(panel());
|
||||
|
||||
@@ -449,17 +449,6 @@ export default function StackAnatomyPanel({
|
||||
<TabsList className="h-7 w-max gap-0.5 bg-transparent border-none p-0">
|
||||
<TabsTrigger value="anatomy" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Anatomy</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Activity</TabsTrigger>
|
||||
<TabsTrigger value="dossier" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Dossier</TabsTrigger>
|
||||
<TabsTrigger value="drift" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Drift</TabsTrigger>
|
||||
{envInventoryEnabled && (
|
||||
<TabsTrigger value="environment" data-testid="environment-tab" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Environment</TabsTrigger>
|
||||
)}
|
||||
{composeLabelsEnabled && (
|
||||
<TabsTrigger value="compose-labels" data-testid="compose-labels-tab" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Compose Labels</TabsTrigger>
|
||||
)}
|
||||
{networkingEnabled && (
|
||||
<TabsTrigger value="networking" data-testid="networking-tab" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Networking</TabsTrigger>
|
||||
)}
|
||||
{doctorEnabled && (
|
||||
<TabsTrigger value="doctor" data-testid="doctor-tab" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
@@ -473,6 +462,17 @@ export default function StackAnatomyPanel({
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="drift" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Drift</TabsTrigger>
|
||||
<TabsTrigger value="dossier" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Dossier</TabsTrigger>
|
||||
{envInventoryEnabled && (
|
||||
<TabsTrigger value="environment" data-testid="environment-tab" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Environment</TabsTrigger>
|
||||
)}
|
||||
{composeLabelsEnabled && (
|
||||
<TabsTrigger value="compose-labels" data-testid="compose-labels-tab" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Compose Labels</TabsTrigger>
|
||||
)}
|
||||
{networkingEnabled && (
|
||||
<TabsTrigger value="networking" data-testid="networking-tab" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Networking</TabsTrigger>
|
||||
)}
|
||||
{storageEnabled && (
|
||||
<TabsTrigger value="storage" data-testid="storage-tab" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Storage</TabsTrigger>
|
||||
)}
|
||||
@@ -733,12 +733,17 @@ export default function StackAnatomyPanel({
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="dossier" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<StackDossierPanel stackName={stackName} anatomy={anatomyInput} canEdit={canEdit} />
|
||||
</TabsContent>
|
||||
<TabsContent value="drift" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<DriftPanel stackName={stackName} />
|
||||
</TabsContent>
|
||||
<TabsContent value="dossier" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<StackDossierPanel stackName={stackName} anatomy={anatomyInput} canEdit={canEdit} />
|
||||
</TabsContent>
|
||||
{doctorEnabled && (
|
||||
<TabsContent value="doctor" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<PreflightPanel stackName={stackName} canEdit={canEdit} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{networkingEnabled && (
|
||||
<TabsContent value="networking" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<StackNetworkingPanel stackName={stackName} canEdit={canEdit} doctorEnabled={doctorEnabled} />
|
||||
@@ -754,11 +759,6 @@ export default function StackAnatomyPanel({
|
||||
<ComposeLabelsPanel stackName={stackName} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{doctorEnabled && (
|
||||
<TabsContent value="doctor" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<PreflightPanel stackName={stackName} canEdit={canEdit} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{storageEnabled && (
|
||||
<TabsContent value="storage" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<StoragePanel stackName={stackName} />
|
||||
|
||||
@@ -89,13 +89,13 @@ function PanelMenuContent({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuContent align="start" sideOffset={8} className="w-56 overflow-hidden rounded-md p-0">
|
||||
<TopBarMenuMasthead title={title} />
|
||||
<div className="border-t border-card-border/60 p-1">{children}</div>
|
||||
{title ? <TopBarMenuMasthead title={title} /> : null}
|
||||
<div className={cn(title && 'border-t border-card-border/60', 'p-1')}>{children}</div>
|
||||
</DropdownMenuContent>
|
||||
);
|
||||
}
|
||||
@@ -274,7 +274,7 @@ function SmartStrip({
|
||||
<ActiveUnderline active={moreActive} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<PanelMenuContent title="More">
|
||||
<PanelMenuContent>
|
||||
<GroupedMenuItems
|
||||
groups={overflowGroups}
|
||||
activeView={activeView}
|
||||
|
||||
@@ -268,6 +268,7 @@ describe('ResourcesView', () => {
|
||||
}
|
||||
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
||||
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
||||
if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '1' }));
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
@@ -298,6 +299,7 @@ describe('ResourcesView', () => {
|
||||
}
|
||||
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
||||
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
||||
if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '1' }));
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
@@ -322,6 +324,7 @@ describe('ResourcesView', () => {
|
||||
}
|
||||
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
||||
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
||||
if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '1' }));
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
@@ -348,6 +351,7 @@ describe('ResourcesView', () => {
|
||||
}
|
||||
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
||||
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
||||
if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '1' }));
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
@@ -367,6 +371,7 @@ describe('ResourcesView', () => {
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(usage));
|
||||
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
||||
if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '1' }));
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
@@ -391,6 +396,7 @@ describe('ResourcesView', () => {
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
||||
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
||||
if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '1' }));
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
@@ -411,29 +417,52 @@ describe('ResourcesView', () => {
|
||||
expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the banner on a node switch when /settings fails, instead of inheriting the previous node opt-out', async () => {
|
||||
let settingsOk = true;
|
||||
it('hides the banner on a node switch when /settings fails, instead of inheriting the previous node opt-in', async () => {
|
||||
// Both responses key off the active node, so the switch cannot desync them:
|
||||
// node A opts in and serves its own image, node B fails /settings.
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
const isNodeA = nodesState.activeNode?.id === 1;
|
||||
if (url === '/settings') {
|
||||
return settingsOk
|
||||
? Promise.resolve(jsonResponse({ reclaim_hero: '0' }))
|
||||
return isNodeA
|
||||
? Promise.resolve(jsonResponse({ reclaim_hero: '1' }))
|
||||
: Promise.resolve(jsonResponse({}, { ok: false, status: 500 }));
|
||||
}
|
||||
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
||||
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [image('node-a-img:latest')], volumes: [], networks: [] }));
|
||||
if (url === '/system/resources') {
|
||||
return Promise.resolve(jsonResponse({
|
||||
images: [image(isNodeA ? 'node-a-img:latest' : 'node-b-img:latest')],
|
||||
volumes: [],
|
||||
networks: [],
|
||||
}));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
// Node A has the banner turned off; once loaded it stays hidden.
|
||||
// Node A has the banner turned on; once loaded it is visible.
|
||||
const { rerender } = render(<ResourcesView />);
|
||||
await screen.findByText('node-a-img:latest');
|
||||
expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument();
|
||||
await screen.findByTestId('reclaim-hero');
|
||||
|
||||
// Switch to node B with a failing /settings: the banner must show (fail
|
||||
// open), not carry over node A's disabled state.
|
||||
settingsOk = false;
|
||||
// Switch to node B with a failing /settings: the banner must hide (fail
|
||||
// closed), not carry over node A's enabled state.
|
||||
nodesState.activeNode = { id: 2 };
|
||||
rerender(<ResourcesView />);
|
||||
await screen.findByTestId('reclaim-hero');
|
||||
await screen.findByText('node-b-img:latest');
|
||||
expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the banner hidden when the setting is explicitly off, even with reclaimable space', async () => {
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
||||
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [image('off-img:latest')], volumes: [], networks: [] }));
|
||||
if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '0' }));
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
// Wait on rendered data, not the request: /settings lands in the same batch,
|
||||
// so a visible image proves the setting was applied before this assertion.
|
||||
render(<ResourcesView />);
|
||||
await screen.findByText('off-img:latest');
|
||||
expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Home, Radar } from 'lucide-react';
|
||||
import { TopBar, type TopBarNavItem } from '../TopBar';
|
||||
import { MAX_QUICK_LINKS } from '@/hooks/use-top-nav-quick-links';
|
||||
|
||||
const navItems: TopBarNavItem[] = [
|
||||
{ value: 'dashboard', label: 'Home', icon: Home },
|
||||
@@ -142,7 +143,7 @@ describe('TopBar smart and compact modes', () => {
|
||||
expect(screen.getByRole('button', { name: 'More navigation' })).toHaveTextContent('More');
|
||||
});
|
||||
|
||||
it('opens the More menu with masthead chrome and keeps overflow labels', async () => {
|
||||
it('opens the More menu without a redundant masthead title and keeps overflow labels', async () => {
|
||||
const onNavigate = vi.fn();
|
||||
renderTopBar({
|
||||
navMode: 'smart',
|
||||
@@ -155,8 +156,8 @@ describe('TopBar smart and compact modes', () => {
|
||||
const more = screen.getByRole('button', { name: 'More navigation' });
|
||||
more.focus();
|
||||
fireEvent.keyDown(more, { key: 'Enter' });
|
||||
expect(await screen.findByText('More', { selector: '.font-heading' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('menuitem', { name: /Logs/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText('More', { selector: '.font-heading' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Logs/i }));
|
||||
expect(onNavigate).toHaveBeenCalledWith('global-observability');
|
||||
});
|
||||
@@ -211,15 +212,18 @@ describe('TopBar smart and compact modes', () => {
|
||||
it('disables Add when persisted capacity is full even if fewer pins are visible', () => {
|
||||
renderTopBar({
|
||||
navMode: 'compact',
|
||||
persistedQuickLinkIds: ['dashboard', 'fleet', 'resources', 'security', 'networking'],
|
||||
// Capacity is a count check, so the IDs only need to be distinct and unpinned-candidate free.
|
||||
persistedQuickLinkIds: Array.from({ length: MAX_QUICK_LINKS }, (_, i) => `pinned-${i}`),
|
||||
quickLinks: [{ value: 'dashboard', label: 'Home', icon: Home }],
|
||||
navModel: {
|
||||
...emptyModel,
|
||||
launcherGroups,
|
||||
quickLinkCandidates: emptyModel.quickLinkCandidates,
|
||||
quickLinkCandidates: [{ value: 'fleet' as const, label: 'Fleet', icon: Radar }],
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Add quick link' })).toBeDisabled();
|
||||
const add = screen.getByRole('button', { name: 'Add quick link' });
|
||||
expect(add).toBeDisabled();
|
||||
expect(add.closest('[title]')).toHaveAttribute('title', 'Remove a quick link to free a slot');
|
||||
});
|
||||
|
||||
it('offers Compact launcher context Add for unpinned destinations', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sparkline } from '@/components/ui/sparkline';
|
||||
import { AlertCircle, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Layers, RefreshCw } from 'lucide-react';
|
||||
import { AlertCircle, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, CircleArrowUp, Layers, RefreshCw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { StackStatusEntry, MetricPoint, StackCpuSeries, StackStatusesLoadStatus } from './types';
|
||||
@@ -9,7 +9,7 @@ import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import { isConfirmedImageUpdate, isConfirmedServiceUpdate } from '@/types/imageUpdates';
|
||||
import { aggregateCurrentUsage } from './aggregateCurrentUsage';
|
||||
import { classifyRow, type RowState } from './classifyRow';
|
||||
import { updateAvailableBadge, updateAvailableLabel } from '@/lib/updateAvailableLabel';
|
||||
import { updateAvailableLabel } from '@/lib/updateAvailableLabel';
|
||||
|
||||
interface StackHealthTableProps {
|
||||
stackStatuses: Record<string, StackStatusEntry>;
|
||||
@@ -271,7 +271,9 @@ export function StackHealthTable({
|
||||
<span />
|
||||
</div>
|
||||
<ul className="divide-y divide-border/40">
|
||||
{pagedRows.map((row) => (
|
||||
{pagedRows.map((row) => {
|
||||
const updateLabel = row.hasUpdate ? updateAvailableLabel(row.outdatedServices) : null;
|
||||
return (
|
||||
<li
|
||||
key={row.file}
|
||||
role="button"
|
||||
@@ -287,12 +289,13 @@ export function StackHealthTable({
|
||||
>
|
||||
<span className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="min-w-0 truncate font-mono text-sm text-stat-value">{row.name}</span>
|
||||
{row.hasUpdate && (
|
||||
<span
|
||||
className="shrink-0 rounded-full bg-brand/15 px-2 py-0.5 font-mono text-[10px] leading-none text-brand tracking-wide"
|
||||
title={updateAvailableLabel(row.outdatedServices)}
|
||||
>
|
||||
{updateAvailableBadge(row.outdatedServices)}
|
||||
{updateLabel && (
|
||||
<span className="shrink-0" title={updateLabel}>
|
||||
<CircleArrowUp
|
||||
className="h-3.5 w-3.5 text-brand"
|
||||
strokeWidth={2}
|
||||
aria-label={updateLabel}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -329,7 +332,8 @@ export function StackHealthTable({
|
||||
</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { StackHealthTable } from '../StackHealthTable';
|
||||
import type { StackStatusEntry } from '../types';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
|
||||
const stackStatuses: Record<string, StackStatusEntry> = {
|
||||
'app.yml': { status: 'running', source: 'local' },
|
||||
};
|
||||
|
||||
function renderTable(stackUpdates: Record<string, StackUpdateInfo>) {
|
||||
return render(
|
||||
<StackHealthTable
|
||||
stackStatuses={stackStatuses}
|
||||
stackStatusesLoadStatus="success"
|
||||
stackStatusesLoadError={null}
|
||||
metrics={[]}
|
||||
stackCpuSeries={{}}
|
||||
onNavigateToStack={vi.fn()}
|
||||
stackUpdates={stackUpdates}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('StackHealthTable update-available icon', () => {
|
||||
it('shows the icon with an accessible name naming the outdated service', () => {
|
||||
renderTable({
|
||||
'app.yml': { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 0, services: [{ service: 'api', image: null, hasUpdate: true, checkStatus: 'ok', lastError: null }] },
|
||||
});
|
||||
const icon = screen.getByTitle('Update available: api').querySelector('svg');
|
||||
expect(icon).not.toBeNull();
|
||||
expect(icon).toHaveAttribute('aria-label', 'Update available: api');
|
||||
});
|
||||
|
||||
it('renders no icon when no update is available', () => {
|
||||
renderTable({
|
||||
'app.yml': { hasUpdate: false, checkStatus: 'ok', lastError: null, checkedAt: 0 },
|
||||
});
|
||||
expect(screen.queryByTitle(/Update available/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
@@ -23,6 +23,8 @@ import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { MultiSelectCombobox, type MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import {
|
||||
layoutDependencyGraph,
|
||||
DEP_NODE_DIMS,
|
||||
@@ -62,6 +64,15 @@ const FLAG_META: { kind: DepFlagKind; label: string; severity: 'warning' | 'dest
|
||||
|
||||
const DESTRUCTIVE_FLAGS = new Set<DepFlagKind>(['missing-dependency', 'port-conflict']);
|
||||
|
||||
function renderNodeOption(option: MultiSelectOption) {
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Server className="h-3 w-3 text-muted-foreground" strokeWidth={2} />
|
||||
{option.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function worstSeverity(flags: DepFlagKind[]): 'destructive' | 'warning' | null {
|
||||
if (flags.length === 0) return null;
|
||||
return flags.some((f) => DESTRUCTIVE_FLAGS.has(f)) ? 'destructive' : 'warning';
|
||||
@@ -169,6 +180,17 @@ export function DependencyMapTab() {
|
||||
const [flagFilter, setFlagFilter] = useState<Set<DepFlagKind>>(new Set());
|
||||
const [expandedStacks, setExpandedStacks] = useState<Set<string>>(new Set());
|
||||
|
||||
// Collapsed by default to a single icon button; expands to the full input on
|
||||
// click and collapses again on blur once the query is cleared. An active
|
||||
// query keeps it open so the filter stays visible and editable.
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchOpen = searchExpanded || search !== '';
|
||||
|
||||
useEffect(() => {
|
||||
if (searchExpanded) searchInputRef.current?.focus();
|
||||
}, [searchExpanded]);
|
||||
|
||||
const fetchMap = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -329,11 +351,15 @@ export function DependencyMapTab() {
|
||||
if (next.has(kind)) next.delete(kind); else next.add(kind);
|
||||
return next;
|
||||
});
|
||||
const toggleNode = (id: number) => setNodeFilter((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const nodeOptions = useMemo(
|
||||
() => presentNodeIds.map((id) => ({ value: String(id), label: nodeNameById.get(id) ?? `Node ${id}` })),
|
||||
[presentNodeIds, nodeNameById],
|
||||
);
|
||||
const selectedNodeValues = useMemo(() => new Set([...nodeFilter].map(String)), [nodeFilter]);
|
||||
const handleNodeSelectionChange = useCallback((selected: Set<string>) => {
|
||||
setNodeFilter(new Set([...selected].map(Number)));
|
||||
}, []);
|
||||
|
||||
const listRows = useMemo(() => {
|
||||
if (!data) return [];
|
||||
@@ -362,15 +388,36 @@ export function DependencyMapTab() {
|
||||
<div className="space-y-3">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search stacks, services, resources…"
|
||||
className="h-8 w-64 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{searchOpen ? (
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder="Search stacks, services, resources…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onBlur={() => { if (search === '') setSearchExpanded(false); }}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0 shrink-0"
|
||||
onClick={() => setSearchExpanded(true)}
|
||||
aria-label="Search stacks, services, resources"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Search stacks, services, resources</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<SegmentedControl
|
||||
value={view}
|
||||
onChange={setView}
|
||||
@@ -380,13 +427,6 @@ export function DependencyMapTab() {
|
||||
{ value: 'list', label: 'List', icon: Layers },
|
||||
]}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => void fetchMap()} disabled={loading} className="gap-2 ml-auto">
|
||||
<RefreshCw className={cn('w-4 h-4', loading && 'animate-spin')} />Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Flag summary strip */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{FLAG_META.map((f) => {
|
||||
const count = flagCounts.get(f.kind) ?? 0;
|
||||
const active = flagFilter.has(f.kind);
|
||||
@@ -409,33 +449,21 @@ export function DependencyMapTab() {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{presentNodeIds.length > 1 && (
|
||||
<MultiSelectCombobox
|
||||
options={nodeOptions}
|
||||
selected={selectedNodeValues}
|
||||
onSelectionChange={handleNodeSelectionChange}
|
||||
placeholder="Nodes"
|
||||
selectedLabel="node"
|
||||
renderOption={renderNodeOption}
|
||||
/>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => void fetchMap()} disabled={loading} className="gap-2 ml-auto">
|
||||
<RefreshCw className={cn('w-4 h-4', loading && 'animate-spin')} />Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Node filter chips (only with more than one node present) */}
|
||||
{presentNodeIds.length > 1 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.22em] text-muted-foreground">Node</span>
|
||||
{presentNodeIds.map((id) => {
|
||||
const active = nodeFilter.has(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => toggleNode(id)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] transition-colors',
|
||||
active ? 'border-brand/60 bg-brand/15 text-brand' : 'border-card-border text-muted-foreground hover:text-foreground hover:bg-muted/40',
|
||||
)}
|
||||
>
|
||||
<Server className="h-3 w-3" strokeWidth={2} />
|
||||
{nodeNameById.get(id) ?? `Node ${id}`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unreachable-node banner */}
|
||||
{data.nodeErrors.length > 0 && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-warning">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Eye, Search, Trash2, ExternalLink, GitBranch } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { filterNetworkRows, rowHasDriftFinding, type NetworkFilter } from '@/lib/networking';
|
||||
import { type NetworkingFinding, type NetworkingFindingKind, type NetworkingNetworkRow, type NetworkingOwnership } from '@/types/networking';
|
||||
@@ -207,7 +208,7 @@ export function NetworkInventoryTable({
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<div className="max-h-[62vh] overflow-auto">
|
||||
<ScrollArea className="h-[62vh] max-md:h-auto">
|
||||
<TooltipProvider>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -329,7 +330,7 @@ export function NetworkInventoryTable({
|
||||
)}
|
||||
</Table>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import type { useAuth } from '@/context/AuthContext';
|
||||
import { isNetworkingActionVisible } from '@/lib/networking';
|
||||
import {
|
||||
@@ -48,7 +49,7 @@ export function NetworkingFindingsList({
|
||||
{FINDING_GROUP_LABELS[group]} · {items.length}
|
||||
</p>
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<div className="max-h-[62vh] overflow-auto">
|
||||
<ScrollArea className="h-[62vh] max-md:h-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
@@ -102,7 +103,7 @@ export function NetworkingFindingsList({
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -111,8 +111,6 @@ export function NetworkingTopologyPanel({
|
||||
ariaLabel="Network ownership"
|
||||
options={OWNERSHIP_OPTIONS.map(({ key, label }) => ({ value: key, label }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterChip active={includeSystem} onClick={() => setIncludeSystem(!includeSystem)}>
|
||||
Include system
|
||||
</FilterChip>
|
||||
|
||||
@@ -231,7 +231,7 @@ export function HistoryTab({ onInspect }: HistoryTabProps) {
|
||||
<HistoryStateMessage loading={loading} error={error} search={search} isEmpty={sorted.length === 0} />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea block className="max-h-[60vh]">
|
||||
<ScrollArea block className="h-[60vh]">
|
||||
<Table className="max-md:min-w-[720px]">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
|
||||
@@ -248,7 +248,7 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<ScrollArea className="max-h-[62vh]">
|
||||
<ScrollArea className="h-[62vh]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
|
||||
@@ -459,6 +459,14 @@ export function AppearanceSection({
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Navigation" kicker="this browser">
|
||||
{topNavMode === 'classic' ? (
|
||||
<SettingsCallout
|
||||
tone="warn"
|
||||
icon={<Info className="h-4 w-4" strokeWidth={1.5} />}
|
||||
title="Classic bar retiring"
|
||||
subtitle="Classic bar will be removed soon. Your preference is kept until then."
|
||||
/>
|
||||
) : null}
|
||||
<SettingsField
|
||||
label="Navigation style"
|
||||
helper="Smart bar is the recommended default: primary destinations stay visible, and the rest live under More. Classic keeps the full horizontal strip. Compact launcher puts destinations in a menu with optional quick links."
|
||||
@@ -497,7 +505,7 @@ export function AppearanceSection({
|
||||
{topNavMode === 'compact' && (
|
||||
<SettingsField
|
||||
label="Quick links"
|
||||
helper="Up to five pinned destinations on the top bar. Defaults are a starting set; add reachable destinations here or with the trailing + on the Compact bar."
|
||||
helper="Up to seven pinned destinations on the top bar. Defaults are a starting set; add reachable destinations here or with the trailing + on the Compact bar."
|
||||
>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{quickLinkIds.length === 0 ? (
|
||||
|
||||
@@ -132,7 +132,7 @@ export function DockerStorageSection({ onDirtyChange }: DockerStorageSectionProp
|
||||
</SettingsField>
|
||||
<SettingsField
|
||||
label="Show reclaimable-space banner"
|
||||
helper="Show the reclaimable-space banner at the top of the Resource Hub when this node has unused images, stopped containers, or dangling volumes to clear. On by default."
|
||||
helper="Show the reclaimable-space banner at the top of the Resource Hub when this node has unused images, stopped containers, or dangling volumes to clear. Off by default."
|
||||
>
|
||||
<TogglePill
|
||||
checked={settings.reclaim_hero === '1'}
|
||||
|
||||
@@ -163,11 +163,10 @@ export function LicenseSection() {
|
||||
}
|
||||
};
|
||||
|
||||
// Pricing link covers two cases: Community-tier operators evaluating
|
||||
// a paid plan, and expired paid licensees who need a path back. Active
|
||||
// and trial paid licensees already manage their plan through the
|
||||
// billing portal in the Plan section above.
|
||||
const showPricingLink = !isPaid || license?.status === 'expired';
|
||||
// Pricing link is only for expired paid licensees who need a path back
|
||||
// to renew. Community tier gets no upsell; active and trial paid
|
||||
// licensees already manage their plan through the billing portal above.
|
||||
const showPricingLink = license?.status === 'expired';
|
||||
|
||||
const renewsValue = useMemo(() => {
|
||||
if (!license) return null;
|
||||
|
||||
@@ -231,7 +231,7 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
|
||||
)}
|
||||
|
||||
{!loading && rows.length > 0 && (
|
||||
<ScrollArea className="max-h-[420px] pr-2">
|
||||
<ScrollArea className="h-[420px] max-md:h-auto pr-2">
|
||||
<ul className="divide-y divide-glass-border">
|
||||
{pageItems.map((row) => (
|
||||
<li key={row.id} className="py-2.5 flex items-start justify-between gap-3">
|
||||
|
||||
@@ -457,6 +457,28 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Tabs value={notifTab} onValueChange={(v) => setNotifTab(v as ChannelType)} className="w-full">
|
||||
<TabsList className="w-full mb-4 grid grid-cols-4">
|
||||
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
|
||||
<TabsHighlightItem value="discord">
|
||||
<TabsTrigger value="discord">Discord</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="slack">
|
||||
<TabsTrigger value="slack">Slack</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="webhook">
|
||||
<TabsTrigger value="webhook">Webhook</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="apprise">
|
||||
<TabsTrigger value="apprise">Apprise</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
</TabsHighlight>
|
||||
</TabsList>
|
||||
<TabsContent value="discord">{renderAgentTab('discord', 'Discord')}</TabsContent>
|
||||
<TabsContent value="slack">{renderAgentTab('slack', 'Slack')}</TabsContent>
|
||||
<TabsContent value="webhook">{renderAgentTab('webhook', 'Custom Webhook')}</TabsContent>
|
||||
<TabsContent value="apprise">{renderAgentTab('apprise', 'Apprise')}</TabsContent>
|
||||
</Tabs>
|
||||
<fieldset disabled={readOnly} className="min-w-0 border-0 p-0 m-0">
|
||||
<SettingsSection title="Delivery retries" kicker={retriesKicker}>
|
||||
<SettingsField
|
||||
@@ -508,28 +530,6 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp
|
||||
</SettingsActions>
|
||||
</SettingsSection>
|
||||
</fieldset>
|
||||
<Tabs value={notifTab} onValueChange={(v) => setNotifTab(v as ChannelType)} className="w-full">
|
||||
<TabsList className="w-full mb-4 grid grid-cols-4">
|
||||
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
|
||||
<TabsHighlightItem value="discord">
|
||||
<TabsTrigger value="discord">Discord</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="slack">
|
||||
<TabsTrigger value="slack">Slack</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="webhook">
|
||||
<TabsTrigger value="webhook">Webhook</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="apprise">
|
||||
<TabsTrigger value="apprise">Apprise</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
</TabsHighlight>
|
||||
</TabsList>
|
||||
<TabsContent value="discord">{renderAgentTab('discord', 'Discord')}</TabsContent>
|
||||
<TabsContent value="slack">{renderAgentTab('slack', 'Slack')}</TabsContent>
|
||||
<TabsContent value="webhook">{renderAgentTab('webhook', 'Custom Webhook')}</TabsContent>
|
||||
<TabsContent value="apprise">{renderAgentTab('apprise', 'Apprise')}</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { TierBadge } from '@/components/TierBadge';
|
||||
import { Book, Bug, Mail, ExternalLink, Crown } from 'lucide-react';
|
||||
import { Book, Bug, Mail, ExternalLink } from 'lucide-react';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { SettingsCallout } from './SettingsCallout';
|
||||
import { SettingsPrimaryButton } from './SettingsActions';
|
||||
|
||||
function DiscordIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface ResourceLinkProps {
|
||||
icon: React.ReactNode;
|
||||
@@ -52,6 +58,12 @@ export function SupportSection() {
|
||||
blurb="Report bugs and request features"
|
||||
href="https://github.com/studio-saelix/sencho/issues"
|
||||
/>
|
||||
<ResourceLink
|
||||
icon={<DiscordIcon className="w-4 h-4" />}
|
||||
title="Discord"
|
||||
blurb="Chat with the community and the team"
|
||||
href="https://discord.gg/rvXAszRGSc"
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -71,22 +83,6 @@ export function SupportSection() {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{!isPaid && (
|
||||
<SettingsCallout
|
||||
icon={<Crown className="h-4 w-4" />}
|
||||
title="Need direct support?"
|
||||
subtitle="Admiral includes priority email support during published support hours."
|
||||
action={
|
||||
<SettingsPrimaryButton
|
||||
size="sm"
|
||||
onClick={() => window.open('https://sencho.io/#pricing', '_blank')}
|
||||
>
|
||||
View plans
|
||||
</SettingsPrimaryButton>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
|
||||
)}
|
||||
|
||||
{!loading && rows.length > 0 && (
|
||||
<ScrollArea className="max-h-[420px] pr-2">
|
||||
<ScrollArea className="h-[420px] max-md:h-auto pr-2">
|
||||
<ul className="divide-y divide-glass-border">
|
||||
{pageItems.map((row) => {
|
||||
const justLabel = triageJustificationLabel(row.justification);
|
||||
|
||||
@@ -186,4 +186,19 @@ describe('AppearanceSection', () => {
|
||||
expect(screen.getByText('Top navigation labels')).toBeTruthy();
|
||||
expect(screen.queryByText('Quick links')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the Classic bar retiring callout only while Classic is selected', () => {
|
||||
localStorage.clear();
|
||||
render(<AppearanceSection />);
|
||||
expect(screen.queryByText('Classic bar retiring')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Classic bar' }));
|
||||
expect(screen.getByText('Classic bar retiring')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('Classic bar will be removed soon. Your preference is kept until then.'),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Compact launcher' }));
|
||||
expect(screen.queryByText('Classic bar retiring')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,3 +111,27 @@ describe('LicenseSection assurance copy', () => {
|
||||
expect(screen.queryByText(/Release Safety|Fleet Beacon|Production Assurance/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LicenseSection pricing link', () => {
|
||||
beforeEach(() => {
|
||||
useLicenseMock.mockReset();
|
||||
});
|
||||
|
||||
it('hides the pricing section on Community tier', () => {
|
||||
mockLicense(baseLicense());
|
||||
render(<LicenseSection />);
|
||||
expect(screen.queryByText('See pricing')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the pricing section for an expired license', () => {
|
||||
mockLicense(
|
||||
baseLicense({
|
||||
tier: 'community',
|
||||
status: 'expired',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
render(<LicenseSection />);
|
||||
expect(screen.getByText('See pricing')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -403,6 +403,11 @@ describe('NotificationsSection', () => {
|
||||
);
|
||||
expect(screen.getByText('Delivery retries')).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
const discordTab = screen.getByRole('tab', { name: 'Discord' });
|
||||
const retriesHeading = screen.getByText('Delivery retries');
|
||||
expect(
|
||||
discordTab.compareDocumentPosition(retriesHeading) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('PATCHes only notification_dispatch_retries when saving retries', async () => {
|
||||
|
||||
@@ -42,7 +42,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
|
||||
scan_history_per_image_limit: '50',
|
||||
prune_orphaned_scans: '1',
|
||||
prune_on_update: '1',
|
||||
reclaim_hero: '1',
|
||||
reclaim_hero: '0',
|
||||
snapshot_documentation: '0',
|
||||
health_gate_enabled: '1',
|
||||
health_gate_window_seconds: '90',
|
||||
|
||||
@@ -157,7 +157,7 @@ export function GitComposeFilePicker({
|
||||
|
||||
{repoFiles && (
|
||||
<div className="rounded-md border border-glass-border">
|
||||
<ScrollArea className="max-h-48">
|
||||
<ScrollArea className="h-48 max-md:h-auto">
|
||||
<div className="p-2 space-y-1">
|
||||
{repoFiles.length === 0 && <p className="text-[11px] text-stat-subtitle px-1 py-2">No files found in the repository.</p>}
|
||||
{repoFiles.map((file) => {
|
||||
|
||||
@@ -343,7 +343,7 @@ export function GitSourcePanel({
|
||||
description="Link this stack to a Git repository so compose updates can be pulled on demand or via webhook."
|
||||
/>
|
||||
|
||||
<ScrollArea className="max-h-[70vh]">
|
||||
<ScrollArea className="h-[70vh] max-md:h-auto">
|
||||
<div className="px-6 py-5 space-y-5">
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -18,6 +18,8 @@ interface MultiSelectComboboxProps {
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
renderOption?: (option: MultiSelectOption, isSelected: boolean) => React.ReactNode
|
||||
/** Noun used in the trigger's selected-count label, e.g. "2 nodes". Defaults to "tag". */
|
||||
selectedLabel?: string
|
||||
}
|
||||
|
||||
export function MultiSelectCombobox({
|
||||
@@ -30,6 +32,7 @@ export function MultiSelectCombobox({
|
||||
disabled = false,
|
||||
className,
|
||||
renderOption,
|
||||
selectedLabel = "tag",
|
||||
}: MultiSelectComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [search, setSearch] = React.useState("")
|
||||
@@ -75,7 +78,7 @@ export function MultiSelectCombobox({
|
||||
}
|
||||
|
||||
const triggerLabel = selected.size > 0
|
||||
? `${selected.size} tag${selected.size !== 1 ? 's' : ''}`
|
||||
? `${selected.size} ${selectedLabel}${selected.size !== 1 ? 's' : ''}`
|
||||
: placeholder
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { recommendedQuickLinkIds } from '@/lib/navigation/appNavRegistry';
|
||||
import type { ActiveView } from '@/lib/router/routeTypes';
|
||||
import {
|
||||
useTopNavQuickLinks,
|
||||
TOP_NAV_QUICK_LINKS_KEY,
|
||||
@@ -9,6 +10,17 @@ import {
|
||||
MAX_QUICK_LINKS,
|
||||
} from '../use-top-nav-quick-links';
|
||||
|
||||
/** A full pin list: eligible IDs, spelled out so the cap value stays pinned independently. */
|
||||
const maxedPins: ActiveView[] = [
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'security',
|
||||
'resources',
|
||||
'networking',
|
||||
'templates',
|
||||
'global-observability',
|
||||
];
|
||||
|
||||
describe('useTopNavQuickLinks', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => localStorage.clear());
|
||||
@@ -31,7 +43,7 @@ describe('useTopNavQuickLinks', () => {
|
||||
expect(result.current.persistedIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('sanitizes unknown, ineligible, and duplicate IDs and caps at five', () => {
|
||||
it('sanitizes unknown, ineligible, and duplicate IDs and caps at seven', () => {
|
||||
expect(
|
||||
sanitizeQuickLinkIds([
|
||||
'dashboard',
|
||||
@@ -43,18 +55,12 @@ describe('useTopNavQuickLinks', () => {
|
||||
'resources',
|
||||
'networking',
|
||||
'templates',
|
||||
'global-observability',
|
||||
'auto-updates',
|
||||
'scheduled-ops',
|
||||
]),
|
||||
).toEqual(['dashboard', 'fleet', 'security', 'resources', 'networking']);
|
||||
expect(
|
||||
sanitizeQuickLinkIds([
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'security',
|
||||
'resources',
|
||||
'networking',
|
||||
'templates',
|
||||
]).length,
|
||||
).toBe(MAX_QUICK_LINKS);
|
||||
).toEqual(maxedPins);
|
||||
expect(sanitizeQuickLinkIds([...maxedPins, 'auto-updates']).length).toBe(MAX_QUICK_LINKS);
|
||||
});
|
||||
|
||||
it('reset writes recommendedQuickLinkIds', () => {
|
||||
@@ -78,23 +84,11 @@ describe('useTopNavQuickLinks', () => {
|
||||
expect(JSON.parse(localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY)!)).toEqual([]);
|
||||
});
|
||||
|
||||
it('add refuses beyond the persisted max of five', () => {
|
||||
it('add refuses beyond the persisted max of seven', () => {
|
||||
const { result } = renderHook(() => useTopNavQuickLinks());
|
||||
act(() => result.current.setPersistedIds([
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'security',
|
||||
'resources',
|
||||
'networking',
|
||||
]));
|
||||
act(() => result.current.addQuickLink('templates'));
|
||||
expect(result.current.persistedIds).toEqual([
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'security',
|
||||
'resources',
|
||||
'networking',
|
||||
]);
|
||||
act(() => result.current.setPersistedIds(maxedPins));
|
||||
act(() => result.current.addQuickLink('auto-updates'));
|
||||
expect(result.current.persistedIds).toEqual(maxedPins);
|
||||
});
|
||||
|
||||
it('syncs a second hook in the same tab', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ActiveView } from '@/lib/router/routeTypes';
|
||||
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
|
||||
export const TOP_NAV_QUICK_LINKS_KEY = 'sencho.appearance.topNavQuickLinks';
|
||||
export const MAX_QUICK_LINKS = 5;
|
||||
export const MAX_QUICK_LINKS = 7;
|
||||
|
||||
/**
|
||||
* Sanitize a candidate ID list: keep registry-known eligible IDs, dedupe,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { updateAvailableBadge, updateAvailableLabel } from '@/lib/updateAvailableLabel';
|
||||
import { updateAvailableLabel } from '@/lib/updateAvailableLabel';
|
||||
|
||||
describe('updateAvailableLabel', () => {
|
||||
it('uses the generic label when no services are named', () => {
|
||||
@@ -16,10 +16,3 @@ describe('updateAvailableLabel', () => {
|
||||
expect(updateAvailableLabel(['a', 'b', 'c', 'd'])).toBe('Update available: 4 services');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAvailableBadge', () => {
|
||||
it('stays compact for the Stack Health column', () => {
|
||||
expect(updateAvailableBadge(['api'])).toBe('Update: api');
|
||||
expect(updateAvailableBadge(['api', 'db'])).toBe('2 updates');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Tooltip / badge copy naming outdated services when the breakdown is known. */
|
||||
/** Tooltip copy naming outdated services when the breakdown is known. */
|
||||
export function updateAvailableLabel(outdatedServices?: string[]): string {
|
||||
const names = (outdatedServices ?? []).filter(Boolean);
|
||||
if (names.length === 0) return 'Update available';
|
||||
@@ -6,11 +6,3 @@ export function updateAvailableLabel(outdatedServices?: string[]): string {
|
||||
if (names.length <= 3) return `Update available: ${names.join(', ')}`;
|
||||
return `Update available: ${names.length} services`;
|
||||
}
|
||||
|
||||
/** Compact Stack Health badge; full detail stays in the title attribute. */
|
||||
export function updateAvailableBadge(outdatedServices?: string[]): string {
|
||||
const names = (outdatedServices ?? []).filter(Boolean);
|
||||
if (names.length === 0) return 'Update available';
|
||||
if (names.length === 1) return `Update: ${names[0]}`;
|
||||
return `${names.length} updates`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user