feat(settings): reorganize the settings hub into domain groups (#1321)

* refactor(settings): split System Limits and regroup the hub

System Limits had grown into a grab-bag of host alert thresholds, Docker
cleanup, and mesh data-plane controls under one mislabeled section. Split it
into Host Alerts, Docker & Storage, and Fleet Mesh, and split Developer into
Developer Diagnostics and Data Retention. Reorganize the sidebar into ten
domain groups: Personal, Access, Infrastructure, Monitoring, Notifications,
Automation, Organization, Security, Operations, Help.

Each section now saves only its own keys, so a concurrent edit in one section
no longer clobbers another. Data Retention sends the audit-log window only on
a paid plan, matching the field's existing visibility, so a Community save no
longer fails on a key the operator cannot set. NumberChip moves to a shared
module and the toggle reuses the existing shared component. The /settings API
is unchanged.

* test(settings): cover registry structure and per-section save payloads

Add structural invariants for the ten-group registry (every item maps to a
real group, ids are unique, the System Limits and Developer splits land in the
right groups with the right gates, renamed labels and the Registries paid gate
hold) and per-section payload tests asserting each split section patches only
its own keys, including the Community path where Data Retention omits the paid
audit-log key.

* docs(settings): document the regrouped settings hub

Rewrite the settings reference for the ten-group layout, replace the System
Limits page with Host Alerts, Docker & Storage, and Fleet Mesh, and document
the prune-on-update, reclaimable-space banner, and mesh auto-recreate settings
that were previously undocumented. Update the Settings navigation breadcrumbs
across the feature docs and refresh the affected screenshots.

* fix(settings): show Access sections as instance-global, not operator-scoped

License, Users, SSO, and API Tokens are instance-global settings but the
masthead scope label rendered them as operator-scoped because it keyed off the
old Identity group. Only Personal sections (account, appearance) are
operator/browser-scoped now; everything else reads as global.

Also add a compile-time exhaustiveness guard to the section switch so a future
SectionId added without a matching case fails the build instead of silently
rendering a blank panel.

* docs(settings): remap remaining settings breadcrumbs to the new groups

Update the navigation breadcrumbs that still pointed at the removed Identity,
Alerts, and Advanced groups: API Tokens and Users now sit under Access, Webhooks
under Automation, Labels under Organization, App Store under Infrastructure,
Appearance under Personal, and scan policies under Security > Vulnerability
Scanning. Correct the settings reference scope note so Access reads as global.

* docs(settings): remap renamed-section breadcrumbs across feature docs

Sweep every feature, operations, getting-started, and reference page for
navigation paths that still named the renamed settings sections, and point them
at the current ones: Security becomes Security > Vulnerability Scanning,
Notifications becomes Notifications > Channels, Routing becomes Notifications >
Notification Routing, and Developer becomes Operations > Developer Diagnostics
(with its retention windows under Operations > Data Retention). App Store moves
under Infrastructure and the four-group overview in the getting-started intro is
rewritten to the ten groups. Separators each page already used are preserved.
This commit is contained in:
Anso
2026-06-05 23:01:37 -04:00
committed by GitHub
parent f7f3afe05a
commit ce08a593d7
53 changed files with 1404 additions and 689 deletions
+13 -13
View File
@@ -6,12 +6,12 @@ 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 one of three external channels you configure. This page covers everything from configuring channels to writing per-stack 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 panel showing the Discord, Slack, and Webhook tabs with the masthead breadcrumb, the CHANNELS 3/3 stat, the active Discord tab with its Enabled toggle on, the Webhook URL input, and the Test and Save actions." />
<img src="/images/alerts-notifications/notifications-settings.png" alt="Settings · Notifications · Channels panel showing the Discord, Slack, and Webhook tabs with the masthead breadcrumb, the CHANNELS 3/3 stat, the active Discord tab with its Enabled toggle on, the Webhook URL input, and the Test and Save actions." />
</Frame>
## Notification channels
Open **Settings · Notifications** to configure the three channel types. 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 three slots are enabled.
Open **Settings · Notifications · Channels** to configure the three channel types. 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 three slots are enabled.
Each tab carries the same controls: an **Enabled** toggle (helper: `Send Sencho events to this <name> channel.`), a **Webhook URL** input (placeholder `https://...`, helper: `Sencho posts JSON payloads here. Use a private channel.`), and the **Test** and **Save** buttons. The kicker on each tab toggles between `enabled` and `off` so you can see at a glance which slots are wired up.
@@ -57,7 +57,7 @@ Each dispatch is a single-shot HTTP POST with a 10-second `AbortSignal.timeout`.
Routing lets you direct alerts that match specific criteria to dedicated channels. Production crashes can land in `#prod-incidents` on Slack while staging notifications go to a less urgent Discord channel, all without juggling per-channel webhook URLs across teams.
<Frame>
<img src="/images/alerts-notifications/routing-list.png" alt="Settings · Routing card list showing two rules ('Critical to Slack' and 'Production alerts'), each with a Discord badge, an ON pill, the 'Matches all alerts' summary line, a truncated channel URL, a Priority chip, and lightning-test, edit, and delete icon actions on the right." />
<img src="/images/alerts-notifications/routing-list.png" alt="Settings · Notifications · Notification Routing card list showing two rules ('Critical to Slack' and 'Production alerts'), each with a Discord badge, an ON pill, the 'Matches all alerts' summary line, a truncated channel URL, a Priority chip, and lightning-test, edit, and delete icon actions on the right." />
</Frame>
### How routing fits into dispatch
@@ -70,7 +70,7 @@ A route with all four matchers left empty matches every alert and intercepts glo
### Creating a routing rule
Open **Settings · Routing** and click **+ Add Route**.
Open **Settings · Notifications · Notification Routing** and click **+ Add Route**.
<Frame>
<img src="/images/alerts-notifications/routing-modal.png" alt="The 'New routing rule' modal with the 'ROUTING · NEW RULE' kicker, a Name input filled with 'Production alerts', a Node scope select reading 'Any node', empty Stacks, Labels, and Categories combobox pickers, the helper line 'Leave blank to match all categories. All non-empty filters must match (AND).', a Channel tab strip with Discord selected and a webhook URL filled in, and Priority and Enabled fields with Cancel and CREATE actions in the footer." />
@@ -164,7 +164,7 @@ The **NOTIFICATION CHANNELS** banner above the rules list reflects what dispatch
- **Loading** is a spinner with `Checking notification channels...` while Sencho asks the target node for its agent state.
- **Remote node** is a blue banner reading `Remote node: <name>`, with the body `Alert rules are stored and evaluated on this remote instance. Notifications are dispatched using that node's configured channels.` A sub-line reports whether the remote has any channels configured.
- **No channels** is an amber banner reading `No notification channels configured`, body `Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, or a webhook in Settings → Notifications.`
- **No channels** is an amber banner reading `No notification channels configured`, body `Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, or a webhook in Settings → Notifications → Channels.`
- **Active** is a green banner reading `Notifications active via Discord, Slack, …` with the configured channels listed.
<Frame>
@@ -286,7 +286,7 @@ Real-time on the Docker event stream:
Each metric (CPU, RAM, disk) carries its own suppression window. The first time a metric crosses its threshold, one notification fires immediately. While the metric stays over threshold within the window, additional cycles are silently counted but not dispatched. The next dispatch after the window elapses carries a summary suffix: `Suppressed 119 alerts in the last 60m; first over threshold at 14:23 UTC.` When the metric drops below threshold, the counter resets so the next breach fires fresh.
The suppression window defaults to 60 minutes and is configured per node in **Settings → System → Host thresholds → Alert suppression**.
The suppression window defaults to 60 minutes and is configured per node in **Settings → Monitoring → Host Alerts → Alert suppression**.
### Docker janitor
@@ -347,10 +347,10 @@ See [Blueprints](/features/blueprint-model).
## Retention and limits
Three retention controls live under **Settings · Developer · Data retention**.
Three retention controls live under **Settings · Operations · Data Retention**.
<Frame>
<img src="/images/alerts-notifications/developer-retention.png" alt="Settings · Developer · Data retention card showing three rows: Container metrics with a 24 HRS field, Notification log with a 30 DAYS field, and Audit log (Admiral) with a 90 DAYS field, plus a SAVE SETTINGS button." />
<img src="/images/alerts-notifications/developer-retention.png" alt="Settings · Operations · Data Retention card showing three rows: Container metrics with a 24 HRS field, Notification log with a 30 DAYS field, and Audit log (Admiral) with a 90 DAYS field, plus a SAVE SETTINGS button." />
</Frame>
| Control | Range | Default | What it prunes |
@@ -365,15 +365,15 @@ A separate rate limit applies to crash and health alerts only: 20 emits per 60-s
## Crash detection toggle
The global crash-capture switch lives under **Settings · System · Docker hygiene**.
The global crash-capture switch lives under **Settings · Monitoring · Host Alerts**.
<Frame>
<img src="/images/alerts-notifications/system-crash-toggle.png" alt="Settings · System · System Limits panel showing a HOST THRESHOLDS section with CPU limit, RAM limit, and Disk limit at 100%, then a DOCKER HYGIENE section with a Janitor threshold of 5 GiB and a Global crash capture toggle in the ON state with the helper line 'Watch every managed container for unexpected exits.'" />
<img src="/images/alerts-notifications/system-crash-toggle.png" alt="Settings · Monitoring · Host Alerts panel showing a HOST THRESHOLDS section with CPU limit, RAM limit, and Disk limit, then a CRASH CAPTURE section with a Global crash capture toggle in the ON state with the helper line 'Watch every managed container for unexpected exits.'" />
</Frame>
The **Global crash capture** toggle controls whether `DockerEventService` raises crash, OOM, and healthcheck alerts on the active node. Helper text: `Watch every managed container for unexpected exits.` Defaults to on; if the database read fails, Sencho falls back to default-deny so the system never leaks alerts you cannot turn off.
The same panel carries the **Host thresholds** rows (CPU limit, RAM limit, Disk limit, all expressed as percent) that drive the host-level monitor warnings, and the **Janitor threshold** (in GiB) that drives the unused-Docker-data alert.
The **Host Alerts** panel also carries the **Host thresholds** rows (CPU limit, RAM limit, Disk limit, all expressed as percent) that drive the host-level monitor warnings. The **Janitor threshold** (in GiB) that drives the unused-Docker-data alert lives in the **Docker & Storage** panel.
## Refresh cadence
@@ -396,7 +396,7 @@ Switching the active node tears down per-stack rule editors and reloads channel
<AccordionGroup>
<Accordion title="Notifications never arrive in Discord, Slack, or my webhook">
Check three things in order. First, the channel toggle in **Settings · Notifications** must be on; the kicker on each tab reads `enabled` or `off`. Second, the URL must use HTTPS; the form rejects plain `http://` outright. Third, a routing rule with empty `Stacks`, `Labels`, and `Categories` matchers will intercept every alert and skip the global channels. Use the per-channel **Test** button to issue a one-shot dispatch and watch your endpoint for the literal message `🔌 Test Notification from Sencho!` Sencho records the failure reason in `notification_history.dispatch_error` when delivery throws, so a row that appears in the bell with no follow-up at the endpoint usually means a 4xx or timeout at the receiver.
Check three things in order. First, the channel toggle in **Settings · Notifications · Channels** must be on; the kicker on each tab reads `enabled` or `off`. Second, the URL must use HTTPS; the form rejects plain `http://` outright. Third, a routing rule with empty `Stacks`, `Labels`, and `Categories` matchers will intercept every alert and skip the global channels. Use the per-channel **Test** button to issue a one-shot dispatch and watch your endpoint for the literal message `🔌 Test Notification from Sencho!` Sencho records the failure reason in `notification_history.dispatch_error` when delivery throws, so a row that appears in the bell with no follow-up at the endpoint usually means a 4xx or timeout at the receiver.
</Accordion>
<Accordion title="An alert rule never fires even when the threshold is breached">
Three causes account for almost every case. First, the rule's **Duration** has not elapsed yet: the breach must persist for the full duration before the rule fires. Second, the rule is still in cooldown after a previous fire. Third, the panel's banner is not green: a remote-node banner means the rule was saved on a remote whose channels you may not have configured, and an amber `No notification channels configured` banner means the rule evaluates fine but Sencho has nowhere to send the alert. The evaluator runs on a 30-second tick, so expect up to 30 seconds of latency between the breach starting and the timer engaging.
@@ -408,7 +408,7 @@ Switching the active node tears down per-stack rule editors and reloads channel
When the daemon disconnects and reconnects, Sencho snapshots every container at the moment of reconnect. If at least 20% exited during the gap, the service emits a single `info`/`system` summary `Docker daemon interruption detected: N containers exited during connection gap.` instead of one crash alert per container. Below the threshold, every gap exit is classified individually.
</Accordion>
<Accordion title="Crash alerts stopped arriving and nothing else looks wrong">
Check **Settings · System · Docker hygiene · Global crash capture**. The toggle is the master switch for the Docker event service. The panel cache reads the database every 500 ms; if the database read errors, Sencho defaults the toggle to off so the failure mode is silent rather than spammy. Repair the toggle, save, and the next Docker event reaches the dispatcher.
Check **Settings · Monitoring · Host Alerts · Global crash capture**. The toggle is the master switch for the Docker event service. The panel cache reads the database every 500 ms; if the database read errors, Sencho defaults the toggle to off so the failure mode is silent rather than spammy. Repair the toggle, save, and the next Docker event reaches the dispatcher.
</Accordion>
<Accordion title="A burst of crashes happened but I only see about twenty alerts">
Sencho rate-limits crash and health dispatches to 20 emits per rolling 60-second window per node, then emits a single `warning`/`monitor_alert` roll-up `N additional containers crashed in the last minute.` once the window closes. Every alert is still persisted to `notification_history` and visible in the bell up to the 100-row per-node cap; only the channel fanout is throttled.
+5 -5
View File
@@ -52,13 +52,13 @@ Finally, scope-enforced API tokens compose with the fleet. When a request carrie
## Creating a token
<Frame caption="Settings → Identity → API Tokens. Empty state, with the Create token affordance.">
<Frame caption="Settings → Access → API Tokens. Empty state, with the Create token affordance.">
<img src="/images/api-tokens/api-tokens-empty-state.png" alt="API Tokens settings tab, empty state" />
</Frame>
<Steps>
<Step title="Open Settings → Identity → API Tokens">
Click your avatar, then **Settings**. The tab lives in the **Identity** group of the Settings sidebar.
<Step title="Open Settings → Access → API Tokens">
Click your avatar, then **Settings**. The tab lives in the **Access** group of the Settings sidebar.
</Step>
<Step title="Click Create token">
The inline form expands.
@@ -212,7 +212,7 @@ Net behaviour: one API token on the hub is a fleet-wide credential whose authori
The Settings → API Tokens tab lists every active token you own as a card.
<Frame caption="Settings → Identity → API Tokens. Each card carries the token name, a scope badge (Read Only · Deploy Only · Full Admin), the created date, last-used relative time, and the expiration date when one is set.">
<Frame caption="Settings → Access → API Tokens. Each card carries the token name, a scope badge (Read Only · Deploy Only · Full Admin), the created date, last-used relative time, and the expiration date when one is set.">
<img src="/images/api-tokens/api-tokens-populated.png" alt="API Tokens settings tab listing three tokens, one of each scope" />
</Frame>
@@ -358,7 +358,7 @@ Read Only is sufficient for the notification stream.
</Accordion>
<Accordion title="The API Tokens tab is missing from Settings">
The tab is admin-only. Sign in as an admin user to see it. Tokens are also issued from the hub; if you have selected a remote node in the node switcher, switch back to the local hub and the tab will appear under **Settings → Identity**.
The tab is admin-only. Sign in as an admin user to see it. Tokens are also issued from the hub; if you have selected a remote node in the node switcher, switch back to the local hub and the tab will appear under **Settings → Access**.
</Accordion>
<Accordion title="The token list shows `Couldn't load API tokens`">
+3 -3
View File
@@ -128,10 +128,10 @@ Sencho can stream the live `docker compose` output for every template install. O
By default Sencho uses LinuxServer.io's hosted catalogue. To point at your own:
<Frame>
<img src="/images/app-store/app-store-settings-registry.png" alt="Settings · Advanced · App Store with two panels: Default registry showing LinuxServer.io and the api.linuxserver.io/api/v1/images URL, and Custom registry with a Registry URL input, a 'using default' hint, and Reset to default and Save & refresh buttons" />
<img src="/images/app-store/app-store-settings-registry.png" alt="Settings · Infrastructure · App Store with two panels: Default registry showing LinuxServer.io and the api.linuxserver.io/api/v1/images URL, and Custom registry with a Registry URL input, a 'using default' hint, and Reset to default and Save & refresh buttons" />
</Frame>
1. Open **Settings Advanced App Store**.
1. Open **Settings Infrastructure App Store**.
2. Type your catalogue URL into **Registry URL**. The URL must start with `http://` or `https://` and serve a JSON array of template objects in the [Portainer v2 template format](https://documentation.portainer.io/v2.0-be/api/templates/).
3. Click **Save & refresh**. Sencho persists the new URL and immediately invalidates the template cache so the App Store reloads from the new source.
@@ -143,7 +143,7 @@ The default registry display at the top of the page is informational only; you c
<AccordionGroup>
<Accordion title="No tiles appear">
Sencho fetches templates from the configured registry on demand. If the grid stays empty and the loading spinner is gone, the registry returned an empty list or the request failed before parsing. Open **Settings Advanced App Store** and confirm the **Registry URL** is reachable from the node (try the URL in a browser tab); a custom catalogue served from a private host will not load if the Sencho container cannot resolve or reach it. Click **Save & refresh** to invalidate the cache and try again. If you switched to a custom registry by mistake, click **Reset to default**. Sencho also bounds the registry response size: a catalogue that returns an excessively large payload is rejected with a size-limit error instead of being loaded, so a misconfigured or runaway endpoint cannot stall the node.
Sencho fetches templates from the configured registry on demand. If the grid stays empty and the loading spinner is gone, the registry returned an empty list or the request failed before parsing. Open **Settings Infrastructure App Store** and confirm the **Registry URL** is reachable from the node (try the URL in a browser tab); a custom catalogue served from a private host will not load if the Sencho container cannot resolve or reach it. Click **Save & refresh** to invalidate the cache and try again. If you switched to a custom registry by mistake, click **Reset to default**. Sencho also bounds the registry response size: a catalogue that returns an excessively large payload is rejected with a size-limit error instead of being loaded, so a misconfigured or runaway endpoint cannot stall the node.
</Accordion>
<Accordion title="The port-conflict warning will not clear">
The conflict indicator is computed against a snapshot of the node's bound ports taken when you opened the deployment sheet. If you freed the port from another stack while the sheet was open, the warning does not refresh on its own. Close the sheet and reopen it to recompute, or pick a different host port. The message `in use by an external app` means a process outside Sencho holds the binding (often the host's reverse proxy or a system service); changing the host port is usually faster than tracking down the offender.
+2 -2
View File
@@ -152,7 +152,7 @@ Audit log entries are automatically pruned based on your configured retention pe
To change the retention period:
1. Open **Settings · Developer**
1. Open **Settings · Operations · Developer Diagnostics**
2. In the **Data retention** card, set the **Audit log** value (1 to 365 days)
3. Click **Save settings**
@@ -182,6 +182,6 @@ Sensitive database values (such as remote node API tokens) are encrypted at rest
Each export is capped at 10,000 entries. If your filter selects more than that, narrow the date range using the **From** and **To** pickers and download in chunks. The cap protects the API from generating very large CSVs in a single response; for full archives, schedule periodic exports from your own tooling.
</Accordion>
<Accordion title="Old entries vanished even though I never deleted anything">
Cleanup runs automatically against the **Audit log** retention value in **Settings · Developer · Data retention** (default 90 days). Entries older than the configured window are pruned on the next maintenance tick. Increase the value (up to 365 days) before the next cleanup runs to retain a longer history; the change applies forward only and cannot bring back already-pruned entries.
Cleanup runs automatically against the **Audit log** retention value in **Settings · Operations · Data Retention** (default 90 days). Entries older than the configured window are pruned on the next maintenance tick. Increase the value (up to 365 days) before the next cleanup runs to retain a longer history; the change applies forward only and cannot bring back already-pruned entries.
</Accordion>
</AccordionGroup>
+1 -1
View File
@@ -109,7 +109,7 @@ Auto-Heal dispatches a notification through your configured channels when:
- A restart call fails (warning).
- A policy auto-disables itself (warning).
Each notification includes the stack and container names. Configure delivery channels under **Settings → Notifications**. See [Alerts & Notifications](/features/alerts-notifications) for setup details.
Each notification includes the stack and container names. Configure delivery channels under **Settings → Notifications → Channels**. See [Alerts & Notifications](/features/alerts-notifications) for setup details.
## Dashboard visibility
+2 -2
View File
@@ -65,7 +65,7 @@ Auto-update is opt-in per stack. A stack participates in unattended updates only
Every update pulls a fresh image and recreates the affected containers. Once the new image is in place, the image it replaced becomes a dangling (untagged) layer that otherwise lingers on disk until you prune it by hand.
Sencho clears this for you. Under **Settings > System > Docker hygiene**, the **Prune dangling images after updates** toggle is on by default. When a stack update or a Sencho self-update finishes successfully, Sencho removes the node's dangling (untagged) image layers, including the one the update just orphaned. This is a standard dangling-image prune, so any other untagged layers already on the node are reclaimed in the same pass. Only untagged layers are touched: tagged images (including those for stopped stacks), your volumes, and your data are never removed. Changing the setting requires an admin account.
Sencho clears this for you. Under **Settings > Monitoring > Docker & Storage**, the **Prune dangling images after updates** toggle is on by default. When a stack update or a Sencho self-update finishes successfully, Sencho removes the node's dangling (untagged) image layers, including the one the update just orphaned. This is a standard dangling-image prune, so any other untagged layers already on the node are reclaimed in the same pass. Only untagged layers are touched: tagged images (including those for stopped stacks), your volumes, and your data are never removed. Changing the setting requires an admin account.
The toggle is per node. Each Sencho instance honors its own setting, so you can leave cleanup on for your hub and off for a particular remote, or the reverse. Switch the active node and open its settings to set the value for that node.
@@ -121,7 +121,7 @@ The preview is recomputed each time the readiness board loads, so it reflects th
One or more nodes that are marked online in your fleet did not respond within the request timeout. Pending updates from those nodes are not shown until they come back. Check the node's status from the Fleet view and the network path between this Sencho instance and the unreachable node.
</Accordion>
<Accordion title='An old image I wanted to keep disappeared after an update'>
Automatic cleanup removes only dangling (untagged) image layers, the leftovers a pull orphans. Any image that still carries a tag, including images for stopped stacks, is left in place. If you keep a previous build to roll back to, give it a distinct tag so it is never dangling, or turn off **Prune dangling images after updates** under **Settings > System > Docker hygiene** on that node.
Automatic cleanup removes only dangling (untagged) image layers, the leftovers a pull orphans. Any image that still carries a tag, including images for stopped stacks, is left in place. If you keep a previous build to roll back to, give it a distinct tag so it is never dangling, or turn off **Prune dangling images after updates** under **Settings > Monitoring > Docker & Storage** on that node.
</Accordion>
<Accordion title='Disk usage barely changed after an update'>
Automatic cleanup reclaims dangling (untagged) image layers only. A long-running host accumulates other reclaimable data (stopped containers, unused volumes and networks, build cache) that this toggle does not touch. Run a full prune from the [Resources](/features/resources) view, or schedule a prune task, to reclaim the rest.
+4 -4
View File
@@ -112,10 +112,10 @@ A **Labels** selector matches any node whose labels satisfy the expression. The
A **Specific nodes** selector picks nodes by ID with a checkbox list. Use it when you want a one-off blueprint that runs only on a known node, or when no labels exist yet.
Add labels to nodes from **Settings · System · Nodes**. Each node row carries a Labels column with a `+` button.
Add labels to nodes from **Settings · Infrastructure · Nodes**. Each node row carries a Labels column with a `+` button.
<Frame caption="Settings · System · Nodes. The Labels column carries pills per node; the `+` button opens an inline add-label popover.">
<img src="/images/blueprint-model/node-labels.png" alt="Settings System Nodes page with the Labels column and add-label popover" />
<Frame caption="Settings · Infrastructure · Nodes. The Labels column carries pills per node; the `+` button opens an inline add-label popover.">
<img src="/images/blueprint-model/node-labels.png" alt="Settings Infrastructure Nodes page with the Labels column and add-label popover" />
</Frame>
### Drift policy
@@ -313,7 +313,7 @@ A future Volume Migration feature will automate this with app-aware backup tooli
The reconciler will not auto-deploy a stateful blueprint to a node it has never run on. Click **Confirm deploy** on the row, then choose **Deploy fresh** in the dialog. Sencho will create empty named volumes and start the stack.
</Accordion>
<Accordion title="A deploy was blocked by vulnerability policy">
Open **Settings · Security** and review the active scan policies for the target node. The Blueprint deployment row records the blocking policy and affected image count. Either fix the image, relax the policy, or deploy through an explicit admin-approved bypass on the stack surface. Blueprints do not silently bypass deploy enforcement.
Open **Settings · Security · Vulnerability Scanning** and review the active scan policies for the target node. The Blueprint deployment row records the blocking policy and affected image count. Either fix the image, relax the policy, or deploy through an explicit admin-approved bypass on the stack surface. Blueprints do not silently bypass deploy enforcement.
</Accordion>
<Accordion title="Compose content was rejected on save">
Sencho accepts valid YAML up to 96 KiB. Split very large compose files into smaller blueprints or move generated content out of the Blueprint. Do not paste secrets into the compose body; use environment files or [Fleet Secrets](/features/fleet-secrets) where appropriate.
+5 -5
View File
@@ -21,7 +21,7 @@ Badge counts in the Resources Hub continue to reflect the raw findings. Suppress
## Creating a suppression from Settings
Open **Settings → Security** and scroll to **CVE Suppressions**, then click **Add Suppression**.
Open **Settings → Security → Vulnerability Scanning** and scroll to **CVE Suppressions**, then click **Add Suppression**.
<Frame>
<img src="/images/cve-suppressions/settings-panel.png" alt="Settings Security page with the CVE Suppressions panel listing two accepted CVEs, each showing the CVE ID, an outlined package badge, a clamped reason line, the author and expiry metadata, and a trash icon for removal" />
@@ -43,7 +43,7 @@ The dialog has the following fields:
### Suppressing directly from a scan result
The panel's empty state hints at the faster path: from any vulnerability scan, click the small shield icon at the right edge of a finding's row. The dialog opens pre-filled with the CVE ID and the package name from that row (both read-only in this flow), leaving you to add a Reason, an optional Image pattern, and an optional Expiry. This is the recommended workflow for everyday triage, because it keeps the scope as narrow as the originating finding. To broaden the scope (for example, to suppress across every package), create the rule from **Settings → Security** instead.
The panel's empty state hints at the faster path: from any vulnerability scan, click the small shield icon at the right edge of a finding's row. The dialog opens pre-filled with the CVE ID and the package name from that row (both read-only in this flow), leaving you to add a Reason, an optional Image pattern, and an optional Expiry. This is the recommended workflow for everyday triage, because it keeps the scope as narrow as the originating finding. To broaden the scope (for example, to suppress across every package), create the rule from **Settings → Security → Vulnerability Scanning** instead.
### How specificity is resolved
@@ -71,7 +71,7 @@ The same dim-and-icon treatment carries through to the **Compare** sheet, so a C
Suppressions are managed on the **control** Sencho instance and replicate automatically to every remote you've registered:
- Creating, editing, or removing a suppression on the control pushes the full list to every remote.
- A Sencho instance that has received at least one push from a control is a **replica**. On a replica, **Settings → Security** shows the suppression list read-only, and replicated rows carry a small `replicated` badge so they are easy to tell apart from any locally-created entries.
- A Sencho instance that has received at least one push from a control is a **replica**. On a replica, **Settings → Security → Vulnerability Scanning** shows the suppression list read-only, and replicated rows carry a small `replicated` badge so they are easy to tell apart from any locally-created entries.
- When you're signed into a control and have a **remote node selected** from the node switcher, the CVE Suppressions panel itself is hidden and a "Scanner is per-node" banner explains that scanning runs on the remote while rules live on the control.
The full replication, retry, and reanchor flow (including the API call to re-bind a replica to a new control) is documented in [Fleet Sync](/features/fleet-sync).
@@ -80,7 +80,7 @@ The full replication, retry, and reanchor flow (including the API call to re-bin
By default, suppressions do not affect [block-on-deploy policies](/features/vulnerability-scanning#honoring-suppressions-in-deploy-blocks). A policy evaluates the raw scan result, so a CVE you have suppressed still blocks a deploy that violates the threshold. Suppressions silence the noise; the gate stays strict.
To have an accepted CVE stop counting toward the gate, an admin can enable **Honor suppressions in deploy blocks** in **Settings → Security**. With it on, a block-on-deploy policy re-derives each image's severity from the findings that remain after suppressions are applied, so a deploy whose only blocking findings are all suppressed proceeds without a manual bypass. Sencho records each such suppression-driven pass in the audit log.
To have an accepted CVE stop counting toward the gate, an admin can enable **Honor suppressions in deploy blocks** in **Settings → Security → Vulnerability Scanning**. With it on, a block-on-deploy policy re-derives each image's severity from the findings that remain after suppressions are applied, so a deploy whose only blocking findings are all suppressed proceeds without a manual bypass. Sencho records each such suppression-driven pass in the audit log.
The toggle is off by default and is set per Sencho instance, because the gate runs on whichever instance performs the deploy.
@@ -101,7 +101,7 @@ Suppressed findings carry through to the [SARIF export](/features/vulnerability-
Badge counts reflect the raw findings so they remain meaningful for alerting. Suppressions apply when results are read for display (the scan drawer, the Compare sheet, the SARIF export), not when they are stored. Open the scan drawer to confirm the row is dimmed with a shield-off icon.
</Accordion>
<Accordion title="A suppressed CVE still blocks my deploy">
Block-on-deploy policies evaluate the raw scan result by default, so a suppressed CVE still counts toward the block. If you want accepted CVEs to stop counting, enable **Honor suppressions in deploy blocks** in **Settings → Security** on the instance that runs the deploy. See [Suppressions and deploy blocking](#suppressions-and-deploy-blocking) for the full behavior.
Block-on-deploy policies evaluate the raw scan result by default, so a suppressed CVE still counts toward the block. If you want accepted CVEs to stop counting, enable **Honor suppressions in deploy blocks** in **Settings → Security → Vulnerability Scanning** on the instance that runs the deploy. See [Suppressions and deploy blocking](#suppressions-and-deploy-blocking) for the full behavior.
</Accordion>
<Accordion title="A suppression I added on the control is not visible on a replica">
Replication runs on every write. If the push failed (network blip, replica restart), the control retries every 5 minutes for 24 hours and the replica picks up the latest state on the next successful push. See [Fleet Sync](/features/fleet-sync) for how to investigate persistent push failures.
+2 -2
View File
@@ -169,7 +169,7 @@ A **Clear All Notifications** button is anchored at the bottom right of the card
The list paginates at eight rows; chevrons and an `N / M` indicator appear in the header when there is more than one page.
Alert rules and severity routing are configured per stack in **Settings · Notifications**. See [Alerts and notifications](/features/alerts-notifications) for channel setup, per-stack rules, and the routing engine.
Alert rules and severity routing are configured per stack in **Settings · Notifications · Channels**. See [Alerts and notifications](/features/alerts-notifications) for channel setup, per-stack rules, and the routing engine.
## How health is derived
@@ -226,6 +226,6 @@ When you switch the active node from the node switcher, the dashboard resets eve
Critical can fire on RAM, disk, or the combination of any exited container with an unread error alert, not only on CPU. Read the reasons line right under the meta line; it lists every signal pushing the state up. Common cases: RAM at or above 90% from an oversized service, disk at or above 90% from runaway log retention, or a crashed container that no one has acknowledged in the alerts panel.
</Accordion>
<Accordion title="The dashboard feels sluggish on a large deployment">
Toggle **Developer mode** under **Settings · System**. Both dashboard endpoints then emit a `[Dashboard:debug]` line in the Sencho container logs for every request, reporting elapsed milliseconds and the contextual fields (`nodeId`, row count, `days` window). Use the timings to identify whether the Configuration Status payload or the stack-restarts query is the bottleneck. Disable developer mode when you are done to keep the log volume manageable.
Toggle **Developer mode** under **Settings · Operations · Developer Diagnostics**. Both dashboard endpoints then emit a `[Dashboard:debug]` line in the Sencho container logs for every request, reporting elapsed milliseconds and the contextual fields (`nodeId`, row count, `days` window). Use the timings to identify whether the Configuration Status payload or the stack-restarts query is the bottleneck. Disable developer mode when you are done to keep the log volume manageable.
</Accordion>
</AccordionGroup>
+2 -2
View File
@@ -11,7 +11,7 @@ Deploy enforcement is the pre-flight half of Sencho's vulnerability workflow. Wh
## Configuring a block policy
Policies are managed under **Settings → Advanced → Security → Scan Policies**. The **Add policy** button opens the editor; existing policies appear as a list of cards with `max: <SEVERITY>` and `block` badges, the configured stack-pattern scope, and pencil and trash buttons.
Policies are managed under **Settings → Security → Vulnerability Scanning → Scan Policies**. The **Add policy** button opens the editor; existing policies appear as a list of cards with `max: <SEVERITY>` and `block` badges, the configured stack-pattern scope, and pencil and trash buttons.
<Frame>
<img src="/images/deploy-enforcement/policy-list.png" alt="Scan Policies card showing a configured policy with the name 'Production block on critical', a max: CRITICAL badge, a block badge, and the Scope demo-blocked-* set as the stack pattern" />
@@ -112,7 +112,7 @@ Neither drift mechanism blocks, stops, or quarantines a running stack automatica
<Accordion title="The gate let a deploy through even though I have a block policy">
Check the following in order:
1. Open **Settings → Advanced → Security** on the target node and confirm Trivy is installed. Sencho fails open when Trivy is missing, dispatching a warning alert instead of blocking. [Install Trivy](/operations/trivy-setup) to enforce the policy.
1. Open **Settings → Security → Vulnerability Scanning** on the target node and confirm Trivy is installed. Sencho fails open when Trivy is missing, dispatching a warning alert instead of blocking. [Install Trivy](/operations/trivy-setup) to enforce the policy.
2. Confirm the policy is enabled and the stack pattern matches the stack name. `prod-*` matches `prod-api` but not `production-api`. An empty pattern matches every stack on the node.
3. Check the highest severity in the latest scan for each image. If no image reached the threshold, the gate correctly allowed the deploy.
</Accordion>
+1 -1
View File
@@ -10,7 +10,7 @@ When you trigger a stack action that runs through `docker compose` (Deploy, Upda
The modal is opt-in. Open **Settings > Appearance > Display** and enable **Deploy progress modal**. The setting is **off by default**, saved to the current browser only, and synced across tabs in the same browser without a reload.
<Frame>
<img src="/images/deploy-progress/setting-toggle.png" alt="Settings · Identity · Appearance panel with the Deploy progress modal field toggled to Enabled and the helper text 'Stream live output for deploy, restart, update, install, and Git operations.'" />
<img src="/images/deploy-progress/setting-toggle.png" alt="Settings · Personal · Appearance panel with the Deploy progress modal field toggled to Enabled and the helper text 'Stream live output for deploy, restart, update, install, and Git operations.'" />
</Frame>
## Using the modal
+7 -7
View File
@@ -93,14 +93,14 @@ Admins can delete snapshots from the list view by clicking the trash icon on the
## Cloud Backup
<Note>
Custom S3-compatible storage is available on every tier. Sencho Cloud Backup is an Admiral feature. Configure either in **Settings → System → Cloud Backup**.
Custom S3-compatible storage is available on every tier. Sencho Cloud Backup is an Admiral feature. Configure either in **Settings → Infrastructure → Cloud Backup**.
</Note>
Cloud Backup mirrors every fleet snapshot to off-site storage so your snapshots survive local disk failure. The Cloud Backup settings page (reached via **Settings → System → Cloud Backup**) shows a header with your current scope, provider, storage used, and total snapshot count in the cloud. Two storage modes are supported.
Cloud Backup mirrors every fleet snapshot to off-site storage so your snapshots survive local disk failure. The Cloud Backup settings page (reached via **Settings → Infrastructure → Cloud Backup**) shows a header with your current scope, provider, storage used, and total snapshot count in the cloud. Two storage modes are supported.
### Sencho Cloud Backup (included)
A managed 500 MB allowance backed by Cloudflare R2, included with every Admiral license. Open **Settings → System → Cloud Backup**, choose **Sencho Cloud Backup (Included)**, and click **Activate**. Sencho exchanges your license key for scoped storage credentials and starts replicating new snapshots automatically.
A managed 500 MB allowance backed by Cloudflare R2, included with every Admiral license. Open **Settings → Infrastructure → Cloud Backup**, choose **Sencho Cloud Backup (Included)**, and click **Activate**. Sencho exchanges your license key for scoped storage credentials and starts replicating new snapshots automatically.
Once active, the settings page shows a storage gauge (used / 500 MB and object count), a status message confirming auto-upload is on, and a **Reprovision** button to refresh credentials if needed. You can verify connectivity at any time with the **Test** button.
@@ -133,7 +133,7 @@ To upload a single snapshot on demand, open the **Snapshots** tab in Fleet View.
### Browsing and downloading cloud snapshots
The **Cloud Snapshots** panel in **Settings → System → Cloud Backup** lists every archive currently in your bucket, with size and last-modified timestamp. Click the download icon to save a `.tar.gz` archive locally for off-host disaster recovery. Each archive contains a `metadata.json` describing the snapshot and a `nodes/` tree with the captured compose and environment files, organised by node and stack.
The **Cloud Snapshots** panel in **Settings → Infrastructure → Cloud Backup** lists every archive currently in your bucket, with size and last-modified timestamp. Click the download icon to save a `.tar.gz` archive locally for off-host disaster recovery. Each archive contains a `metadata.json` describing the snapshot and a `nodes/` tree with the captured compose and environment files, organised by node and stack.
### Restoring from a cloud snapshot
@@ -153,7 +153,7 @@ Snapshots are stored in Sencho's SQLite database. Captured file contents, includ
<AccordionGroup>
<Accordion title="A snapshot shows skipped nodes">
If a remote node is offline, unreachable, or its API token has expired, the node is skipped during snapshot creation. The list shows a warning icon with a count of skipped nodes, and the snapshot's detail view names each one along with the reason. Common causes are the remote Sencho instance being stopped or restarting, the node's API URL or token having been changed after it was added, or a firewall or network issue blocking the connection. Verify the remote is running and reachable, update the node's API URL and token in **Settings → System → Nodes** if needed, then create a new snapshot.
If a remote node is offline, unreachable, or its API token has expired, the node is skipped during snapshot creation. The list shows a warning icon with a count of skipped nodes, and the snapshot's detail view names each one along with the reason. Common causes are the remote Sencho instance being stopped or restarting, the node's API URL or token having been changed after it was added, or a firewall or network issue blocking the connection. Verify the remote is running and reachable, update the node's API URL and token in **Settings → Infrastructure → Nodes** if needed, then create a new snapshot.
</Accordion>
<Accordion title="Restore fails with 'Target node no longer exists'">
The node recorded in the snapshot has been removed from the fleet since the snapshot was taken. Snapshots reference nodes by registry ID, so a node that was deleted and re-added is treated as a different target. Re-add the node first, then retry the restore against the freshly registered row.
@@ -168,12 +168,12 @@ Snapshots are stored in Sencho's SQLite database. Captured file contents, includ
Double-check the Access Key ID, Secret Access Key, and bucket name; one wrong character is the most common cause. Some providers require S3-compatible API access to be enabled on the bucket separately from the credentials. For MinIO, confirm the user has read/write permission on the target bucket. After correcting the values, click **Test** again before saving.
</Accordion>
<Accordion title="Cloud uploads fail after hitting the 500 MB quota">
Sencho Cloud Backup carries a 500 MB allowance per license. Once you hit the cap, new uploads fail with a quota error and the storage gauge in the settings page reads at or near 500 MB. Free space by deleting older archives from the **Cloud Snapshots** panel in **Settings → System → Cloud Backup**. Local snapshots are unaffected by cloud deletions, so the in-place restore path stays intact. To raise the ceiling, switch the storage mode to **Custom S3 (BYOB)** and point at a bucket you control.
Sencho Cloud Backup carries a 500 MB allowance per license. Once you hit the cap, new uploads fail with a quota error and the storage gauge in the settings page reads at or near 500 MB. Free space by deleting older archives from the **Cloud Snapshots** panel in **Settings → Infrastructure → Cloud Backup**. Local snapshots are unaffected by cloud deletions, so the in-place restore path stays intact. To raise the ceiling, switch the storage mode to **Custom S3 (BYOB)** and point at a bucket you control.
</Accordion>
<Accordion title="Cloud upload returns a network timeout or 5xx error">
Transient errors surface as a notification and leave the local snapshot in place. Retry by clicking the cloud-upload action on the snapshot row, or wait for the next scheduled snapshot which retries on its own. Persistent failures usually indicate an endpoint outage; verify the storage provider is reachable from your Sencho host (custom S3 endpoints often sit behind a different DNS or firewall path than the rest of your traffic).
</Accordion>
<Accordion title="I need more detail about a snapshot or restore operation">
Enable **Developer Mode** under **Settings → Developer** to activate diagnostic logging for snapshot creation (per-node capture timing and file counts), restore operations, and scheduled snapshot execution. Diagnostic logs appear in the server's standard output with a `:debug` suffix.
Enable **Developer Mode** under **Settings → Operations → Developer Diagnostics** to activate diagnostic logging for snapshot creation (per-node capture timing and file counts), restore operations, and scheduled snapshot execution. Diagnostic logs appear in the server's standard output with a `:debug` suffix.
</Accordion>
</AccordionGroup>
+7 -7
View File
@@ -11,20 +11,20 @@ Fleet Sync replicates three resources today, all over the same channel and with
- **CVE suppressions** ([CVE Suppressions](/features/cve-suppressions)).
- **Misconfig acknowledgements**.
Fleet Sync lives under **Settings → Security** on both the control (where you author rules) and the replica (where you see them, read-only).
Fleet Sync lives under **Settings → Security → Vulnerability Scanning** on both the control (where you author rules) and the replica (where you see them, read-only).
<Frame>
<img src="/images/fleet-sync/control-security.png" alt="Settings → Security on the control. The Vulnerability Scanner card shows Trivy installed with Auto-update Trivy turned on. Below it, the empty 'No scan policies configured' state and the CVE Suppressions section listing two active suppressions against github.com/docker/docker, each annotated 'by admin · expires Never'." />
<img src="/images/fleet-sync/control-security.png" alt="Settings → Security → Vulnerability Scanning on the control. The Vulnerability Scanner card shows Trivy installed with Auto-update Trivy turned on. Below it, the empty 'No scan policies configured' state and the CVE Suppressions section listing two active suppressions against github.com/docker/docker, each annotated 'by admin · expires Never'." />
</Frame>
## Control and replica roles
Every Sencho instance carries a `fleet_role` flag that is either `control` or `replica`. The flag is consulted on every write path for the replicated resources and by the **Settings → Security** UI when it decides whether to render the edit controls.
Every Sencho instance carries a `fleet_role` flag that is either `control` or `replica`. The flag is consulted on every write path for the replicated resources and by the **Settings → Security → Vulnerability Scanning** UI when it decides whether to render the edit controls.
| Role | Behaviour |
|---|---|
| **Control** | The default for any fresh install and for the instance whose **Settings → Nodes** lists the rest of the fleet. Accepts create, edit, and delete on scan policies, CVE suppressions, and misconfig acknowledgements. Pushes the full current state of each resource to every reachable remote on every write. |
| **Replica** | An instance that has received at least one Fleet Sync push. Renders replicated rules as read-only with a "Managed by control node" banner above the policy editor on **Settings → Security**. Returns `403 Forbidden` for any direct write attempt against the replicated tables. |
| **Replica** | An instance that has received at least one Fleet Sync push. Renders replicated rules as read-only with a "Managed by control node" banner above the policy editor on **Settings → Security → Vulnerability Scanning**. Returns `403 Forbidden` for any direct write attempt against the replicated tables. |
The transition from control to replica happens automatically the first time a replica accepts a push: the apply transaction sets `fleet_role = 'replica'` atomically with the row replacement, so the role flip and the new rows land together or not at all. Going the other way is explicit: an admin clicks **Demote to control** on the replica (see [Demote a replica](#demote-a-replica) below).
@@ -38,7 +38,7 @@ What does *not* replicate:
- **Trivy itself.** The scanner binary is installed independently on each instance. The Security panel on a remote shows a "Scanner is per-node" callout in place of the full editor, since the scanner lifecycle is a node concern, not a fleet concern.
<Frame>
<img src="/images/fleet-sync/remote-security-via-proxy.png" alt="Settings → Security on a remote, opened via the control's node switcher. The Vulnerability Scanner card shows 'Not installed' with an Install Trivy button; below it, a 'Scanner is per-node' status callout reads 'Trivy is installed independently on each Sencho instance. Scan policies and CVE suppressions are managed on the control node.'" />
<img src="/images/fleet-sync/remote-security-via-proxy.png" alt="Settings → Security → Vulnerability Scanning on a remote, opened via the control's node switcher. The Vulnerability Scanner card shows 'Not installed' with an Install Trivy button; below it, a 'Scanner is per-node' status callout reads 'Trivy is installed independently on each Sencho instance. Scan policies and CVE suppressions are managed on the control node.'" />
</Frame>
- **Everything outside the three resources above.** API tokens, audit logs, blueprints, secrets, alert rules, users, SSO config, and general settings stay per-instance.
- **Pilot-agent nodes.** Sync over the [pilot tunnel](/features/pilot-agent) is not part of v1; the control logs a one-time warning per pilot node and skips it during fanout. The pilot node's local rules are unaffected.
@@ -95,7 +95,7 @@ Stale pushes (`409 STALE_SYNC_PUSH`) are not counted as failures, since a newer
## Demote a replica
An admin on a replica can take the instance back to a standalone control from **Settings → Security**. The button is "Demote to control" and sits inside the "Managed by control node" callout. The exact modal copy:
An admin on a replica can take the instance back to a standalone control from **Settings → Security → Vulnerability Scanning**. The button is "Demote to control" and sits inside the "Managed by control node" callout. The exact modal copy:
> **Demote replica to control**
>
@@ -159,7 +159,7 @@ Fleet Sync v1 ships the three replicated resources and the control mechanics des
Misconfig acknowledgements ride the same channel as scan policies and CVE suppressions; if one resource replicates and another does not, it is almost always a per-resource watermark race. Re-save the acknowledgement on the control to produce a fresh `pushedAt`, then the next push catches the remote up. If the symptom persists, check the control's server logs for the `[FleetSync]` push outcome on that remote.
</Accordion>
<Accordion title="I want this replica to be a standalone control again">
Open **Settings → Security** on the replica and click **Demote to control** in the "Managed by control node" callout. The action requires confirmation and drops every replicated row from this instance. Local rules authored directly on this instance are kept. The control loses this remote as a replica; remove it from the control's **Settings → Nodes** as well if you no longer want the control to push to it.
Open **Settings → Security → Vulnerability Scanning** on the replica and click **Demote to control** in the "Managed by control node" callout. The action requires confirmation and drops every replicated row from this instance. Local rules authored directly on this instance are kept. The control loses this remote as a replica; remove it from the control's **Settings → Nodes** as well if you no longer want the control to push to it.
</Accordion>
</AccordionGroup>
+5 -5
View File
@@ -59,7 +59,7 @@ Three buttons sit next to the tab strip in the top-right corner. They are visibl
|--------|--------------|
| **Check Updates** | Opens the [Node Updates sheet](#node-updates) where you can manage Sencho version updates across the fleet. |
| **Refresh** | Forces an immediate re-fetch of fleet data. The icon spins while the refresh is in flight; the button is disabled until it completes. |
| **Add node** | Opens the same Add Node dialog as **Settings · System · Nodes** so you can register a new node without leaving the Fleet page. Admin-only. After saving a remote proxy node, a connection test runs automatically and the result toasts in (success or saved-but-unreachable). |
| **Add node** | Opens the same Add Node dialog as **Settings · Infrastructure · Nodes** so you can register a new node without leaving the Fleet page. Admin-only. After saving a remote proxy node, a connection test runs automatically and the result toasts in (success or saved-but-unreachable). |
## The Overview tab
@@ -100,7 +100,7 @@ Offline nodes render dimmed, with no stats grid, no usage bars, and no update af
### Node actions menu (admin)
Every card carries a three-dot **Node actions** kebab in the top-right corner. The menu surfaces the same lifecycle actions you would find in **Settings · System · Nodes**:
Every card carries a three-dot **Node actions** kebab in the top-right corner. The menu surfaces the same lifecycle actions you would find in **Settings · Infrastructure · Nodes**:
| Action | Notes |
|--------|-------|
@@ -143,7 +143,7 @@ A toolbar at the top of the topology canvas offers three layouts. Pick the one t
| **Grouped** | Remotes cluster by their primary node label (alphabetically first when a node has multiple). The local node sits in its own cluster. Use this to read environment, region, or role groupings at a glance. Nodes without any label fall into an Unlabeled cluster. |
| **Free** | Drag any node anywhere on the canvas. Positions persist in this browser, so the arrangement is there when you come back. |
Assign labels in **Settings · System · Nodes** to drive Grouped mode. Each node accepts multiple labels; the alphabetically first one is its cluster.
Assign labels in **Settings · Infrastructure · Nodes** to drive Grouped mode. Each node accepts multiple labels; the alphabetically first one is its cluster.
### Stack drill-down
@@ -264,7 +264,7 @@ Fleet View queries every registered node in parallel. Each node responds indepen
<AccordionGroup>
<Accordion title="A remote node's Current version reads 'unknown'">
The control instance could not resolve the remote's `/api/meta` endpoint. Open **Settings → System → Nodes**, click **Test connection** on the row, and read the toast. The most common causes are a wrong API URL or scheme, a token that has been rotated on the remote (issue a new one and update the saved row), or a firewall or reverse proxy that is not forwarding to the remote's Sencho port. Once the remote is reachable, its version surfaces on the next fleet refresh and the **Update** button becomes available again.
The control instance could not resolve the remote's `/api/meta` endpoint. Open **Settings → Infrastructure → Nodes**, click **Test connection** on the row, and read the toast. The most common causes are a wrong API URL or scheme, a token that has been rotated on the remote (issue a new one and update the saved row), or a firewall or reverse proxy that is not forwarding to the remote's Sencho port. Once the remote is reachable, its version surfaces on the next fleet refresh and the **Update** button becomes available again.
</Accordion>
<Accordion title="'Update all' says no updates available">
The bulk action only triggers updates on remotes that report a valid current version *and* a valid latest version. If a remote's Current reads `unknown`, it is excluded from **Update all** because the gateway cannot perform a safe version comparison. The per-row **Update** button is still available and will run a one-shot update with the latest version it could resolve.
@@ -282,6 +282,6 @@ Fleet View queries every registered node in parallel. Each node responds indepen
Free mode stores node positions in your browser's local storage. The arrangement is per-browser; it does not sync across devices or users. If positions reset on reload, the most common causes are a browser session that has local storage disabled (private or incognito windows, strict tracking-prevention modes, which fall back to in-memory state for the session only), storage cleared by browser cleanup tools or a profile reset, or simply a different browser or profile. To verify storage is writable, open DevTools → Application → Local Storage on the Sencho origin and confirm the `sencho-topology-preferences` key exists after switching modes or dragging a node.
</Accordion>
<Accordion title="Grouped topology shows everything in one Unlabeled cluster">
Grouped mode clusters remote nodes by their primary node label. When no remotes carry any labels, every remote falls into the **Unlabeled** cluster and the canvas looks similar to Hub mode. A hint banner above the canvas points to **Settings · System · Nodes** where labels are managed. Add at least one label to two or more remotes and reopen the topology to see the clusters split.
Grouped mode clusters remote nodes by their primary node label. When no remotes carry any labels, every remote falls into the **Unlabeled** cluster and the canvas looks similar to Hub mode. A hint banner above the canvas points to **Settings · Infrastructure · Nodes** where labels are managed. Add at least one label to two or more remotes and reopen the topology to see the clusters split.
</Accordion>
</AccordionGroup>
+1 -1
View File
@@ -183,5 +183,5 @@ The page is built from several independent loops so the masthead and the sparkli
</AccordionGroup>
<Note>
Log retention on the backend is controlled by **Settings · Developer · Notification Log Retention** (default thirty days). That setting governs alert and notification history stored in Sencho's database. It does not apply to the live container stream this page renders, which is bound entirely by Docker's own log driver and the in-browser buffers above.
Log retention on the backend is controlled by **Settings · Operations · Data Retention** (default thirty days). That setting governs alert and notification history stored in Sencho's database. It does not apply to the live container stream this page renders, which is bound entirely by Docker's own log driver and the in-browser buffers above.
</Note>
+8 -8
View File
@@ -6,7 +6,7 @@ description: Connect multiple Sencho instances and manage every server from one
Sencho's multi-node feature lets you operate Docker Compose stacks on every server you run from a single browser tab. Each server runs its own Sencho instance and the control instance acts as a transparent proxy to the others.
<Frame>
<img src="/images/multi-node/node-manager.png" alt="Settings · System · Nodes panel showing the masthead breadcrumb, SCOPE/NODES/REMOTE stat strip, Add node button, Generate Node Token card, and a populated table of one Local row plus seven remote rows with mixed Proxy and Pilot Agent modes." />
<img src="/images/multi-node/node-manager.png" alt="Settings · Infrastructure · Nodes panel showing the masthead breadcrumb, SCOPE/NODES/REMOTE stat strip, Add node button, Generate Node Token card, and a populated table of one Local row plus seven remote rows with mixed Proxy and Pilot Agent modes." />
</Frame>
## How it works
@@ -45,7 +45,7 @@ For a deep look at how the pilot tunnel works under the hood (credential lifecyc
### Step 1. Open the Add node form
On the control instance, click your avatar in the top-right and choose **Settings**. In the sidebar pick **System → Nodes**, then click **Add node**.
On the control instance, click your avatar in the top-right and choose **Settings**. In the sidebar pick **Infrastructure → Nodes**, then click **Add node**.
<Frame>
<img src="/images/multi-node/add-node-pilot.png" alt="Add remote node modal with Type set to Remote and Mode set to Pilot Agent. The form shows Name, Type, Mode, Mode helper text, and Compose Directory fields. URL and token fields are hidden because Pilot Agent does not need them." />
@@ -90,7 +90,7 @@ Click the button to mint a fresh enrollment command. The previous tunnel is disc
### Step 1. Generate a long-lived token on the remote
On the **remote** Sencho instance (the server you want to add), open **Settings → System → Nodes**. Click **Generate Token** in the **Generate Node Token** card and copy the token that appears.
On the **remote** Sencho instance (the server you want to add), open **Settings → Infrastructure → Nodes**. Click **Generate Token** in the **Generate Node Token** card and copy the token that appears.
<Frame>
<img src="/images/multi-node/generate-token.png" alt="Generate Node Token card showing the explanation text and a freshly issued JWT-style token displayed in a monospace block with a copy button." />
@@ -102,7 +102,7 @@ On the **remote** Sencho instance (the server you want to add), open **Settings
### Step 2. Add the node on your control instance
Open **Settings → System → Nodes** on the control instance and click **Add node**. Set Type to **Remote** and Mode to **Distributed API Proxy** to reveal the URL and token fields.
Open **Settings → Infrastructure → Nodes** on the control instance and click **Add node**. Set Type to **Remote** and Mode to **Distributed API Proxy** to reveal the URL and token fields.
<Frame>
<img src="/images/multi-node/add-node-proxy.png" alt="Add remote node modal in Distributed API Proxy mode. The form shows Name, Type, Mode, Sencho API URL with a plain-HTTP warning banner underneath, API Token (masked), and Compose Directory. The HTTP warning recommends HTTPS or a VPN when the node is reachable over the public internet." />
@@ -185,10 +185,10 @@ When you select a remote node in the switcher, the Settings hub filters to the p
| Panel | Scope | Notes |
|-------|:-----:|-------|
| Appearance | Per browser | Theme and density preferences are stored in your browser, not on the node. |
| System Limits | Per node | Host CPU, RAM, disk, and crash-loop thresholds for the selected node. |
| Notifications | Per node | Discord, Slack, and Webhook channels fire from the node that detects the event. |
| Host Alerts | Per node | Host CPU, RAM, disk, and crash thresholds for the selected node. |
| Channels | Per node | Discord, Slack, and Webhook channels fire from the node that detects the event. |
| Labels | Per node | Stack and container label palettes. |
| Security | Per node | Trivy install state and scanner readiness for the selected node. |
| Vulnerability Scanning | Per node | Trivy install state and scanner readiness for the selected node. |
| Developer | Per node | Retention windows for metrics and logs, plus Developer Mode. |
| App Store | Per node | Template registry URL and featured-catalog source for the selected node's catalog. |
| Support | Per browser | Diagnostics bundle, docs links, contact channels. |
@@ -226,7 +226,7 @@ Click the trash icon to remove a remote node. The local row hides this icon beca
Bearer tokens grant full control over the remote Sencho instance. Treat them like passwords:
- **Rotate immediately** if a token is compromised: open the remote instance's **Settings → System → Nodes** and click **Generate Token** to mint a new one. The previous token is invalidated instantly.
- **Rotate immediately** if a token is compromised: open the remote instance's **Settings → Infrastructure → Nodes** and click **Generate Token** to mint a new one. The previous token is invalidated instantly.
- Tokens are **encrypted at rest** in the local SQLite database.
- Tokens cannot be used to open interactive terminals (Host Console or container exec). Interactive shell access always requires a real browser session on that specific instance.
+1 -1
View File
@@ -159,7 +159,7 @@ Generate scoped API tokens for CI/CD pipelines, scripts, and automation workflow
### Vulnerability scanning
Scan container images for known CVEs with [Trivy](https://trivy.dev). Install Trivy with one click from Settings → Security on first use; the [setup guide](/operations/trivy-setup) covers bind-mounted and air-gapped alternatives. Manual scanning, secret and misconfiguration detection, scan comparison, scheduled scans, and CVE suppressions are available on every tier; scan policies that gate deploys, SBOM generation, SARIF export, and auto-update of the managed Trivy binary are Admiral. [Learn more →](/features/vulnerability-scanning)
Scan container images for known CVEs with [Trivy](https://trivy.dev). Install Trivy with one click from Settings → Security → Vulnerability Scanning on first use; the [setup guide](/operations/trivy-setup) covers bind-mounted and air-gapped alternatives. Manual scanning, secret and misconfiguration detection, scan comparison, scheduled scans, and CVE suppressions are available on every tier; scan policies that gate deploys, SBOM generation, SARIF export, and auto-update of the managed Trivy binary are Admiral. [Learn more →](/features/vulnerability-scanning)
### CVE suppressions
+2 -2
View File
@@ -52,7 +52,7 @@ Conceptually, the agent reverses the usual client/server direction.
Inside the control instance, the tunnel terminates at a **loopback bridge**: a tiny HTTP server on `127.0.0.1:<random-port>` per active tunnel. Every other Sencho feature (the existing HTTP proxy, WebSocket forwarder, mesh dialer, license-tier propagation) treats the bridge as just another remote URL. Once enrolled, the pilot node shows up in the same Nodes table, the same node switcher, and the same dashboard as a proxy-mode node, with the same shipped capabilities except where pilot mode intentionally narrows the surface (see below).
<Frame>
<img src="/images/pilot-agent/03-nodes-table-tunnel.png" alt="Settings · System · Nodes table showing a Local row and a Pilot Agent row. The pilot row has Mode badge 'Pilot Agent', Endpoint 'tunnel (seen 45m ago)', and Status badge 'Online'." />
<img src="/images/pilot-agent/03-nodes-table-tunnel.png" alt="Settings · Infrastructure · Nodes table showing a Local row and a Pilot Agent row. The pilot row has Mode badge 'Pilot Agent', Endpoint 'tunnel (seen 45m ago)', and Status badge 'Online'." />
</Frame>
What rides through the tunnel:
@@ -302,7 +302,7 @@ The generic node-connectivity issues (a node showing Offline, a pilot agent stuc
</Accordion>
<Accordion title="Tunnel closes with WebSocket close code 1002 (protocol error)">
The agent sent a frame larger than the 8 MB ceiling, or the wire decoder rejected a malformed frame. Run the control instance with developer mode enabled (Settings → Developer) to surface the diagnostic decode log, then check the logs for `[PilotBridge:diag] Malformed frame from agent`. The agent reconnects automatically; persistent failures usually mean a version mismatch between the control instance and the agent image.
The agent sent a frame larger than the 8 MB ceiling, or the wire decoder rejected a malformed frame. Run the control instance with developer mode enabled (Settings → Operations → Developer Diagnostics) to surface the diagnostic decode log, then check the logs for `[PilotBridge:diag] Malformed frame from agent`. The agent reconnects automatically; persistent failures usually mean a version mismatch between the control instance and the agent image.
</Accordion>
<Accordion title="I ran the enrollment command on the wrong host">
+2 -2
View File
@@ -24,7 +24,7 @@ Sencho stores credentials for your private Docker registries and injects them au
## Where to find it
Open **Settings → System → Registries** on the Sencho instance you are signed into directly. The section is hidden when you are viewing another node through the node switcher.
Open **Settings → Infrastructure → Registries** on the Sencho instance you are signed into directly. The section is hidden when you are viewing another node through the node switcher.
## Adding a registry
@@ -184,7 +184,7 @@ Because the Registries section is hidden when you view another node through the
</Accordion>
<Accordion title="The Registries section is not visible">
The section is shown on the instance you are signed into directly, and only when that instance's license is Admiral and the signed-in operator has the admin role. On a remote node viewed through the node switcher, the section is hidden by design; sign into that node's own Sencho instance to manage its registries. If a non-admin operator should be able to manage registries, change their role under **Settings → Identity → Users** first.
The section is shown on the instance you are signed into directly, and only when that instance's license is Admiral and the signed-in operator has the admin role. On a remote node viewed through the node switcher, the section is hidden by design; sign into that node's own Sencho instance to manage its registries. If a non-admin operator should be able to manage registries, change their role under **Settings → Access → Users** first.
</Accordion>
<Accordion title="A node displays a 'Private Registries is not available on this node' lock card">
+2 -2
View File
@@ -58,10 +58,10 @@ Community accounts use the **Admin** and **Viewer** roles. Admiral adds the inte
## Managing users
The Users panel lives at **Settings · Users**, under the **Identity** group of the settings sidebar. It is visible only to users with the Admin role, and is hidden when a remote node is the active selection.
The Users panel lives at **Settings · Users**, under the **Access** group of the settings sidebar. It is visible only to users with the Admin role, and is hidden when a remote node is the active selection.
<Frame>
<img src="/images/rbac/users-list.png" alt="Settings Users panel showing the Identity sidebar selection, a SCOPE operator chip, an OPERATORS 2 counter, an Add user button, and a four-column table (Username, Role, Created, Actions) with two rows: admin marked (you) with a disabled trash icon and viewer with active edit and trash icons." />
<img src="/images/rbac/users-list.png" alt="Settings Users panel showing the Access sidebar selection, a SCOPE global chip, an OPERATORS 2 counter, an Add user button, and a four-column table (Username, Role, Created, Actions) with two rows: admin marked (you) with a disabled trash icon and viewer with active edit and trash icons." />
</Frame>
The table shows one row per user with their **Username**, **Role** badge, account **Created** date, and per-row action icons. The signed-in admin's row carries a small `(you)` marker after the username and the delete icon is disabled, so you cannot lock yourself out by deleting your own account.
+2 -2
View File
@@ -17,7 +17,7 @@ The hero stays hidden when there is nothing to reclaim, keeping the view focused
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 → System → Docker hygiene** and switch off **Show reclaimable-space banner**. It is on by default and applies per node.
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.
<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.
@@ -224,6 +224,6 @@ When no unmanaged containers are detected, the tab shows a success state with th
The Unmanaged tab filters the running Sencho container out by design so it cannot be selected and purged. Other containers started outside Sencho (with `docker run` or by a Compose project outside `COMPOSE_DIR`) still appear normally. To inspect the Sencho container itself, use **Host Console** or `docker ps` on the host.
</Accordion>
<Accordion title="The reclaim banner will not go away, or shows a small amount that will not prune">
The banner mirrors what Docker reports as reclaimable on the node, and it counts only the resources a standard prune can actually remove. After a prune, some storage drivers still report a few megabytes as reclaimable that the prune does not free. To stop the banner drawing attention to a stubborn remainder, dismiss it with the **×** in its top-right corner; it stays hidden until the reclaimable total grows past that point. To turn it off for the node entirely, switch off **Show reclaimable-space banner** in **Settings → System → Docker hygiene**.
The banner mirrors what Docker reports as reclaimable on the node, and it counts only the resources a standard prune can actually remove. After a prune, some storage drivers still report a few megabytes as reclaimable that the prune does not free. To stop the banner drawing attention to a stubborn remainder, dismiss it with the **×** in its top-right corner; it stays hidden until the reclaimable total grows past that point. To turn it off for the node entirely, switch off **Show reclaimable-space banner** in **Settings → Monitoring → Docker & Storage**.
</Accordion>
</AccordionGroup>
+2 -2
View File
@@ -195,7 +195,7 @@ When a task that previously failed succeeds again, Sencho dispatches an `info`-l
Vulnerability Scan tasks are an exception: they always dispatch a completion notification, even on a clean run, because the message body carries severity counts you may want to react to. The severity is `info` on a clean run and `warning` when findings are present; the category is `scan_finding`, not `system`. The full message format and routing behavior is documented in [Alerts & Notifications · Vulnerability scanning](/features/alerts-notifications#vulnerability-scanning).
Configure delivery channels in **Settings · Notifications**.
Configure delivery channels in **Settings · Notifications · Channels**.
## Execution history
@@ -245,7 +245,7 @@ If Sencho restarts while a task is mid-execution, the orphaned run record is mar
Sencho was restarted (or the process crashed) while this run was in flight, so the scheduler marked it failed on startup to avoid a stuck `Running` row. The task itself is still enabled and will fire again at its next cron tick. If you want to re-execute immediately, click **Run now** on the row.
</Accordion>
<Accordion title="A scheduled scan completed but no notification arrived">
Scan notifications share the same delivery path as every other Sencho notification. Check, in order: 1) at least one channel is enabled in **Settings · Notifications** and its **Test** button succeeds; 2) if the stack the scan is associated with has notification routes defined, at least one matching route is enabled (the routing layer takes priority over the global channels); 3) open the notification bell, where every notification is recorded regardless of channel configuration, and look for a delivery-error entry; 4) on a remote node, channels must be configured on that node itself because channel settings are per-node.
Scan notifications share the same delivery path as every other Sencho notification. Check, in order: 1) at least one channel is enabled in **Settings · Notifications · Channels** and its **Test** button succeeds; 2) if the stack the scan is associated with has notification routes defined, at least one matching route is enabled (the routing layer takes priority over the global channels); 3) open the notification bell, where every notification is recorded regardless of channel configuration, and look for a delivery-error entry; 4) on a remote node, channels must be configured on that node itself because channel settings are per-node.
</Accordion>
<Accordion title="A Vulnerability Scan fails with 'Trivy binary is not available'">
Trivy must be installed on the node that runs the scan, not just on the gateway. Follow [Installing Trivy](/operations/trivy-setup) on the target node, then click **Run now** on the schedule to confirm the scan succeeds before waiting for the next cron tick.
+2 -2
View File
@@ -89,7 +89,7 @@ The sheet shows:
Mesh runs in-process on each node; there is no separate mesh container to inspect with `docker ps`.
Enable **Developer Mode** under **Settings → Developer** to surface `[Mesh:diag]` lines in the container logs covering the forwarder's routing decisions (same-node versus cross-node, and which node-identity resolution won) and the timing of opt-in, opt-out, and alias-refresh operations (`docker logs sencho 2>&1 | grep '\[Mesh:diag\]'`). They are off by default and meant for one-off debugging; turn developer mode back off when you are done to keep log volume manageable.
Enable **Developer Mode** under **Settings → Operations → Developer Diagnostics** to surface `[Mesh:diag]` lines in the container logs covering the forwarder's routing decisions (same-node versus cross-node, and which node-identity resolution won) and the timing of opt-in, opt-out, and alias-refresh operations (`docker logs sencho 2>&1 | grep '\[Mesh:diag\]'`). They are off by default and meant for one-off debugging; turn developer mode back off when you are done to keep log volume manageable.
## Mesh activity log
@@ -169,7 +169,7 @@ services:
Sencho pins itself to `<network address> + 2` (so `10.42.0.2` for the example above; the bridge's `+1` gateway sits between). The `sencho_mesh` network is created with an IPRange covering the upper half of the subnet, so Docker's auto-allocation hands meshed workloads `<network> + 128` and up by default and never grabs Sencho's static IP while Sencho is offline. Each node is configured independently; the alias registry pushes the correct local IP to each node's override file.
Sencho re-checks the mesh data plane every 10 seconds and updates `/api/health` and the dashboard banner with the current state, so removing or recreating `sencho_mesh` while Sencho is running is reflected within one tick instead of waiting for a restart. The reconciler is report-only by default: if the network disappears, Sencho surfaces `not_found` and waits for the operator to restart. To have Sencho rebuild the network at the same subnet on the next tick, flip **Settings → System → Mesh data plane → Auto-recreate mesh network** on. Auto-recreate never iterates the subnet candidate list (it would invalidate every existing `extra_hosts` override on disk); if the original subnet is no longer free, Sencho reports `subnet_overlap` and stops. Repeat failures are throttled to one attempt per minute.
Sencho re-checks the mesh data plane every 10 seconds and updates `/api/health` and the dashboard banner with the current state, so removing or recreating `sencho_mesh` while Sencho is running is reflected within one tick instead of waiting for a restart. The reconciler is report-only by default: if the network disappears, Sencho surfaces `not_found` and waits for the operator to restart. To have Sencho rebuild the network at the same subnet on the next tick, flip **Settings → Infrastructure → Fleet Mesh → Auto-recreate mesh network** on. Auto-recreate never iterates the subnet candidate list (it would invalidate every existing `extra_hosts` override on disk); if the original subnet is no longer free, Sencho reports `subnet_overlap` and stops. Repeat failures are throttled to one attempt per minute.
## Security and trust boundaries
+1 -1
View File
@@ -301,7 +301,7 @@ If not set, Sencho auto-detects the URL from the request's `Host` header and pro
</Accordion>
<Accordion title="Users land with the wrong username or no email">
Enable **Developer Mode** (Settings → Developer) to log the raw claims Sencho receives from the provider in the server logs. Check your provider's documentation for which claims it includes in the ID token and `userinfo` response, and verify that the configured **Scopes** include everything your provider needs to emit `email` and group claims. For Custom OIDC, override **User ID Claim**, **Username Claim**, or **Email Claim** to match the names your provider actually emits.
Enable **Developer Mode** (Settings → Operations → Developer Diagnostics) to log the raw claims Sencho receives from the provider in the server logs. Check your provider's documentation for which claims it includes in the ID token and `userinfo` response, and verify that the configured **Scopes** include everything your provider needs to emit `email` and group claims. For Custom OIDC, override **User ID Claim**, **Username Claim**, or **Email Claim** to match the names your provider actually emits.
</Accordion>
<Accordion title="The provider returns 'invalid redirect URI' during sign-in">
+6 -6
View File
@@ -48,10 +48,10 @@ The Tags filter aggregates label rows across nodes by name, so a label called `p
### Manage labels from Settings
**Settings · Advanced · Labels** is the canonical place to create, rename, recolor, and delete labels. The masthead shows a `LABELS N/50` counter so you can see how close the active node is to the cap, and a per-row stack count tells you how many stacks currently carry each label.
**Settings · Organization · Labels** is the canonical place to create, rename, recolor, and delete labels. The masthead shows a `LABELS N/50` counter so you can see how close the active node is to the cap, and a per-row stack count tells you how many stacks currently carry each label.
<Frame>
<img src="/images/stack-labels/settings-labels.png" alt="Settings page open on the Advanced · Labels section. The right pane shows a 'Per-node labels for stacks and containers.' description, a 'New label' primary button, and three label rows: Media (purple dot, 6 stacks), Network (orange dot, 3 stacks), Utilities (slate dot, 5 stacks). The masthead shows the LABELS 3/50 stat." />
<img src="/images/stack-labels/settings-labels.png" alt="Settings page open on the Organization · Labels section. The right pane shows a 'Per-node labels for stacks and containers.' description, a 'New label' primary button, and three label rows: Media (purple dot, 6 stacks), Network (orange dot, 3 stacks), Utilities (slate dot, 5 stacks). The masthead shows the LABELS 3/50 stat." />
</Frame>
Hover any row to reveal a **Pencil** edit icon and a destructive **Trash** icon on the trailing edge. The edit dialog shares its chrome with the create dialog: the kicker reads `LABELS · NEW` for a new label or `LABELS · EDIT` when you opened it from the pencil, the body has a single `Label name` input plus the ten color swatches, and the footer has **Cancel** and **Create** (or **Save**) buttons.
@@ -67,7 +67,7 @@ Deleting a label opens a destructive confirmation with the kicker `LABELS · DEL
Right-clicking a stack in the sidebar (or using the three-dot kebab menu on its row) opens the same context menu under the **organize** group. Click **Labels** to open a submenu listing every label that exists on the active node, with a checkmark next to each one currently assigned to this stack. Clicking a label toggles the assignment immediately. The two trailing items handle creation and full management:
- **New label** drops an inline form into the same submenu (text input with placeholder `Label name`, the ten color swatches, **Create** / **Cancel** buttons). Submitting creates the label on this node and assigns it to the stack in a single round trip. The entry hides itself once the node hits 50 labels.
- **Manage labels...** sends you to **Settings · Advanced · Labels** for bulk renames, recolors, and deletions.
- **Manage labels...** sends you to **Settings · Organization · Labels** for bulk renames, recolors, and deletions.
<Frame>
<img src="/images/stack-labels/context-menu-labels.png" alt="Right-click context menu on a sidebar stack row. The Labels submenu is open and shows three label rows (Media with a checkmark on the right, Network, Utilities), a separator, a + New label entry, and a Manage labels... entry." />
@@ -111,19 +111,19 @@ The **Bulk label assign** card is per-node only by design. To re-tag stacks on a
- **Names are unique per node**, case-sensitive. The same name on two nodes is two separate label rows. Cross-node fleet stop matches on name; bulk assign always operates on one node's labels at a time.
- **Allowed name characters**: letters, digits, spaces, and hyphens. Empty names and names beyond 30 characters are rejected at the API.
- **Bulk-action concurrency**: only one label-driven bulk action can run on a single node at a time. A second concurrent attempt against the same node returns HTTP 429 and the operator sees an error toast; the in-flight action keeps running.
- **Role visibility**: label authoring is open to every signed-in role. Sidebar grouping, trailing dots on stack rows, the **Settings · Advanced · Labels** panel, the inline create form in the stack menu, and the Fleet View **Tags** filter all work for every user.
- **Role visibility**: label authoring is open to every signed-in role. Sidebar grouping, trailing dots on stack rows, the **Settings · Organization · Labels** panel, the inline create form in the stack menu, and the Fleet View **Tags** filter all work for every user.
## Troubleshooting
<AccordionGroup>
<Accordion title="Stacks are not grouped under labels in the sidebar">
Grouping requires at least one stack to carry at least one label. With zero labels assigned the sidebar collapses into a single `UNLABELED` group, which is rendered as a flat list. Create a label from **Settings · Advanced · Labels** or right-click any stack and use **Labels · New label**, assign it to a stack, and the grouped layout takes over.
Grouping requires at least one stack to carry at least one label. With zero labels assigned the sidebar collapses into a single `UNLABELED` group, which is rendered as a flat list. Create a label from **Settings · Organization · Labels** or right-click any stack and use **Labels · New label**, assign it to a stack, and the grouped layout takes over.
</Accordion>
<Accordion title="The trailing colored dots are missing on stack rows">
A stack row only renders trailing dots when at least one label is assigned to that stack. Right-click the row, open the **Labels** submenu, and tick at least one label; the dots appear on the next sidebar refresh. If a stack already has labels assigned but the dots still do not appear, check that the active node is the one that owns the assignments. Labels are per-node, so switching the node switcher to a different instance shows that instance's assignments only.
</Accordion>
<Accordion title="`+ New label` is missing from the stack context menu">
The active node already has 50 labels (the per-node cap). Both the inline `New label` entry in the stack submenu and the **New label** button in **Settings · Advanced · Labels** hide themselves at the cap. Delete an unused label or rename an existing one to free a slot.
The active node already has 50 labels (the per-node cap). Both the inline `New label` entry in the stack submenu and the **New label** button in **Settings · Organization · Labels** hide themselves at the cap. Delete an unused label or rename an existing one to free a slot.
</Accordion>
<Accordion title="The Tags filter does not list a label I just created">
The Tags filter aggregates labels across every node in the fleet by name. If the new label only exists on one node and that node was offline at the moment the page loaded, the dropdown may not include it. Refresh **Fleet · Overview** with the **Refresh** button in the toolbar to repull node state.
+10 -10
View File
@@ -14,7 +14,7 @@ Sencho integrates with [Trivy](https://trivy.dev) to scan container images and C
The Trivy CLI must be available on the machine running Sencho. Trivy is not bundled with the Sencho Docker image; see [Installing Trivy](/operations/trivy-setup) for mount and installation options. Sencho checks for Trivy on startup and hides scanning UI when the binary is not available.
<Note>
Trivy is installed independently on each Sencho instance. When you select a remote node, **Settings → Security** shows only the scanner status for that node; install, update, or uninstall Trivy from there to manage the remote's binary. Scan policies, CVE suppressions, and misconfig acknowledgements are managed on the control instance and replicate fleet-wide.
Trivy is installed independently on each Sencho instance. When you select a remote node, **Settings → Security → Vulnerability Scanning** shows only the scanner status for that node; install, update, or uninstall Trivy from there to manage the remote's binary. Scan policies, CVE suppressions, and misconfig acknowledgements are managed on the control instance and replicate fleet-wide.
</Note>
## Tier availability
@@ -149,7 +149,7 @@ See [Deploy Enforcement](/features/deploy-enforcement) for the full pre-flight f
### Creating a policy
Go to **Settings → Security** and click **Add Policy**.
Go to **Settings → Security → Vulnerability Scanning** and click **Add Policy**.
| Field | Description |
|-------|-------------|
@@ -195,7 +195,7 @@ Only one policy is evaluated per deploy. Use a single tight pattern rather than
By default a block-on-deploy policy evaluates the **raw** scan result, so a CVE you have accepted in [CVE Suppressions](/features/cve-suppressions) still counts toward the block. That keeps the gate strict: suppressions silence alerts and dim findings in reports, but on their own they do not open the deploy path.
To have an accepted CVE stop counting toward the gate, turn on **Honor suppressions in deploy blocks** in **Settings → Security**. With it on, the gate re-derives each image's severity from the findings that remain after suppressions are applied, so an image whose only blocking findings are all suppressed deploys without a manual bypass. When a deploy proceeds for this reason, Sencho records it in the audit log so the suppression-driven pass stays traceable.
To have an accepted CVE stop counting toward the gate, turn on **Honor suppressions in deploy blocks** in **Settings → Security → Vulnerability Scanning**. With it on, the gate re-derives each image's severity from the findings that remain after suppressions are applied, so an image whose only blocking findings are all suppressed deploys without a manual bypass. When a deploy proceeds for this reason, Sencho records it in the audit log so the suppression-driven pass stays traceable.
The toggle governs the Sencho instance that runs the deploy and is off by default. Enable it on each instance whose deploys should honor suppressions.
@@ -272,7 +272,7 @@ Acknowledged rows render dimmed with a strikethrough title; hovering surfaces th
### Managing acknowledgements
**Settings → Security** has a Misconfig Acknowledgements panel listing every acknowledgement on this control: rule id, optional stack pattern (glob), creator, expiry date, and a delete button. The same `replicated` badge that appears on CVE suppressions appears here for rows pushed from the control to a replica.
**Settings → Security → Vulnerability Scanning** has a Misconfig Acknowledgements panel listing every acknowledgement on this control: rule id, optional stack pattern (glob), creator, expiry date, and a delete button. The same `replicated` badge that appears on CVE suppressions appears here for rows pushed from the control to a replica.
Replicas show the panel read-only; write operations return 403 with a "managed by control" message so configuration drift cannot accumulate on the leaf nodes.
@@ -382,7 +382,7 @@ Up to 1000 findings per scan are loaded for comparison. When a scan exceeds this
<AccordionGroup>
<Accordion title="Scan button is not visible">
Sencho hides scanning UI when the Trivy binary is not detected. Check **Settings → Security** for the scanner status, then follow [Installing Trivy](/operations/trivy-setup) if it is missing.
Sencho hides scanning UI when the Trivy binary is not detected. Check **Settings → Security → Vulnerability Scanning** for the scanner status, then follow [Installing Trivy](/operations/trivy-setup) if it is missing.
</Accordion>
<Accordion title="Scans time out">
The default scan timeout is 5 minutes. Very large images (2 GB or more) over a slow connection may exceed this; pre-pulling the image to the host speeds up the scan significantly because Trivy then works against the local image store.
@@ -400,16 +400,16 @@ Up to 1000 findings per scan are loaded for comparison. When a scan exceeds this
Sencho re-checks for the Trivy binary every ten minutes from the scheduler. Install Trivy on the host, wait for the next window, and the scanning UI lights up automatically.
</Accordion>
<Accordion title="Collecting diagnostic logs for support">
Enable **Developer Mode** under **Settings → Developer** and trigger the failing scan again. The backend logs verbose `[Trivy:diag]` entries covering detection timing, cache hits, Trivy invocation, and parse statistics. Attach these lines when filing a support issue. Turn Developer Mode off once you have captured the output.
Enable **Developer Mode** under **Settings → Operations → Developer Diagnostics** and trigger the failing scan again. The backend logs verbose `[Trivy:diag]` entries covering detection timing, cache hits, Trivy invocation, and parse statistics. Attach these lines when filing a support issue. Turn Developer Mode off once you have captured the output.
</Accordion>
<Accordion title="Post-deploy scan failure notifications">
When a post-deploy scan fails for a specific image (for example because Trivy could not resolve a private registry pull), Sencho dispatches a warning-level alert through your configured notification channels. The deploy itself is never blocked by a scan failure.
</Accordion>
<Accordion title="A deploy was blocked by a policy I did not expect">
The block dialog names the policy that fired and lists every image that violated the threshold. Open **Settings → Security** and review the matching policy: check the stack pattern glob and the max severity. If the policy should not apply, tighten the pattern (for example `staging-*` instead of `*`) or turn **Block on deploy** off to keep the evaluation in alert-only mode. Admins can bypass a single deploy with the **Deploy anyway** button; every bypass is recorded in the [Audit Log](/features/audit-log) with the actor, policy, and violation list.
The block dialog names the policy that fired and lists every image that violated the threshold. Open **Settings → Security → Vulnerability Scanning** and review the matching policy: check the stack pattern glob and the max severity. If the policy should not apply, tighten the pattern (for example `staging-*` instead of `*`) or turn **Block on deploy** off to keep the evaluation in alert-only mode. Admins can bypass a single deploy with the **Deploy anyway** button; every bypass is recorded in the [Audit Log](/features/audit-log) with the actor, policy, and violation list.
</Accordion>
<Accordion title="Trivy is not installed and a deploy with a block policy went through">
Sencho fails open when Trivy is not installed on the target node, so operators are never locked out by tooling state. A warning alert is dispatched through your configured notification channels with the message `Pre-deploy scan for "<stack>" skipped: Trivy not installed on this node`. Install Trivy from **Settings → Security** to enforce the policy; see [Installing Trivy](/operations/trivy-setup) for options.
Sencho fails open when Trivy is not installed on the target node, so operators are never locked out by tooling state. A warning alert is dispatched through your configured notification channels with the message `Pre-deploy scan for "<stack>" skipped: Trivy not installed on this node`. Install Trivy from **Settings → Security → Vulnerability Scanning** to enforce the policy; see [Installing Trivy](/operations/trivy-setup) for options.
</Accordion>
<Accordion title="Compare button is disabled in the scan history sheet">
The Compare primary action enables only after exactly two scans are ticked. Selecting zero, one, or three scans leaves it disabled. If you have only one scan for an image, trigger a second scan from the Resources Hub (or wait for a scheduled scan), then return to Scan history and tick both.
@@ -424,10 +424,10 @@ Up to 1000 findings per scan are loaded for comparison. When a scan exceeds this
The Scan history sheet uses server-driven pagination. If you know the scan exists but cannot see it, use the search box to filter by image reference, or page forward with the arrows in the card header. Scans older than 90 days are pruned automatically to keep the database compact.
</Accordion>
<Accordion title="Scan policies are missing on one of my nodes">
Scan policies are managed from the control Sencho instance and replicate to every remote. On a replica, **Settings → Security** shows a banner explaining that rules are managed upstream. See [Fleet Sync](/features/fleet-sync) for how replication works and how to investigate push failures.
Scan policies are managed from the control Sencho instance and replicate to every remote. On a replica, **Settings → Security → Vulnerability Scanning** shows a banner explaining that rules are managed upstream. See [Fleet Sync](/features/fleet-sync) for how replication works and how to investigate push failures.
</Accordion>
<Accordion title="I suppressed a CVE but the scan badge count is unchanged">
Badge counts always reflect raw findings so alerting stays accurate. Policy evaluation also uses raw findings by default; if you want suppressed CVEs to stop counting toward block-on-deploy policies, enable **Honor suppressions in deploy blocks** in **Settings → Security**. Open the scan drawer to confirm the row is dimmed with a shield-off icon. See [CVE Suppressions](/features/cve-suppressions) for how the filter is applied across the drawer, compare sheet, and other read surfaces.
Badge counts always reflect raw findings so alerting stays accurate. Policy evaluation also uses raw findings by default; if you want suppressed CVEs to stop counting toward block-on-deploy policies, enable **Honor suppressions in deploy blocks** in **Settings → Security → Vulnerability Scanning**. Open the scan drawer to confirm the row is dimmed with a shield-off icon. See [CVE Suppressions](/features/cve-suppressions) for how the filter is applied across the drawer, compare sheet, and other read surfaces.
</Accordion>
<Accordion title="The Secrets tab is empty on an image I expect to contain credentials">
Secret detection matches against Trivy's built-in rule set, which focuses on well-known provider patterns. Plain text passwords, custom token formats, or values that do not match any published rule will not appear. Make sure you picked **Full scan (vulnerabilities + secrets)** from the shield-icon menu; a plain vulnerability scan does not walk the filesystem.
+3 -3
View File
@@ -11,14 +11,14 @@ Sencho webhooks let external systems trigger stack actions over HTTP. The typica
## How it works
1. You create a webhook in **Settings → Alerts → Webhooks**, targeting a specific stack and action.
1. You create a webhook in **Settings → Automation → Webhooks**, targeting a specific stack and action.
2. Sencho generates a unique secret for HMAC-SHA256 signature validation.
3. Your CI/CD system sends a `POST` request to the trigger URL with the correct signature.
4. Sencho validates the signature and executes the action asynchronously.
## Creating a webhook
Open **Settings → Alerts → Webhooks** and click **Create webhook**. The page is part of the global settings and is reachable while the **Local** node is active in the node switcher; webhooks created from this page execute against the local Sencho instance.
Open **Settings → Automation → Webhooks** and click **Create webhook**. The page is part of the global settings and is reachable while the **Local** node is active in the node switcher; webhooks created from this page execute against the local Sencho instance.
<Frame>
<img src="/images/webhooks/webhooks-create-form.png" alt="New webhook form with Name, Stack, Node, and Action fields. The Action dropdown is open showing Deploy (down + up), Restart, Stop, Start, Pull and Update, and Git source sync." />
@@ -169,7 +169,7 @@ Sencho retains the last 100 executions per webhook and surfaces the 20 most rece
<Accordion title="My trigger returns 404 'Webhook not found or signature invalid'.">
The trigger endpoint returns the same 404 for every unauthenticated case, so the response alone will not tell you which check failed. Work through this checklist:
- The id in the URL is wrong. Copy the trigger URL straight from the webhook card in **Settings → Alerts → Webhooks**.
- The id in the URL is wrong. Copy the trigger URL straight from the webhook card in **Settings → Automation → Webhooks**.
- The webhook's enable toggle is off. Check the **On / Off** switch on the card.
- The request omitted the `X-Webhook-Signature` header. Confirm the full value, including the `sha256=` prefix; the header value is `sha256={hex}`.
- The request had no body. Send at least `{}` and sign that exact byte sequence.
+2 -2
View File
@@ -80,7 +80,7 @@ The **Logs** view aggregates logs across stacks on the active node. It includes
## Settings and security
Settings are grouped by **Identity**, **System**, **Alerts**, and **Advanced**. Some sections are global to the control instance, while others are scoped to the active node.
Settings are grouped by **Personal**, **Access**, **Infrastructure**, **Monitoring**, **Notifications**, **Automation**, **Organization**, **Security**, **Operations**, and **Help**. Some sections are global to the control instance, while others are scoped to the active node.
Use Settings to manage account security, licensing, users, SSO, API tokens, host thresholds, node registration, alert delivery, security scanning, template registry settings, diagnostics, and build metadata. The [Configuration](/getting-started/configuration) guide covers the host and environment settings that matter before first deploy.
@@ -91,7 +91,7 @@ Use Settings to manage account security, licensing, users, SSO, API tokens, host
3. Confirm the Home view sees your active node, resource gauges, and stack list.
4. Create a stack from the sidebar, from Git, from a Docker Run command, or from the App Store.
5. Open the stack workspace to inspect containers, logs, anatomy, files, and compose content.
6. Add remote nodes from **Fleet** or **Settings · System · Nodes** when you are ready to manage more hosts.
6. Add remote nodes from **Fleet** or **Settings · Infrastructure · Nodes** when you are ready to manage more hosts.
7. Use the rest of the top navigation as your operational needs grow.
## Next steps
Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

+4 -4
View File
@@ -5,7 +5,7 @@ description: Install and mount the Trivy CLI so Sencho can scan container images
Sencho's [Vulnerability Scanning](/features/vulnerability-scanning) feature uses the [Trivy](https://trivy.dev) CLI. Trivy is not bundled with the Sencho Docker image. You have three ways to provide it, in order of convenience:
1. **One-click install from Settings → Security** (recommended).
1. **One-click install from Settings → Security → Vulnerability Scanning** (recommended).
2. Bind mount a host Trivy binary into the container.
3. Build a custom Sencho image with Trivy baked in.
@@ -23,7 +23,7 @@ Trivy's vulnerability database updates multiple times per day and is around 100
Sencho can install and manage Trivy for you without any extra bind mounts or environment variables.
1. Go to **Settings → Security**.
1. Go to **Settings → Security → Vulnerability Scanning**.
2. Under **Vulnerability Scanner**, click **Install Trivy**.
3. Wait for the status to flip to **Installed (managed)**. The version appears next to the badge.
@@ -40,7 +40,7 @@ Behind the scenes:
### Updating the managed install
When a newer Trivy release is available, Settings → Security shows an **Update available** badge next to the version. Click **Update** to pull the latest release.
When a newer Trivy release is available, Settings → Security → Vulnerability Scanning shows an **Update available** badge next to the version. Click **Update** to pull the latest release.
To update automatically instead, toggle **Auto-update Trivy** on. Sencho checks for new releases once a day and installs them in the background. You'll get an in-app notification each time a new version is installed, or when an update is available and auto-update is off.
@@ -181,7 +181,7 @@ Plan to refresh the bundle on a schedule (weekly is typical) so CVE data stays c
## Verifying Sencho detects Trivy
1. Open **Settings → Security**. The **Vulnerability Scanner** card shows the current status and version.
1. Open **Settings → Security → Vulnerability Scanning**. The **Vulnerability Scanner** card shows the current status and version.
2. Open the **Resources** tab. If Trivy is detected, a shield icon appears in the Actions column of the **Images** panel next to the delete icon on every row.
If the scanner shows as not installed after using Option 2 or 3, see the troubleshooting section below.
+1 -1
View File
@@ -479,7 +479,7 @@ docker compose pull && docker compose up -d
**Checks:**
1. **Stack name match**: The stack name in the routing rule must match exactly. Stack names are case-sensitive and correspond to the directory name in your compose folder.
2. **Rule is enabled**: Check that the route's toggle is turned on in **Profile > Settings > Routing**.
2. **Rule is enabled**: Check that the route's toggle is turned on in **Profile > Settings > Notifications > Notification Routing**.
---
+1 -1
View File
@@ -48,7 +48,7 @@ The command respects the container's `DATA_DIR`, so it always acts on the same S
This is one of several host-level recovery commands. See [Emergency command-line recovery](/operations/emergency-cli) for the full set, including password reset, creating an emergency admin, and disabling a broken SSO provider.
<Note>
Enabling **Developer Mode** under **Settings · Developer** surfaces additional `[MFA:diag]` lines in the backend logs. They are helpful when investigating a 2FA support ticket and can be turned off again once the issue is resolved.
Enabling **Developer Mode** under **Settings · Operations · Developer Diagnostics** surfaces additional `[MFA:diag]` lines in the backend logs. They are helpful when investigating a 2FA support ticket and can be turned off again once the issue is resolved.
</Note>
## SSO and 2FA: two independent toggles
+1 -1
View File
@@ -207,7 +207,7 @@ For user management and scoped permissions, see [RBAC & User Management](/featur
Every POST, PUT, DELETE, and PATCH request to the API is recorded in the audit log with the acting username, IP address, HTTP method, response status, and an auto-generated summary. GET requests are excluded to keep the log focused on mutations.
The audit log is searchable by keyword (actions, paths, usernames) and filterable by HTTP method and date range. Results can be exported as CSV or JSON (up to 10,000 entries per export). Retention defaults to 90 days and is configurable from 1 to 365 days in **Settings · Developer · Data retention**.
The audit log is searchable by keyword (actions, paths, usernames) and filterable by HTTP method and date range. Results can be exported as CSV or JSON (up to 10,000 entries per export). Retention defaults to 90 days and is configurable from 1 to 365 days in **Settings · Operations · Data Retention**.
The **Auditor** role provides read-only access to the audit log without any other administrative privileges, making it suitable for compliance reviewers who should not have access to system settings.
+87 -37
View File
@@ -3,22 +3,28 @@ title: Settings Reference
description: Complete reference for every option in the Sencho Settings Hub.
---
Open the Settings Hub by clicking the **Profile** icon in the top bar and selecting **Settings**. The sidebar groups every section into four themes so related settings live together.
Open the Settings Hub by clicking the **Profile** icon in the top bar and selecting **Settings**. The sidebar groups every section into themed groups so related settings live together.
<Frame>
<img src="/images/settings/settings-hub-grouped.png" alt="Settings Hub with the grouped sidebar: Identity, System, Alerts, Advanced" />
<img src="/images/settings/settings-hub-grouped.png" alt="Settings Hub with the grouped sidebar" />
</Frame>
### Sidebar groups
| Group | What it covers |
|-------|----------------|
| **Identity** | Account, Appearance, License, Users, SSO, API Tokens |
| **System** | System Limits, Registries, Cloud Backup, Nodes (System Limits is per-node; the rest are global) |
| **Alerts** | Notifications, Routing, Webhooks |
| **Advanced** | Labels, Security, Developer, App Store, Support, About |
| **Personal** | Account, Appearance |
| **Access** | License, Users, SSO, API Tokens |
| **Infrastructure** | Nodes, Fleet Mesh, Registries, Cloud Backup, App Store |
| **Monitoring** | Host Alerts, Docker & Storage |
| **Notifications** | Channels, Notification Routing |
| **Automation** | Webhooks |
| **Organization** | Labels |
| **Security** | Vulnerability Scanning |
| **Operations** | Data Retention, Developer Diagnostics, Recovery |
| **Help** | Support, About |
Sections that require a higher license tier stay hidden until the operator has the matching license. Admin-only sections (Users, SSO, API Tokens, Registries, Cloud Backup, Routing, Security) stay hidden for non-admin operators.
Sections that require a higher license tier stay hidden until the operator has the matching license. Admin-only sections (Users, SSO, API Tokens, Fleet Mesh, Registries, Cloud Backup, Notification Routing, Vulnerability Scanning) stay hidden for non-admin operators.
### Page chrome
@@ -26,10 +32,10 @@ Every section renders inside the same masthead-and-sidebar layout. The masthead
| Pill | Meaning |
|------|---------|
| **SCOPE** `operator` / `global` | Setting applies to your account (Identity sections) or to the whole instance (System and Advanced) |
| **SCOPE** `operator` / `global` | Setting applies to your account or this browser (Personal sections) or to the whole instance (every other non-node group) |
| **NODE** `<node name>` | Setting is per-node and is currently being edited against this node |
| **EDITED** `<count>` pending / `saved` | The current section has unsaved changes |
| Section-specific stats | Each section can publish its own pills: `2FA on`/`off` and `BACKUP <n> left` (Account); `PLAN`, `TRIAL <n>d left`, `RENEWS`, `STATUS` (License); `OPERATORS` (Users); `CHANNELS` (Notifications); `ROUTES` (Routing); `WEBHOOKS` and `ENABLED` (Webhooks); `LABELS` (Labels); `TRIVY managed`/`host`/`missing` and `POLICIES` (Security); `PROVIDER`, `USED`, `SNAPSHOTS` (Cloud Backup); `DEV MODE` (Developer) |
| Section-specific stats | Each section can publish its own pills: `2FA on`/`off` and `BACKUP <n> left` (Account); `PLAN`, `TRIAL <n>d left`, `RENEWS`, `STATUS` (License); `OPERATORS` (Users); `CHANNELS` (Channels); `ROUTES` (Notification Routing); `WEBHOOKS` and `ENABLED` (Webhooks); `LABELS` (Labels); `TRIVY managed`/`host`/`missing` and `POLICIES` (Vulnerability Scanning); `PROVIDER`, `USED`, `SNAPSHOTS` (Cloud Backup); `DEV MODE` (Developer Diagnostics) |
### Quick search
@@ -44,7 +50,7 @@ Click **Filter** at the top of the sidebar, or press `Ctrl+K` / `⌘K` while the
For node-scoped sections the masthead replaces the **SCOPE** pill with a **NODE** pill showing the active node name. Switch the active node from the top bar's **Switch node** button to edit a different node's per-node settings.
<Frame>
<img src="/images/settings/settings-node-scope.png" alt="System Limits section showing the NODE pill in the masthead" />
<img src="/images/settings/settings-node-scope.png" alt="Host Alerts section showing the NODE pill in the masthead" />
</Frame>
---
@@ -211,32 +217,73 @@ See [API Tokens](/features/api-tokens) for the full walkthrough.
---
## System Limits
## Host Alerts
**Scope:** Per-node (applies to the currently selected node)
Configure resource thresholds that trigger visual warnings on the dashboard stat cards. These are display thresholds only; Sencho does not throttle or kill containers when limits are reached. The masthead **NODE** pill names which node you are configuring; the **EDITED** pill counts unsaved changes.
Configure the host resource thresholds that trigger warnings, the suppression cadence for repeated alerts, and whether unexpected container exits raise a crash alert. These are alerting thresholds, not enforcement: Sencho warns when a metric crosses a line, it never throttles or kills containers. The masthead **NODE** pill names which node you are configuring; the **EDITED** pill counts unsaved changes.
<Frame>
<img src="/images/settings/settings-system-limits.png" alt="System Limits section showing Host thresholds and Docker hygiene subsections" />
<img src="/images/settings/settings-host-alerts.png" alt="Host Alerts section showing the Host thresholds and Crash capture subsections" />
</Frame>
### Host thresholds
| Setting | Default | Description |
|---------|---------|-------------|
| **CPU limit** | 90% | Alerts fire when the 5-minute average CPU exceeds this percentage. The input warns at values above 95%. |
| **CPU limit** | 90% | Alerts fire when host CPU utilization exceeds this percentage. The input warns at values above 95%. |
| **RAM limit** | 90% | Set this below the point at which the host starts paging to swap. |
| **Disk limit** | 90% | Low free space slows image pulls and backups. |
| **Alert suppression** | 60 min | How long to wait before resending a host alert while the metric stays over threshold. The follow-up message includes a count of suppressed cycles. Range 1 to 1,440 minutes. |
### Docker hygiene
### Crash capture
| Setting | Default | Description |
|---------|---------|-------------|
| **Global crash capture** | On | Watch every managed container for unexpected exits. When on, Sencho sends a notification whenever a container exits with a non-zero status outside a planned stop. |
Click **Save alerts** to apply.
---
## Docker & Storage
**Scope:** Per-node (applies to the currently selected node)
Configure the reclaimable-space alert, the reclaimable-space banner, and automatic image cleanup after updates.
### Storage alerts
| Setting | Default | Description |
|---------|---------|-------------|
| **Janitor 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. |
| **Global crash capture** | On | Watch every managed container for unexpected exits. When on, Sencho sends a notification whenever a container exits with a non-zero status outside a planned stop. |
| **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. |
Click **Save limits** to apply.
### Image cleanup
| Setting | Default | Description |
|---------|---------|-------------|
| **Prune dangling images after updates** | On | When a stack update or a Sencho self-update finishes, remove the node's dangling (untagged) image layers, including the one the update just orphaned. Only untagged layers are touched: tagged images, your volumes, and your data are never removed. Turn it off to keep every old layer. |
Click **Save settings** to apply.
---
## Fleet Mesh
<Note>
Fleet Mesh is admin-only.
</Note>
**Scope:** Per-node (applies to the currently selected node)
Control the cross-node service mesh data plane for the selected node.
| Setting | Default | Description |
|---------|---------|-------------|
| **Auto-recreate mesh network** | Off | If `sencho_mesh` is removed at runtime, rebuild it at the same subnet on the next 10-second tick. Off by default; leave it off and restart Sencho manually for the safest path. See [Sencho Mesh](/features/sencho-mesh) for the reconciler behavior. |
Click **Save settings** to apply.
---
@@ -300,7 +347,7 @@ Quick reference:
---
## Notifications
## Channels
**Scope:** Per-node (each node has its own notification agents; remote nodes dispatch alerts through their own channels)
@@ -319,7 +366,7 @@ At least one agent must be enabled for stack alerts to deliver notifications. Se
---
## Routing
## Notification Routing
<Note>
Creating, editing, and deleting routes is admin-only.
@@ -385,10 +432,10 @@ See [Stack Labels](/features/stack-labels) for the full walkthrough.
---
## Security
## Vulnerability Scanning
<Note>
Security is admin-only. The Trivy installer and CVE/misconfig suppressions are available on all tiers; scan policies and the **Auto-update Trivy** toggle require an Admiral license (the toggle also requires a managed Trivy binary).
Vulnerability Scanning is admin-only. The Trivy installer and CVE/misconfig suppressions are available on all tiers; scan policies and the **Auto-update Trivy** toggle require an Admiral license (the toggle also requires a managed Trivy binary).
</Note>
**Scope:** Per-node
@@ -423,31 +470,34 @@ Sencho instances on the Admiral tier can act as a security control plane for the
---
## Developer
## Data Retention
**Scope:** Per-node (developer mode), Global (data retention)
**Scope:** Per-node
<Frame>
<img src="/images/settings/settings-developer-section.png" alt="Developer settings panel with the Developer Mode toggle and Data Retention fields" />
</Frame>
Advanced settings for debug diagnostics and data retention. Most operators can leave these at their defaults. The masthead publishes a **DEV MODE** pill (`on` / `off`).
### Diagnostics
| Setting | Default | Description |
|---------|---------|-------------|
| **Developer mode** | Off | Enables real-time metrics streams and verbose debug diagnostics in the UI. Does not affect [Global Observability](/features/global-observability) streaming, which is always on. |
### Data retention
How long Sencho keeps historical data on this node before pruning it.
| Setting | Default | Max | Description |
|---------|---------|-----|-------------|
| **Container metrics** | 24 hrs | 8,760 (1 year) | How long to keep per-container CPU, RAM, and network history for dashboard charts. |
| **Notification log** | 30 days | 365 | How long to keep alert and notification history. |
| **Scan history per image** | 50 scans | 1,000 | How many vulnerability scans to keep per image. Older scans beyond the cap are pruned. |
| **Audit log** | 90 days | 365 | How long to keep audit trail entries. Requires Admiral. |
Click **Save settings** to apply. Retention rows are global; the **Developer mode** toggle is per-node.
Click **Save settings** to apply.
---
## Developer Diagnostics
**Scope:** Per-node
Debug diagnostics for this node. Most operators can leave this off. The masthead publishes a **DEV MODE** pill (`on` / `off`).
| Setting | Default | Description |
|---------|---------|-------------|
| **Developer mode** | Off | Enables real-time metrics streams and verbose debug diagnostics in the UI. Does not affect [Global Observability](/features/global-observability) streaming, which is always on. |
Click **Save settings** to apply.
---
@@ -158,12 +158,12 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
<Row
label="Auto-heal policies"
value={automation.autoHeal.total === 0 ? 'None' : `${automation.autoHeal.enabled} / ${automation.autoHeal.total} active`}
onClick={open('system')}
onClick={open('host-alerts')}
/>
<Row
label="Auto-update schedules"
value={automation.autoUpdate.total === 0 ? 'None' : `${automation.autoUpdate.enabled} / ${automation.autoUpdate.total} active`}
onClick={open('system')}
onClick={open('host-alerts')}
/>
{!automation.webhooks.locked && (
<Row
@@ -176,7 +176,7 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
<Row
label="Scheduled tasks"
value={formatCount(automation.scheduledTasks.enabled, 'active')}
onClick={open('system')}
onClick={open('host-alerts')}
/>
)}
@@ -206,12 +206,12 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
<Row
label="Alert thresholds"
value={`CPU ${thresholds.cpuLimit}% · RAM ${thresholds.ramLimit}% · Disk ${thresholds.diskLimit}%`}
onClick={open('system')}
onClick={open('host-alerts')}
/>
<Row
label="Crash detection"
value={thresholds.globalCrash ? 'On' : 'Off'}
onClick={open('system')}
onClick={open('host-alerts')}
/>
</div>
</CardContent>
@@ -0,0 +1,236 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { RefreshCw } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
import type { SenchoSettingsChangedDetail } from '@/lib/events';
import { DEFAULT_SETTINGS } from './types';
import type { PatchableSettings } from './types';
import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
interface DataRetentionSectionProps {
onDirtyChange?: (dirty: boolean) => void;
}
function SectionSkeleton() {
return (
<div className="space-y-3 rounded-lg border border-glass-border bg-glass p-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
type DataRetentionFields = Pick<PatchableSettings, 'metrics_retention_hours' | 'log_retention_days' | 'audit_retention_days' | 'scan_history_per_image_limit'>;
const DEFAULT_DATA_RETENTION: DataRetentionFields = {
metrics_retention_hours: DEFAULT_SETTINGS.metrics_retention_hours,
log_retention_days: DEFAULT_SETTINGS.log_retention_days,
audit_retention_days: DEFAULT_SETTINGS.audit_retention_days,
scan_history_per_image_limit: DEFAULT_SETTINGS.scan_history_per_image_limit,
};
export function DataRetentionSection({ onDirtyChange }: DataRetentionSectionProps) {
const { isAdmin } = useAuth();
const { isPaid } = useLicense();
const { activeNode } = useNodes();
const readOnly = !isAdmin;
const [settings, setSettings] = useState<DataRetentionFields>({ ...DEFAULT_DATA_RETENTION });
const serverSettingsRef = useRef<DataRetentionFields>({ ...DEFAULT_DATA_RETENTION });
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const dirtyCount = useMemo(() => {
const baseline = serverSettingsRef.current;
let n = 0;
if (settings.metrics_retention_hours !== baseline.metrics_retention_hours) n++;
if (settings.log_retention_days !== baseline.log_retention_days) n++;
if (settings.audit_retention_days !== baseline.audit_retention_days) n++;
if (settings.scan_history_per_image_limit !== baseline.scan_history_per_image_limit) n++;
return n;
}, [settings]);
const hasChanges = dirtyCount > 0;
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
useMastheadStats(
isLoading
? null
: [
{
label: 'EDITED',
value: hasChanges ? `${dirtyCount} pending` : 'saved',
tone: hasChanges ? 'warn' : 'value',
},
],
);
useEffect(() => {
const fetchSettings = async () => {
setIsLoading(true);
try {
const nodeRes = await apiFetch('/settings');
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: DataRetentionFields = {
metrics_retention_hours: nodeData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
log_retention_days: nodeData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
audit_retention_days: nodeData.audit_retention_days ?? DEFAULT_SETTINGS.audit_retention_days,
scan_history_per_image_limit: nodeData.scan_history_per_image_limit ?? DEFAULT_SETTINGS.scan_history_per_image_limit,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
} catch (e) {
console.error('Failed to fetch data retention settings', e);
} finally {
setIsLoading(false);
}
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
const onSettingChange = <K extends keyof DataRetentionFields>(key: K, value: DataRetentionFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
const payload: DataRetentionFields = {
metrics_retention_hours: settings.metrics_retention_hours,
log_retention_days: settings.log_retention_days,
scan_history_per_image_limit: settings.scan_history_per_image_limit,
};
// audit_retention_days is a paid-only key the backend rejects from a
// Community operator. The field renders only when isPaid, so include it
// in the save only then; otherwise a Community save would 403 on a key
// the operator cannot edit and never sees.
if (isPaid) {
payload.audit_retention_days = settings.audit_retention_days;
}
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
return;
}
serverSettingsRef.current = { ...settings };
toast.success('Data retention saved.');
window.dispatchEvent(new CustomEvent<SenchoSettingsChangedDetail>(SENCHO_SETTINGS_CHANGED, {
detail: { changedKeys: Object.keys(payload) },
}));
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
}
};
if (isLoading) return <SectionSkeleton />;
return (
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Retention windows">
<SettingsField
label="Container metrics"
helper="How long to keep per-container CPU, RAM, and network history."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={8760}
value={settings.metrics_retention_hours}
onChange={(e) => onSettingChange('metrics_retention_hours', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">hrs</span>
</div>
</SettingsField>
<SettingsField
label="Notification log"
helper="How long to keep alert and notification history."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={365}
value={settings.log_retention_days}
onChange={(e) => onSettingChange('log_retention_days', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">days</span>
</div>
</SettingsField>
<SettingsField
label="Scan history per image"
helper="How many vulnerability scans to keep per image. Older scans beyond the cap are pruned."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={5}
max={1000}
value={settings.scan_history_per_image_limit}
onChange={(e) => onSettingChange('scan_history_per_image_limit', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">scans</span>
</div>
</SettingsField>
{isPaid && (
<SettingsField
label="Audit log"
helper="How long to keep audit trail entries."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={365}
value={settings.audit_retention_days}
onChange={(e) => onSettingChange('audit_retention_days', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">days</span>
</div>
</SettingsField>
)}
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Saving
</>
) : (
'Save settings'
)}
</SettingsPrimaryButton>
)}
</SettingsActions>
</fieldset>
);
}
@@ -1,9 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { TogglePill } from '@/components/ui/toggle-pill';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { RefreshCw } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
@@ -25,25 +23,18 @@ function SectionSkeleton() {
return (
<div className="space-y-3 rounded-lg border border-glass-border bg-glass p-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
type DeveloperFields = Pick<PatchableSettings, 'developer_mode' | 'metrics_retention_hours' | 'log_retention_days' | 'audit_retention_days' | 'scan_history_per_image_limit'>;
type DeveloperFields = Pick<PatchableSettings, 'developer_mode'>;
const DEFAULT_DEVELOPER: DeveloperFields = {
developer_mode: DEFAULT_SETTINGS.developer_mode,
metrics_retention_hours: DEFAULT_SETTINGS.metrics_retention_hours,
log_retention_days: DEFAULT_SETTINGS.log_retention_days,
audit_retention_days: DEFAULT_SETTINGS.audit_retention_days,
scan_history_per_image_limit: DEFAULT_SETTINGS.scan_history_per_image_limit,
};
export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
const { isAdmin } = useAuth();
const { isPaid } = useLicense();
const { activeNode } = useNodes();
const readOnly = !isAdmin;
const [settings, setSettings] = useState<DeveloperFields>({ ...DEFAULT_DEVELOPER });
@@ -51,12 +42,7 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const hasChanges =
settings.developer_mode !== serverSettingsRef.current.developer_mode ||
settings.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours ||
settings.log_retention_days !== serverSettingsRef.current.log_retention_days ||
settings.audit_retention_days !== serverSettingsRef.current.audit_retention_days ||
settings.scan_history_per_image_limit !== serverSettingsRef.current.scan_history_per_image_limit;
const hasChanges = settings.developer_mode !== serverSettingsRef.current.developer_mode;
useEffect(() => {
onDirtyChange?.(hasChanges);
@@ -82,10 +68,6 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: DeveloperFields = {
developer_mode: (nodeData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
metrics_retention_hours: nodeData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
log_retention_days: nodeData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
audit_retention_days: nodeData.audit_retention_days ?? DEFAULT_SETTINGS.audit_retention_days,
scan_history_per_image_limit: nodeData.scan_history_per_image_limit ?? DEFAULT_SETTINGS.scan_history_per_image_limit,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
@@ -106,10 +88,6 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
const saveSettings = async () => {
const payload = {
developer_mode: settings.developer_mode,
metrics_retention_hours: settings.metrics_retention_hours,
log_retention_days: settings.log_retention_days,
audit_retention_days: settings.audit_retention_days,
scan_history_per_image_limit: settings.scan_history_per_image_limit,
};
setIsSaving(true);
try {
@@ -151,78 +129,6 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
</SettingsField>
</SettingsSection>
<SettingsSection title="Data retention">
<SettingsField
label="Container metrics"
helper="How long to keep per-container CPU, RAM, and network history."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={8760}
value={settings.metrics_retention_hours}
onChange={(e) => onSettingChange('metrics_retention_hours', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">hrs</span>
</div>
</SettingsField>
<SettingsField
label="Notification log"
helper="How long to keep alert and notification history."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={365}
value={settings.log_retention_days}
onChange={(e) => onSettingChange('log_retention_days', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">days</span>
</div>
</SettingsField>
<SettingsField
label="Scan history per image"
helper="How many vulnerability scans to keep per image. Older scans beyond the cap are pruned."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={5}
max={1000}
value={settings.scan_history_per_image_limit}
onChange={(e) => onSettingChange('scan_history_per_image_limit', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">scans</span>
</div>
</SettingsField>
{isPaid && (
<SettingsField
label="Audit log"
helper="How long to keep audit trail entries."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={365}
value={settings.audit_retention_days}
onChange={(e) => onSettingChange('audit_retention_days', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">days</span>
</div>
</SettingsField>
)}
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? 'unsaved changes' : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
@@ -0,0 +1,180 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { RefreshCw } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { DEFAULT_SETTINGS } from './types';
import type { PatchableSettings } from './types';
import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { TogglePill } from '@/components/ui/toggle-pill';
import { NumberChip } from './SystemControls';
interface DockerStorageSectionProps {
onDirtyChange?: (dirty: boolean) => void;
}
function SectionSkeleton() {
return (
<div className="space-y-3 rounded-lg border border-glass-border bg-glass p-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
type DockerStorageFields = Pick<PatchableSettings, 'docker_janitor_gb' | 'prune_on_update' | 'reclaim_hero'>;
const DEFAULT_DOCKER_STORAGE: DockerStorageFields = {
docker_janitor_gb: DEFAULT_SETTINGS.docker_janitor_gb,
prune_on_update: DEFAULT_SETTINGS.prune_on_update,
reclaim_hero: DEFAULT_SETTINGS.reclaim_hero,
};
export function DockerStorageSection({ onDirtyChange }: DockerStorageSectionProps) {
const { activeNode } = useNodes();
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const [settings, setSettings] = useState<DockerStorageFields>({ ...DEFAULT_DOCKER_STORAGE });
const serverSettingsRef = useRef<DockerStorageFields>({ ...DEFAULT_DOCKER_STORAGE });
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const dirtyCount = useMemo(() => {
const baseline = serverSettingsRef.current;
let n = 0;
if (settings.docker_janitor_gb !== baseline.docker_janitor_gb) n++;
if (settings.prune_on_update !== baseline.prune_on_update) n++;
if (settings.reclaim_hero !== baseline.reclaim_hero) n++;
return n;
}, [settings]);
const hasChanges = dirtyCount > 0;
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
useMastheadStats(
isLoading
? null
: [
{
label: 'EDITED',
value: hasChanges ? `${dirtyCount} pending` : 'saved',
tone: hasChanges ? 'warn' : 'value',
},
],
);
useEffect(() => {
const fetchSettings = async () => {
setIsLoading(true);
try {
const nodeRes = await apiFetch('/settings');
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: DockerStorageFields = {
docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
prune_on_update: (nodeData.prune_on_update as '0' | '1') ?? DEFAULT_SETTINGS.prune_on_update,
reclaim_hero: (nodeData.reclaim_hero as '0' | '1') ?? DEFAULT_SETTINGS.reclaim_hero,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
} catch (e) {
console.error('Failed to fetch Docker & storage settings', e);
} finally {
setIsLoading(false);
}
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
const onSettingChange = <K extends keyof DockerStorageFields>(key: K, value: DockerStorageFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
body: JSON.stringify(settings),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
return;
}
serverSettingsRef.current = { ...settings };
toast.success('Docker & storage settings saved.');
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
}
};
if (isLoading) return <SectionSkeleton />;
return (
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Storage alerts">
<SettingsField
label="Janitor threshold"
helper="Alert when reclaimable Docker data exceeds this size."
>
<NumberChip
value={settings.docker_janitor_gb || '5'}
onChange={(v) => onSettingChange('docker_janitor_gb', v)}
suffix="GiB"
min={0}
step={0.5}
warnOver={10}
/>
</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."
>
<TogglePill
checked={settings.reclaim_hero === '1'}
onChange={(next) => onSettingChange('reclaim_hero', next ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
<SettingsSection title="Image cleanup">
<SettingsField
label="Prune dangling images after updates"
helper="When an update finishes, remove the node's dangling (untagged) image layers, including the one the update just orphaned. On by default; turn it off to keep every old layer. Applies to stack updates and Sencho self-updates on this node."
>
<TogglePill
checked={settings.prune_on_update === '1'}
onChange={(next) => onSettingChange('prune_on_update', next ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Saving
</>
) : (
'Save settings'
)}
</SettingsPrimaryButton>
)}
</SettingsActions>
</fieldset>
);
}
@@ -0,0 +1,146 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { RefreshCw } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { DEFAULT_SETTINGS } from './types';
import type { PatchableSettings } from './types';
import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { TogglePill } from '@/components/ui/toggle-pill';
interface FleetMeshSectionProps {
onDirtyChange?: (dirty: boolean) => void;
}
function SectionSkeleton() {
return (
<div className="space-y-3 rounded-lg border border-glass-border bg-glass p-4">
<Skeleton className="h-10 w-full" />
</div>
);
}
type FleetMeshFields = Pick<PatchableSettings, 'mesh_auto_recreate'>;
const DEFAULT_FLEET_MESH: FleetMeshFields = {
mesh_auto_recreate: DEFAULT_SETTINGS.mesh_auto_recreate,
};
export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
const { activeNode } = useNodes();
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const [settings, setSettings] = useState<FleetMeshFields>({ ...DEFAULT_FLEET_MESH });
const serverSettingsRef = useRef<FleetMeshFields>({ ...DEFAULT_FLEET_MESH });
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const dirtyCount = useMemo(() => {
const baseline = serverSettingsRef.current;
let n = 0;
if (settings.mesh_auto_recreate !== baseline.mesh_auto_recreate) n++;
return n;
}, [settings]);
const hasChanges = dirtyCount > 0;
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
useMastheadStats(
isLoading
? null
: [
{
label: 'EDITED',
value: hasChanges ? `${dirtyCount} pending` : 'saved',
tone: hasChanges ? 'warn' : 'value',
},
],
);
useEffect(() => {
const fetchSettings = async () => {
setIsLoading(true);
try {
const nodeRes = await apiFetch('/settings');
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: FleetMeshFields = {
mesh_auto_recreate: (nodeData.mesh_auto_recreate as '0' | '1') ?? DEFAULT_SETTINGS.mesh_auto_recreate,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
} catch (e) {
console.error('Failed to fetch fleet mesh settings', e);
} finally {
setIsLoading(false);
}
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
const onSettingChange = <K extends keyof FleetMeshFields>(key: K, value: FleetMeshFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
body: JSON.stringify(settings),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
return;
}
serverSettingsRef.current = { ...settings };
toast.success('Mesh settings saved.');
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
}
};
if (isLoading) return <SectionSkeleton />;
return (
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Mesh data plane">
<SettingsField
label="Auto-recreate mesh network"
helper="If sencho_mesh is removed at runtime, rebuild it at the same subnet on the next 10s tick. Off by default; leave off and restart Sencho manually for the safest path."
>
<TogglePill
checked={settings.mesh_auto_recreate === '1'}
onChange={(next) => onSettingChange('mesh_auto_recreate', next ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Saving
</>
) : (
'Save settings'
)}
</SettingsPrimaryButton>
)}
</SettingsActions>
</fieldset>
);
}
@@ -0,0 +1,216 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { RefreshCw } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { DEFAULT_SETTINGS } from './types';
import type { PatchableSettings } from './types';
import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { TogglePill } from '@/components/ui/toggle-pill';
import { NumberChip } from './SystemControls';
interface HostAlertsSectionProps {
onDirtyChange?: (dirty: boolean) => void;
}
function SectionSkeleton() {
return (
<div className="space-y-3 rounded-lg border border-glass-border bg-glass p-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
type HostAlertFields = Pick<PatchableSettings, 'host_cpu_limit' | 'host_ram_limit' | 'host_disk_limit' | 'host_alert_suppression_mins' | 'global_crash'>;
const DEFAULT_HOST_ALERTS: HostAlertFields = {
host_cpu_limit: DEFAULT_SETTINGS.host_cpu_limit,
host_ram_limit: DEFAULT_SETTINGS.host_ram_limit,
host_disk_limit: DEFAULT_SETTINGS.host_disk_limit,
host_alert_suppression_mins: DEFAULT_SETTINGS.host_alert_suppression_mins,
global_crash: DEFAULT_SETTINGS.global_crash,
};
export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
const { activeNode } = useNodes();
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const [settings, setSettings] = useState<HostAlertFields>({ ...DEFAULT_HOST_ALERTS });
const serverSettingsRef = useRef<HostAlertFields>({ ...DEFAULT_HOST_ALERTS });
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const dirtyCount = useMemo(() => {
const baseline = serverSettingsRef.current;
let n = 0;
if (settings.host_cpu_limit !== baseline.host_cpu_limit) n++;
if (settings.host_ram_limit !== baseline.host_ram_limit) n++;
if (settings.host_disk_limit !== baseline.host_disk_limit) n++;
if (settings.host_alert_suppression_mins !== baseline.host_alert_suppression_mins) n++;
if (settings.global_crash !== baseline.global_crash) n++;
return n;
}, [settings]);
const hasChanges = dirtyCount > 0;
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
useMastheadStats(
isLoading
? null
: [
{
label: 'EDITED',
value: hasChanges ? `${dirtyCount} pending` : 'saved',
tone: hasChanges ? 'warn' : 'value',
},
],
);
useEffect(() => {
const fetchSettings = async () => {
setIsLoading(true);
try {
const nodeRes = await apiFetch('/settings');
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: HostAlertFields = {
host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
host_alert_suppression_mins: nodeData.host_alert_suppression_mins ?? DEFAULT_SETTINGS.host_alert_suppression_mins,
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
} catch (e) {
console.error('Failed to fetch host alert settings', e);
} finally {
setIsLoading(false);
}
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
const onSettingChange = <K extends keyof HostAlertFields>(key: K, value: HostAlertFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
body: JSON.stringify(settings),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
return;
}
serverSettingsRef.current = { ...settings };
toast.success('Host alerts saved.');
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
}
};
if (isLoading) return <SectionSkeleton />;
return (
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Host thresholds">
<SettingsField
label="CPU limit"
helper="Alerts fire when host CPU utilization exceeds this percentage."
>
<NumberChip
value={settings.host_cpu_limit || '90'}
onChange={(v) => onSettingChange('host_cpu_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
</SettingsField>
<SettingsField
label="RAM limit"
helper="Swap is never acceptable. Set this below where the host begins paging."
>
<NumberChip
value={settings.host_ram_limit || '90'}
onChange={(v) => onSettingChange('host_ram_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
</SettingsField>
<SettingsField
label="Disk limit"
helper="Low free space slows image pulls and backups."
>
<NumberChip
value={settings.host_disk_limit || '90'}
onChange={(v) => onSettingChange('host_disk_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
</SettingsField>
<SettingsField
label="Alert suppression"
helper="How long to wait before resending a host alert while the metric stays over threshold. The follow-up message includes a count of suppressed cycles."
>
<NumberChip
value={settings.host_alert_suppression_mins || '60'}
onChange={(v) => onSettingChange('host_alert_suppression_mins', v)}
suffix="min"
min={1}
max={1440}
/>
</SettingsField>
</SettingsSection>
<SettingsSection title="Crash capture">
<SettingsField
label="Global crash capture"
helper="Watch every managed container for unexpected exits."
>
<TogglePill
checked={settings.global_crash === '1'}
onChange={(next) => onSettingChange('global_crash', next ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Saving
</>
) : (
'Save alerts'
)}
</SettingsPrimaryButton>
)}
</SettingsActions>
</fieldset>
);
}
@@ -19,9 +19,12 @@ import {
AccountSection,
AppearanceSection,
LicenseSection,
SystemSection,
HostAlertsSection,
DockerStorageSection,
FleetMeshSection,
NotificationsSection,
DeveloperSection,
DataRetentionSection,
AppStoreSection,
SupportSection,
AboutSection,
@@ -191,19 +194,23 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
case 'api-tokens': return <ApiTokensSection />;
case 'registries': return <RegistriesSection />;
case 'labels': return <LabelsSection />;
case 'system': return <SystemSection onDirtyChange={(d) => handleDirtyChange('system', d)} />;
case 'host-alerts': return <HostAlertsSection onDirtyChange={(d) => handleDirtyChange('host-alerts', d)} />;
case 'docker-storage': return <DockerStorageSection onDirtyChange={(d) => handleDirtyChange('docker-storage', d)} />;
case 'fleet-mesh': return <FleetMeshSection onDirtyChange={(d) => handleDirtyChange('fleet-mesh', d)} />;
case 'notifications': return <NotificationsSection />;
case 'notification-routing': return <NotificationRoutingSection />;
case 'webhooks': return <WebhooksSection />;
case 'security': return <SecuritySection isPaid={isPaid} />;
case 'cloud-backup': return <CloudBackupSection />;
case 'developer': return <DeveloperSection onDirtyChange={(d) => handleDirtyChange('developer', d)} />;
case 'data-retention': return <DataRetentionSection onDirtyChange={(d) => handleDirtyChange('data-retention', d)} />;
case 'nodes': return <NodeManager />;
case 'app-store': return <AppStoreSection />;
case 'recovery': return <RecoverySection />;
case 'support': return <SupportSection />;
case 'about': return <AboutSection />;
default: return null;
// Exhaustiveness guard: a new SectionId without a case above fails tsc here.
default: return assertExhaustiveSection(safeSection);
}
// Section components close over isPaid for tier-gated branches; handleDirtyChange is stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -301,8 +308,19 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
);
}
// Compile-time check that the section switch covers every SectionId. If the
// switch is ever reached at runtime (it should not be, since safeSection is a
// validated registry id), log the unhandled id and render nothing rather than crash.
function assertExhaustiveSection(section: never): null {
console.error('Unhandled settings section', section);
return null;
}
function scopeLabel(item: SettingsItemMeta): string {
if (item.group === 'identity') return 'operator';
// Personal sections (account, appearance) apply to the signed-in operator or
// this browser. Access sections (license, users, sso, api-tokens) are
// instance-global, so they read as global like every other non-node group.
if (item.group === 'personal') return 'operator';
return 'global';
}
@@ -0,0 +1,83 @@
import { useState, useRef, useEffect } from 'react';
import { cn } from '@/lib/utils';
interface NumberChipProps {
value: string;
onChange: (v: string) => void;
suffix: string;
min?: number;
max?: number;
step?: number;
warnOver?: number;
}
export function NumberChip({ value, onChange, suffix, min, max, step = 1, warnOver }: NumberChipProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (editing) inputRef.current?.select();
}, [editing]);
const startEdit = () => {
setDraft(value);
setEditing(true);
};
const commit = () => {
const trimmed = draft.trim();
const parsed = Number(trimmed);
if (trimmed !== '' && Number.isFinite(parsed)) {
let next = parsed;
if (typeof min === 'number') next = Math.max(min, next);
if (typeof max === 'number') next = Math.min(max, next);
onChange(String(next));
}
setEditing(false);
};
const numeric = Number(value);
const warn = typeof warnOver === 'number' && Number.isFinite(numeric) && numeric > warnOver;
const chipClass = cn(
'inline-flex items-baseline gap-1 rounded-md border px-2.5 py-1 font-mono text-sm tabular-nums tracking-tight transition-colors min-w-[78px] justify-end focus-within:ring-2 focus-within:ring-brand/50 focus-within:outline-none',
warn
? 'border-warning/40 bg-warning/10 text-warning'
: 'border-card-border bg-card text-stat-value hover:border-brand/50',
);
if (editing) {
return (
<span className={chipClass}>
<input
ref={inputRef}
type="number"
min={min}
max={max}
step={step}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') setEditing(false);
}}
className="w-12 bg-transparent text-right outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<span className="text-stat-subtitle">{suffix}</span>
</span>
);
}
return (
<button
type="button"
className={cn(chipClass, 'focus-visible:ring-2 focus-visible:ring-brand/50 focus-visible:outline-none disabled:opacity-50 disabled:cursor-not-allowed')}
onClick={startEdit}
>
<span>{value || '0'}</span>
<span className="text-stat-subtitle">{suffix}</span>
</button>
);
}
@@ -1,386 +0,0 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { DEFAULT_SETTINGS } from './types';
import type { PatchableSettings } from './types';
import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
interface SystemSectionProps {
onDirtyChange?: (dirty: boolean) => void;
}
interface NumberChipProps {
value: string;
onChange: (v: string) => void;
suffix: string;
min?: number;
max?: number;
step?: number;
warnOver?: number;
}
function NumberChip({ value, onChange, suffix, min, max, step = 1, warnOver }: NumberChipProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (editing) inputRef.current?.select();
}, [editing]);
const startEdit = () => {
setDraft(value);
setEditing(true);
};
const commit = () => {
const trimmed = draft.trim();
const parsed = Number(trimmed);
if (trimmed !== '' && Number.isFinite(parsed)) {
let next = parsed;
if (typeof min === 'number') next = Math.max(min, next);
if (typeof max === 'number') next = Math.min(max, next);
onChange(String(next));
}
setEditing(false);
};
const numeric = Number(value);
const warn = typeof warnOver === 'number' && Number.isFinite(numeric) && numeric > warnOver;
const chipClass = cn(
'inline-flex items-baseline gap-1 rounded-md border px-2.5 py-1 font-mono text-sm tabular-nums tracking-tight transition-colors min-w-[78px] justify-end focus-within:ring-2 focus-within:ring-brand/50 focus-within:outline-none',
warn
? 'border-warning/40 bg-warning/10 text-warning'
: 'border-card-border bg-card text-stat-value hover:border-brand/50',
);
if (editing) {
return (
<span className={chipClass}>
<input
ref={inputRef}
type="number"
min={min}
max={max}
step={step}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') setEditing(false);
}}
className="w-12 bg-transparent text-right outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<span className="text-stat-subtitle">{suffix}</span>
</span>
);
}
return (
<button
type="button"
className={cn(chipClass, 'focus-visible:ring-2 focus-visible:ring-brand/50 focus-visible:outline-none disabled:opacity-50 disabled:cursor-not-allowed')}
onClick={startEdit}
>
<span>{value || '0'}</span>
<span className="text-stat-subtitle">{suffix}</span>
</button>
);
}
interface TogglePillProps {
checked: boolean;
onChange: (next: boolean) => void;
}
function TogglePill({ checked, onChange }: TogglePillProps) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
'inline-flex items-center justify-center rounded-md border px-2.5 py-1 font-mono text-xs uppercase tracking-[0.18em] transition-colors min-w-[60px] focus-visible:ring-2 focus-visible:ring-brand/50 focus-visible:outline-none disabled:opacity-50 disabled:cursor-not-allowed',
checked
? 'border-success/30 bg-success/10 text-success hover:bg-success/15'
: 'border-card-border bg-card text-stat-subtitle hover:text-stat-value',
)}
>
{checked ? 'ON' : 'OFF'}
</button>
);
}
function SettingsSkeleton() {
return (
<div className="space-y-3 rounded-lg border border-glass-border bg-glass p-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
type SystemFields = Pick<PatchableSettings, 'host_cpu_limit' | 'host_ram_limit' | 'host_disk_limit' | 'host_alert_suppression_mins' | 'docker_janitor_gb' | 'global_crash' | 'prune_on_update' | 'mesh_auto_recreate' | 'reclaim_hero'>;
const DEFAULT_SYSTEM: SystemFields = {
host_cpu_limit: DEFAULT_SETTINGS.host_cpu_limit,
host_ram_limit: DEFAULT_SETTINGS.host_ram_limit,
host_disk_limit: DEFAULT_SETTINGS.host_disk_limit,
host_alert_suppression_mins: DEFAULT_SETTINGS.host_alert_suppression_mins,
docker_janitor_gb: DEFAULT_SETTINGS.docker_janitor_gb,
global_crash: DEFAULT_SETTINGS.global_crash,
prune_on_update: DEFAULT_SETTINGS.prune_on_update,
mesh_auto_recreate: DEFAULT_SETTINGS.mesh_auto_recreate,
reclaim_hero: DEFAULT_SETTINGS.reclaim_hero,
};
export function SystemSection({ onDirtyChange }: SystemSectionProps) {
const { activeNode } = useNodes();
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const [settings, setSettings] = useState<SystemFields>({ ...DEFAULT_SYSTEM });
const serverSettingsRef = useRef<SystemFields>({ ...DEFAULT_SYSTEM });
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const dirtyCount = useMemo(() => {
const baseline = serverSettingsRef.current;
let n = 0;
if (settings.host_cpu_limit !== baseline.host_cpu_limit) n++;
if (settings.host_ram_limit !== baseline.host_ram_limit) n++;
if (settings.host_disk_limit !== baseline.host_disk_limit) n++;
if (settings.host_alert_suppression_mins !== baseline.host_alert_suppression_mins) n++;
if (settings.docker_janitor_gb !== baseline.docker_janitor_gb) n++;
if (settings.global_crash !== baseline.global_crash) n++;
if (settings.prune_on_update !== baseline.prune_on_update) n++;
if (settings.mesh_auto_recreate !== baseline.mesh_auto_recreate) n++;
if (settings.reclaim_hero !== baseline.reclaim_hero) n++;
return n;
}, [settings]);
const hasChanges = dirtyCount > 0;
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
useMastheadStats(
isLoading
? null
: [
{
label: 'EDITED',
value: hasChanges ? `${dirtyCount} pending` : 'saved',
tone: hasChanges ? 'warn' : 'value',
},
],
);
useEffect(() => {
const fetchSettings = async () => {
setIsLoading(true);
try {
const nodeRes = await apiFetch('/settings');
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: SystemFields = {
host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
host_alert_suppression_mins: nodeData.host_alert_suppression_mins ?? DEFAULT_SETTINGS.host_alert_suppression_mins,
docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
prune_on_update: (nodeData.prune_on_update as '0' | '1') ?? DEFAULT_SETTINGS.prune_on_update,
mesh_auto_recreate: (nodeData.mesh_auto_recreate as '0' | '1') ?? DEFAULT_SETTINGS.mesh_auto_recreate,
reclaim_hero: (nodeData.reclaim_hero as '0' | '1') ?? DEFAULT_SETTINGS.reclaim_hero,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
} catch (e) {
console.error('Failed to fetch system settings', e);
} finally {
setIsLoading(false);
}
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
const onSettingChange = <K extends keyof SystemFields>(key: K, value: SystemFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
body: JSON.stringify(settings),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
return;
}
serverSettingsRef.current = { ...settings };
toast.success('System limits saved.');
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
}
};
if (isLoading) return <SettingsSkeleton />;
return (
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Host thresholds">
<SettingsField
label="CPU limit"
helper="Alerts fire when host CPU utilization exceeds this percentage."
>
<NumberChip
value={settings.host_cpu_limit || '90'}
onChange={(v) => onSettingChange('host_cpu_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
</SettingsField>
<SettingsField
label="RAM limit"
helper="Swap is never acceptable. Set this below where the host begins paging."
>
<NumberChip
value={settings.host_ram_limit || '90'}
onChange={(v) => onSettingChange('host_ram_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
</SettingsField>
<SettingsField
label="Disk limit"
helper="Low free space slows image pulls and backups."
>
<NumberChip
value={settings.host_disk_limit || '90'}
onChange={(v) => onSettingChange('host_disk_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
</SettingsField>
<SettingsField
label="Alert suppression"
helper="How long to wait before resending a host alert while the metric stays over threshold. The follow-up message includes a count of suppressed cycles."
>
<NumberChip
value={settings.host_alert_suppression_mins || '60'}
onChange={(v) => onSettingChange('host_alert_suppression_mins', v)}
suffix="min"
min={1}
max={1440}
/>
</SettingsField>
</SettingsSection>
<SettingsSection title="Docker hygiene">
<SettingsField
label="Janitor threshold"
helper="Alert when reclaimable Docker data exceeds this size."
>
<NumberChip
value={settings.docker_janitor_gb || '5'}
onChange={(v) => onSettingChange('docker_janitor_gb', v)}
suffix="GiB"
min={0}
step={0.5}
warnOver={10}
/>
</SettingsField>
<SettingsField
label="Global crash capture"
helper="Watch every managed container for unexpected exits."
>
<TogglePill
checked={settings.global_crash === '1'}
onChange={(next) => onSettingChange('global_crash', next ? '1' : '0')}
/>
</SettingsField>
<SettingsField
label="Prune dangling images after updates"
helper="When an update finishes, remove the node's dangling (untagged) image layers, including the one the update just orphaned. On by default; turn it off to keep every old layer. Applies to stack updates and Sencho self-updates on this node."
>
<TogglePill
checked={settings.prune_on_update === '1'}
onChange={(next) => onSettingChange('prune_on_update', next ? '1' : '0')}
/>
</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."
>
<TogglePill
checked={settings.reclaim_hero === '1'}
onChange={(next) => onSettingChange('reclaim_hero', next ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
{/*
Mesh-data-plane recreate touches Docker (createNetwork +
connectContainerToNetwork) on the backend, which is admin-gated
by `requireAdmin` on the settings route. Hide the affordance
for non-admins so the toggle does not surface a 403 on save.
The rest of the System section stays visible because it
matches the existing pattern (host thresholds, janitor, alert
suppression all visible read-only to non-admins).
*/}
{isAdmin && (
<SettingsSection title="Mesh data plane">
<SettingsField
label="Auto-recreate mesh network"
helper="If sencho_mesh is removed at runtime, rebuild it at the same subnet on the next 10s tick. Off by default; leave off and restart Sencho manually for the safest path."
>
<TogglePill
checked={settings.mesh_auto_recreate === '1'}
onChange={(next) => onSettingChange('mesh_auto_recreate', next ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
)}
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Saving
</>
) : (
'Save limits'
)}
</SettingsPrimaryButton>
)}
</SettingsActions>
</fieldset>
);
}
@@ -0,0 +1,133 @@
/**
* The System Limits split is only correct if each successor section saves just
* its own keys. The old SystemSection PATCHed all nine keys at once; after the
* split a save from one section must not carry another section's keys, otherwise
* editing Host Alerts could clobber a concurrently-changed Docker setting.
*
* Each test loads a section, makes one change, saves, and asserts the PATCH body
* contains exactly that section's key set.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true }) }));
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' } }) }));
vi.mock('@/context/LicenseContext', () => ({ useLicense: vi.fn(() => ({ isPaid: true })) }));
vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {} }));
import { apiFetch } from '@/lib/api';
import { useLicense } from '@/context/LicenseContext';
import { HostAlertsSection } from '../HostAlertsSection';
import { DockerStorageSection } from '../DockerStorageSection';
import { FleetMeshSection } from '../FleetMeshSection';
import { DataRetentionSection } from '../DataRetentionSection';
import { DeveloperSection } from '../DeveloperSection';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const mockedLicense = useLicense as unknown as ReturnType<typeof vi.fn>;
const FULL_SETTINGS: Record<string, string> = {
host_cpu_limit: '90',
host_ram_limit: '90',
host_disk_limit: '90',
host_alert_suppression_mins: '60',
global_crash: '1',
docker_janitor_gb: '5',
prune_on_update: '1',
reclaim_hero: '1',
mesh_auto_recreate: '0',
metrics_retention_hours: '24',
log_retention_days: '30',
audit_retention_days: '90',
scan_history_per_image_limit: '50',
developer_mode: '0',
};
function patchedKeys(): string[] {
const patch = [...mockedFetch.mock.calls].reverse().find(c => c[1]?.method === 'PATCH');
if (!patch) throw new Error('expected a PATCH /settings call');
return Object.keys(JSON.parse(patch[1].body as string)).sort();
}
beforeEach(() => {
mockedFetch.mockReset();
mockedFetch.mockResolvedValue({ ok: true, json: async () => ({ ...FULL_SETTINGS }) });
mockedLicense.mockReturnValue({ isPaid: true });
});
describe('split section save payloads', () => {
it('HostAlertsSection patches only host alert keys', async () => {
render(<HostAlertsSection />);
const save = await screen.findByRole('button', { name: /save alerts/i });
fireEvent.click(screen.getByRole('switch')); // global_crash
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
expect(patchedKeys()).toEqual([
'global_crash',
'host_alert_suppression_mins',
'host_cpu_limit',
'host_disk_limit',
'host_ram_limit',
]);
});
it('DockerStorageSection patches only docker and storage keys', async () => {
render(<DockerStorageSection />);
const save = await screen.findByRole('button', { name: /save settings/i });
fireEvent.click(screen.getAllByRole('switch')[0]); // reclaim_hero
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
expect(patchedKeys()).toEqual(['docker_janitor_gb', 'prune_on_update', 'reclaim_hero']);
});
it('FleetMeshSection patches only the mesh key', async () => {
render(<FleetMeshSection />);
const save = await screen.findByRole('button', { name: /save settings/i });
fireEvent.click(screen.getByRole('switch')); // mesh_auto_recreate
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
expect(patchedKeys()).toEqual(['mesh_auto_recreate']);
});
it('DataRetentionSection patches only retention keys, never developer_mode', async () => {
render(<DataRetentionSection />);
const save = await screen.findByRole('button', { name: /save settings/i });
fireEvent.change(screen.getAllByRole('spinbutton')[0], { target: { value: '48' } }); // metrics window
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
expect(patchedKeys()).toEqual([
'audit_retention_days',
'log_retention_days',
'metrics_retention_hours',
'scan_history_per_image_limit',
]);
});
it('DataRetentionSection omits the paid audit_retention_days key for a Community operator', async () => {
mockedLicense.mockReturnValue({ isPaid: false });
render(<DataRetentionSection />);
const save = await screen.findByRole('button', { name: /save settings/i });
fireEvent.change(screen.getAllByRole('spinbutton')[0], { target: { value: '48' } }); // metrics window
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
// audit_retention_days is paid-gated; sending it would 403 the whole save.
expect(patchedKeys()).toEqual([
'log_retention_days',
'metrics_retention_hours',
'scan_history_per_image_limit',
]);
});
it('DeveloperSection patches only developer_mode', async () => {
render(<DeveloperSection />);
const save = await screen.findByRole('button', { name: /save settings/i });
fireEvent.click(screen.getByRole('switch')); // developer_mode
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
expect(patchedKeys()).toEqual(['developer_mode']);
});
});
@@ -0,0 +1,73 @@
/**
* Structural invariants for the settings registry after the hub reorganization.
*
* Guards the ten-group taxonomy and the System Limits split: every item lands in
* a real group, the three successor sections (Host Alerts, Docker & Storage,
* Fleet Mesh) exist with the right group/scope/gate, Developer is split from
* Data Retention, and the renamed labels are applied.
*/
import { describe, it, expect } from 'vitest';
import { SETTINGS_GROUPS, SETTINGS_ITEMS } from '../registry';
describe('settings registry', () => {
it('points every item at a defined group', () => {
const groupIds = new Set(SETTINGS_GROUPS.map(g => g.id));
for (const item of SETTINGS_ITEMS) {
expect(groupIds.has(item.group), `item ${item.id} -> group ${item.group}`).toBe(true);
}
});
it('gives every group at least one item', () => {
for (const group of SETTINGS_GROUPS) {
const count = SETTINGS_ITEMS.filter(i => i.group === group.id).length;
expect(count, `group ${group.id}`).toBeGreaterThan(0);
}
});
it('keeps item ids unique', () => {
const ids = SETTINGS_ITEMS.map(i => i.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('replaces System Limits with three focused sections', () => {
expect(SETTINGS_ITEMS.some(i => (i.id as string) === 'system')).toBe(false);
const hostAlerts = SETTINGS_ITEMS.find(i => i.id === 'host-alerts');
const dockerStorage = SETTINGS_ITEMS.find(i => i.id === 'docker-storage');
const fleetMesh = SETTINGS_ITEMS.find(i => i.id === 'fleet-mesh');
expect(hostAlerts?.group).toBe('monitoring');
expect(dockerStorage?.group).toBe('monitoring');
expect(fleetMesh?.group).toBe('infrastructure');
// All three edit the active instance's own settings through the proxy.
expect(hostAlerts?.scope).toBe('node');
expect(dockerStorage?.scope).toBe('node');
expect(fleetMesh?.scope).toBe('node');
});
it('gates the Fleet Mesh section to admins so the sidebar entry and panel both hide', () => {
const fleetMesh = SETTINGS_ITEMS.find(i => i.id === 'fleet-mesh');
expect(fleetMesh?.adminOnly).toBe(true);
});
it('splits Developer into Developer Diagnostics and Data Retention under Operations', () => {
const developer = SETTINGS_ITEMS.find(i => i.id === 'developer');
const dataRetention = SETTINGS_ITEMS.find(i => i.id === 'data-retention');
expect(developer?.label).toBe('Developer Diagnostics');
expect(developer?.group).toBe('operations');
expect(dataRetention?.group).toBe('operations');
});
it('applies the renamed section labels', () => {
const byId = new Map(SETTINGS_ITEMS.map(i => [i.id, i]));
expect(byId.get('notifications')?.label).toBe('Channels');
expect(byId.get('notification-routing')?.label).toBe('Notification Routing');
expect(byId.get('security')?.label).toBe('Vulnerability Scanning');
});
it('preserves the paid gate on Registries', () => {
const registries = SETTINGS_ITEMS.find(i => i.id === 'registries');
expect(registries?.tier).toBe('paid');
});
});
+4 -1
View File
@@ -3,9 +3,12 @@
export { AccountSection } from './AccountSection';
export { AppearanceSection } from './AppearanceSection';
export { LicenseSection } from './LicenseSection';
export { SystemSection } from './SystemSection';
export { HostAlertsSection } from './HostAlertsSection';
export { DockerStorageSection } from './DockerStorageSection';
export { FleetMeshSection } from './FleetMeshSection';
export { NotificationsSection } from './NotificationsSection';
export { DeveloperSection } from './DeveloperSection';
export { DataRetentionSection } from './DataRetentionSection';
export { AppStoreSection } from './AppStoreSection';
export { SupportSection } from './SupportSection';
export { AboutSection } from './AboutSection';
+103 -49
View File
@@ -1,6 +1,16 @@
import type { SectionId } from './types';
export type SettingsGroupId = 'identity' | 'system' | 'alerts' | 'advanced';
export type SettingsGroupId =
| 'personal'
| 'access'
| 'infrastructure'
| 'monitoring'
| 'notifications'
| 'automation'
| 'organization'
| 'security'
| 'operations'
| 'help';
export interface SettingsGroupMeta {
id: SettingsGroupId;
@@ -10,10 +20,16 @@ export interface SettingsGroupMeta {
}
export const SETTINGS_GROUPS: readonly SettingsGroupMeta[] = [
{ id: 'identity', label: 'Identity', glyph: '\u25C8' },
{ id: 'system', label: 'System', kicker: 'node-scoped', glyph: '\u25C6' },
{ id: 'alerts', label: 'Alerts', glyph: '\u25C7' },
{ id: 'advanced', label: 'Advanced', glyph: '\u25C7' },
{ id: 'personal', label: 'Personal', glyph: '\u25C8' },
{ id: 'access', label: 'Access', glyph: '\u25C8' },
{ id: 'infrastructure', label: 'Infrastructure', glyph: '\u25C6' },
{ id: 'monitoring', label: 'Monitoring', kicker: 'node-scoped', glyph: '\u25C6' },
{ id: 'notifications', label: 'Notifications', glyph: '\u25C7' },
{ id: 'automation', label: 'Automation', glyph: '\u25C7' },
{ id: 'organization', label: 'Organization', glyph: '\u25C7' },
{ id: 'security', label: 'Security', glyph: '\u25C6' },
{ id: 'operations', label: 'Operations', glyph: '\u25C7' },
{ id: 'help', label: 'Help', glyph: '\u25C7' },
];
export type TierGate = 'paid' | null;
@@ -32,9 +48,10 @@ export interface SettingsItemMeta {
}
export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
// Personal
{
id: 'account',
group: 'identity',
group: 'personal',
label: 'Account',
description: 'Password, MFA, and session controls for the signed-in operator.',
keywords: ['password', 'mfa', 'two-factor', 'session', 'profile'],
@@ -44,16 +61,17 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
},
{
id: 'appearance',
group: 'identity',
group: 'personal',
label: 'Appearance',
description: 'Theme, accent, density, and display preferences saved to this browser.',
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display'],
tier: null,
scope: 'global',
},
// Access
{
id: 'license',
group: 'identity',
group: 'access',
label: 'License',
description: 'Activation key, plan tier, and seat allocation.',
keywords: ['key', 'activation', 'tier', 'plan', 'seats', 'billing'],
@@ -63,7 +81,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
},
{
id: 'users',
group: 'identity',
group: 'access',
label: 'Users',
description: 'Operators, role assignments, and access scopes.',
keywords: ['operators', 'team', 'rbac', 'roles', 'permissions'],
@@ -74,7 +92,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
},
{
id: 'sso',
group: 'identity',
group: 'access',
label: 'SSO',
description: 'Single sign-on via SAML or OIDC identity providers.',
keywords: ['saml', 'oidc', 'okta', 'entra', 'azure', 'login'],
@@ -85,7 +103,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
},
{
id: 'api-tokens',
group: 'identity',
group: 'access',
label: 'API Tokens',
description: 'Long-lived bearer tokens for CI and scripts.',
keywords: ['bearer', 'automation', 'ci', 'scripts', 'scopes'],
@@ -94,18 +112,30 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
adminOnly: true,
hiddenOnRemote: true,
},
// Infrastructure
{
id: 'system',
group: 'system',
label: 'System Limits',
description: 'Threshold percentages for host CPU, RAM, disk, and crash-loop alerts.',
keywords: ['cpu', 'ram', 'disk', 'limits', 'thresholds', 'alerts'],
id: 'nodes',
group: 'infrastructure',
label: 'Nodes',
description: 'Remote Sencho instances proxied through this control plane.',
keywords: ['fleet', 'remote', 'proxy', 'node', 'cluster'],
tier: null,
scope: 'global',
hiddenOnRemote: true,
},
{
id: 'fleet-mesh',
group: 'infrastructure',
label: 'Fleet Mesh',
description: 'Data-plane network behavior for the cross-node service mesh.',
keywords: ['mesh', 'network', 'recreate', 'fleet', 'routing', 'data plane', 'sencho_mesh'],
tier: null,
scope: 'node',
adminOnly: true,
},
{
id: 'registries',
group: 'system',
group: 'infrastructure',
label: 'Registries',
description: 'Private Docker registries and pull credentials.',
keywords: ['docker', 'ghcr', 'ecr', 'private', 'pull', 'auth'],
@@ -116,7 +146,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
},
{
id: 'cloud-backup',
group: 'system',
group: 'infrastructure',
label: 'Cloud Backup',
description: 'Mirror fleet snapshots to Sencho Cloud Backup or any S3-compatible storage.',
keywords: ['cloud', 'backup', 'snapshot', 's3', 'r2', 'minio', 'storage', 'offsite'],
@@ -126,28 +156,47 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
hiddenOnRemote: true,
},
{
id: 'nodes',
group: 'system',
label: 'Nodes',
description: 'Remote Sencho instances proxied through this control plane.',
keywords: ['fleet', 'remote', 'proxy', 'node', 'cluster'],
id: 'app-store',
group: 'infrastructure',
label: 'App Store',
description: 'Template registry URL and featured-catalog source.',
keywords: ['templates', 'registry', 'catalog', 'featured'],
tier: null,
scope: 'global',
hiddenOnRemote: true,
scope: 'node',
},
// Monitoring
{
id: 'host-alerts',
group: 'monitoring',
label: 'Host Alerts',
description: 'Alert thresholds for host CPU, RAM, and disk, plus suppression cadence and container crash capture.',
keywords: ['cpu', 'ram', 'disk', 'thresholds', 'alerts', 'suppression', 'crash', 'host', 'limits'],
tier: null,
scope: 'node',
},
{
id: 'docker-storage',
group: 'monitoring',
label: 'Docker & Storage',
description: 'Reclaimable-space alerts and Docker image cleanup after updates.',
keywords: ['docker', 'janitor', 'prune', 'reclaim', 'storage', 'images', 'cleanup', 'dangling'],
tier: null,
scope: 'node',
},
// Notifications
{
id: 'notifications',
group: 'alerts',
label: 'Notifications',
description: 'In-app toasts and browser push for stack, container, and system events.',
keywords: ['toasts', 'push', 'events', 'alerts', 'inbox'],
group: 'notifications',
label: 'Channels',
description: 'Discord, Slack, and custom webhook destinations for Sencho alerts.',
keywords: ['discord', 'slack', 'webhook', 'channels', 'destinations', 'alerts'],
tier: null,
scope: 'node',
},
{
id: 'notification-routing',
group: 'alerts',
label: 'Routing',
group: 'notifications',
label: 'Notification Routing',
description: 'Rules that steer alerts to the right channel based on severity or label.',
keywords: ['rules', 'routing', 'channels', 'severity', 'labels'],
tier: null,
@@ -155,9 +204,10 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
adminOnly: true,
hiddenOnRemote: true,
},
// Automation
{
id: 'webhooks',
group: 'alerts',
group: 'automation',
label: 'Webhooks',
description: 'Incoming HMAC-signed HTTP triggers that run stack actions from CI/CD pipelines.',
keywords: ['webhook', 'incoming', 'trigger', 'ci', 'cd', 'pipeline', 'deploy', 'hmac', 'signature', 'action'],
@@ -165,46 +215,49 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
scope: 'global',
hiddenOnRemote: true,
},
// Organization
{
id: 'labels',
group: 'advanced',
group: 'organization',
label: 'Labels',
description: 'Per-node labels for stacks and containers.',
keywords: ['labels', 'tags', 'palette', 'organisation'],
tier: null,
scope: 'node',
},
// Security
{
id: 'security',
group: 'advanced',
label: 'Security',
group: 'security',
label: 'Vulnerability Scanning',
description: 'Image scanning, suppressions, and posture defaults.',
keywords: ['scan', 'cve', 'trivy', 'suppressions', 'hardening'],
keywords: ['scan', 'cve', 'trivy', 'suppressions', 'hardening', 'vulnerability', 'misconfig'],
tier: null,
scope: 'node',
adminOnly: true,
},
// Operations
{
id: 'developer',
group: 'advanced',
label: 'Developer',
description: 'Retention windows and debug modes.',
keywords: ['retention', 'logs', 'metrics', 'debug', 'developer'],
id: 'data-retention',
group: 'operations',
label: 'Data Retention',
description: 'How long to keep container metrics, notification logs, scan history, and audit entries.',
keywords: ['retention', 'metrics', 'logs', 'scans', 'audit', 'history', 'prune', 'window'],
tier: null,
scope: 'node',
},
{
id: 'app-store',
group: 'advanced',
label: 'App Store',
description: 'Template registry URL and featured-catalog source.',
keywords: ['templates', 'registry', 'catalog', 'featured'],
id: 'developer',
group: 'operations',
label: 'Developer Diagnostics',
description: 'Developer mode for real-time metrics streams and verbose debug diagnostics.',
keywords: ['developer', 'debug', 'diagnostics', 'metrics', 'verbose'],
tier: null,
scope: 'node',
},
{
id: 'recovery',
group: 'advanced',
group: 'operations',
label: 'Recovery',
description: 'System health snapshot, safe recovery actions, and emergency command-line reference.',
keywords: ['recovery', 'safe mode', 'diagnostics', 'health', 'emergency', 'cli', 'reset', 'backup', 'restore'],
@@ -213,9 +266,10 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
adminOnly: true,
hiddenOnRemote: true,
},
// Help
{
id: 'support',
group: 'advanced',
group: 'help',
label: 'Support',
description: 'Diagnostics bundle, docs links, and contact channels.',
keywords: ['help', 'diagnostics', 'bundle', 'docs', 'contact'],
@@ -224,7 +278,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
},
{
id: 'about',
group: 'advanced',
group: 'help',
label: 'About',
description: 'Build metadata, release notes, and licence attributions.',
keywords: ['version', 'build', 'release', 'attributions'],
+4 -1
View File
@@ -43,12 +43,15 @@ export type SectionId =
| 'api-tokens'
| 'registries'
| 'labels'
| 'system'
| 'host-alerts'
| 'docker-storage'
| 'fleet-mesh'
| 'notifications'
| 'webhooks'
| 'security'
| 'cloud-backup'
| 'developer'
| 'data-retention'
| 'nodes'
| 'app-store'
| 'notification-routing'