diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx index b259dea9..8ccb2204 100644 --- a/docs/features/alerts-notifications.mdx +++ b/docs/features/alerts-notifications.mdx @@ -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. - 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. + 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. ## 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 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. - 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. + 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. ### 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**. 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: `, 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. @@ -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**. - 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. + 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. | 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**. - 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.' + 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.' 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 - 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. 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. - 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. 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. diff --git a/docs/features/api-tokens.mdx b/docs/features/api-tokens.mdx index 0d5859bd..a9b544b6 100644 --- a/docs/features/api-tokens.mdx +++ b/docs/features/api-tokens.mdx @@ -52,13 +52,13 @@ Finally, scope-enforced API tokens compose with the fleet. When a request carrie ## Creating a token - + API Tokens settings tab, empty state - - Click your avatar, then **Settings**. The tab lives in the **Identity** group of the Settings sidebar. + + Click your avatar, then **Settings**. The tab lives in the **Access** group of the Settings sidebar. 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. - + API Tokens settings tab listing three tokens, one of each scope @@ -358,7 +358,7 @@ Read Only is sufficient for the notification stream. - 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**. diff --git a/docs/features/app-store.mdx b/docs/features/app-store.mdx index 411ee875..07eab102 100644 --- a/docs/features/app-store.mdx +++ b/docs/features/app-store.mdx @@ -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: - 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 + 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 -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 - 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. 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. diff --git a/docs/features/audit-log.mdx b/docs/features/audit-log.mdx index a1367018..d2740618 100644 --- a/docs/features/audit-log.mdx +++ b/docs/features/audit-log.mdx @@ -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. - 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. diff --git a/docs/features/auto-heal-policies.mdx b/docs/features/auto-heal-policies.mdx index 65f6da9b..23df7109 100644 --- a/docs/features/auto-heal-policies.mdx +++ b/docs/features/auto-heal-policies.mdx @@ -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 diff --git a/docs/features/auto-update-policies.mdx b/docs/features/auto-update-policies.mdx index 28c3b777..83eee22f 100644 --- a/docs/features/auto-update-policies.mdx +++ b/docs/features/auto-update-policies.mdx @@ -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. - 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. 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. diff --git a/docs/features/blueprint-model.mdx b/docs/features/blueprint-model.mdx index 8cf17f0c..6165af15 100644 --- a/docs/features/blueprint-model.mdx +++ b/docs/features/blueprint-model.mdx @@ -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. - - Settings System Nodes page with the Labels column and add-label popover + + Settings Infrastructure Nodes page with the Labels column and add-label popover ### 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. - 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. 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. diff --git a/docs/features/cve-suppressions.mdx b/docs/features/cve-suppressions.mdx index 9028452c..2c6ccb52 100644 --- a/docs/features/cve-suppressions.mdx +++ b/docs/features/cve-suppressions.mdx @@ -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**. 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. - 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. 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. diff --git a/docs/features/dashboard.mdx b/docs/features/dashboard.mdx index dd27e7ba..77faa289 100644 --- a/docs/features/dashboard.mdx +++ b/docs/features/dashboard.mdx @@ -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. - 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. diff --git a/docs/features/deploy-enforcement.mdx b/docs/features/deploy-enforcement.mdx index ac31d221..9189a41b 100644 --- a/docs/features/deploy-enforcement.mdx +++ b/docs/features/deploy-enforcement.mdx @@ -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: ` 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: ` and `block` badges, the configured stack-pattern scope, and pencil and trash buttons. 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 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. diff --git a/docs/features/deploy-progress.mdx b/docs/features/deploy-progress.mdx index 007263a5..295dcdf5 100644 --- a/docs/features/deploy-progress.mdx +++ b/docs/features/deploy-progress.mdx @@ -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. - 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.' + 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.' ## Using the modal diff --git a/docs/features/fleet-backups.mdx b/docs/features/fleet-backups.mdx index eea93e2b..1fad727d 100644 --- a/docs/features/fleet-backups.mdx +++ b/docs/features/fleet-backups.mdx @@ -93,14 +93,14 @@ Admins can delete snapshots from the list view by clicking the trash icon on the ## Cloud Backup - 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**. -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 - 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. 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. - 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. 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). - 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. diff --git a/docs/features/fleet-sync.mdx b/docs/features/fleet-sync.mdx index a688a46c..d2a9c08a 100644 --- a/docs/features/fleet-sync.mdx +++ b/docs/features/fleet-sync.mdx @@ -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). - 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'. + 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'. ## 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. - 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.' + 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.' - **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. - 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. diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 7f152365..cefc158c 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -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 - 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. 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. - 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. diff --git a/docs/features/global-observability.mdx b/docs/features/global-observability.mdx index d366893e..dfafa75c 100644 --- a/docs/features/global-observability.mdx +++ b/docs/features/global-observability.mdx @@ -183,5 +183,5 @@ The page is built from several independent loops so the masthead and the sparkli - 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. diff --git a/docs/features/multi-node.mdx b/docs/features/multi-node.mdx index c44a52de..0a088fd1 100644 --- a/docs/features/multi-node.mdx +++ b/docs/features/multi-node.mdx @@ -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. - 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. + 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. ## 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**. 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. 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. 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. diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index d27e6895..4472b1e0 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -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 diff --git a/docs/features/pilot-agent.mdx b/docs/features/pilot-agent.mdx index a3516ae4..7f48bb11 100644 --- a/docs/features/pilot-agent.mdx +++ b/docs/features/pilot-agent.mdx @@ -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:` 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). - 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'. + 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'. What rides through the tunnel: @@ -302,7 +302,7 @@ The generic node-connectivity issues (a node showing Offline, a pilot agent stuc - 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. diff --git a/docs/features/private-registries.mdx b/docs/features/private-registries.mdx index 6e1e1bca..c4a581e1 100644 --- a/docs/features/private-registries.mdx +++ b/docs/features/private-registries.mdx @@ -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 - 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. diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx index 95c8739e..db14642f 100644 --- a/docs/features/rbac.mdx +++ b/docs/features/rbac.mdx @@ -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. - 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. + 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. 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. diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx index d06c65bd..b2c2339f 100644 --- a/docs/features/resources.mdx +++ b/docs/features/resources.mdx @@ -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. 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. - 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**. diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index 09cbea8a..50aaf8a2 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -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. - 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. 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. diff --git a/docs/features/sencho-mesh.mdx b/docs/features/sencho-mesh.mdx index c926cc4b..32e44c6d 100644 --- a/docs/features/sencho-mesh.mdx +++ b/docs/features/sencho-mesh.mdx @@ -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 ` + 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 ` + 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 diff --git a/docs/features/sso.mdx b/docs/features/sso.mdx index fb917abf..6bffce65 100644 --- a/docs/features/sso.mdx +++ b/docs/features/sso.mdx @@ -301,7 +301,7 @@ If not set, Sencho auto-detects the URL from the request's `Host` header and pro - 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. diff --git a/docs/features/stack-labels.mdx b/docs/features/stack-labels.mdx index fb352c08..9b344e8d 100644 --- a/docs/features/stack-labels.mdx +++ b/docs/features/stack-labels.mdx @@ -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. - 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. + 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. 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. 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 - 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. 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. - 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. 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. diff --git a/docs/features/vulnerability-scanning.mdx b/docs/features/vulnerability-scanning.mdx index 645aafa3..2578337a 100644 --- a/docs/features/vulnerability-scanning.mdx +++ b/docs/features/vulnerability-scanning.mdx @@ -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. - 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. ## 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 - 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. 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. - 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. 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. - 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. - 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 "" 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 "" 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. 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. - 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. - 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. 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. diff --git a/docs/features/webhooks.mdx b/docs/features/webhooks.mdx index 4b29866f..76fb768b 100644 --- a/docs/features/webhooks.mdx +++ b/docs/features/webhooks.mdx @@ -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. 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 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. diff --git a/docs/getting-started/introduction.mdx b/docs/getting-started/introduction.mdx index ba470f79..b3c79acb 100644 --- a/docs/getting-started/introduction.mdx +++ b/docs/getting-started/introduction.mdx @@ -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 diff --git a/docs/images/alerts-notifications/system-crash-toggle.png b/docs/images/alerts-notifications/system-crash-toggle.png index 55828ff4..cb20eaba 100644 Binary files a/docs/images/alerts-notifications/system-crash-toggle.png and b/docs/images/alerts-notifications/system-crash-toggle.png differ diff --git a/docs/images/settings/settings-developer-section.png b/docs/images/settings/settings-developer-section.png deleted file mode 100644 index 0f07cbe8..00000000 Binary files a/docs/images/settings/settings-developer-section.png and /dev/null differ diff --git a/docs/images/settings/settings-host-alerts.png b/docs/images/settings/settings-host-alerts.png new file mode 100644 index 00000000..cb20eaba Binary files /dev/null and b/docs/images/settings/settings-host-alerts.png differ diff --git a/docs/images/settings/settings-hub-grouped.png b/docs/images/settings/settings-hub-grouped.png index ff4ad27c..6b5cf184 100644 Binary files a/docs/images/settings/settings-hub-grouped.png and b/docs/images/settings/settings-hub-grouped.png differ diff --git a/docs/images/settings/settings-node-scope.png b/docs/images/settings/settings-node-scope.png index bfc2a289..cb20eaba 100644 Binary files a/docs/images/settings/settings-node-scope.png and b/docs/images/settings/settings-node-scope.png differ diff --git a/docs/images/settings/settings-system-limits.png b/docs/images/settings/settings-system-limits.png deleted file mode 100644 index 91bdb7ca..00000000 Binary files a/docs/images/settings/settings-system-limits.png and /dev/null differ diff --git a/docs/operations/trivy-setup.mdx b/docs/operations/trivy-setup.mdx index 8ab38f42..a8f722cb 100644 --- a/docs/operations/trivy-setup.mdx +++ b/docs/operations/trivy-setup.mdx @@ -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. diff --git a/docs/operations/troubleshooting.mdx b/docs/operations/troubleshooting.mdx index 6213c3e4..52ac216a 100644 --- a/docs/operations/troubleshooting.mdx +++ b/docs/operations/troubleshooting.mdx @@ -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**. --- diff --git a/docs/operations/two-factor-admin.mdx b/docs/operations/two-factor-admin.mdx index ea212848..0e6514d0 100644 --- a/docs/operations/two-factor-admin.mdx +++ b/docs/operations/two-factor-admin.mdx @@ -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. - 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. ## SSO and 2FA: two independent toggles diff --git a/docs/reference/security.mdx b/docs/reference/security.mdx index bbe93fac..514bdc52 100644 --- a/docs/reference/security.mdx +++ b/docs/reference/security.mdx @@ -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. diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 15f9001f..b28221a5 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -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. - Settings Hub with the grouped sidebar: Identity, System, Alerts, Advanced + Settings Hub with the grouped sidebar ### 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** `` | Setting is per-node and is currently being edited against this node | | **EDITED** `` pending / `saved` | The current section has unsaved changes | -| Section-specific stats | Each section can publish its own pills: `2FA on`/`off` and `BACKUP left` (Account); `PLAN`, `TRIAL 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 left` (Account); `PLAN`, `TRIAL 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. - System Limits section showing the NODE pill in the masthead + Host Alerts section showing the NODE pill in the masthead --- @@ -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. - System Limits section showing Host thresholds and Docker hygiene subsections + Host Alerts section showing the Host thresholds and Crash capture subsections ### 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 + + + Fleet Mesh is admin-only. + + +**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 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 - 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). **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 - - Developer settings panel with the Developer Mode toggle and Data Retention fields - - -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. --- diff --git a/frontend/src/components/dashboard/ConfigurationStatus.tsx b/frontend/src/components/dashboard/ConfigurationStatus.tsx index 3a6c77d6..521cba63 100644 --- a/frontend/src/components/dashboard/ConfigurationStatus.tsx +++ b/frontend/src/components/dashboard/ConfigurationStatus.tsx @@ -158,12 +158,12 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps {!automation.webhooks.locked && ( )} @@ -206,12 +206,12 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps diff --git a/frontend/src/components/settings/DataRetentionSection.tsx b/frontend/src/components/settings/DataRetentionSection.tsx new file mode 100644 index 00000000..dc02d485 --- /dev/null +++ b/frontend/src/components/settings/DataRetentionSection.tsx @@ -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 ( +
+ + + +
+ ); +} + +type DataRetentionFields = Pick; + +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({ ...DEFAULT_DATA_RETENTION }); + const serverSettingsRef = useRef({ ...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 = 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 = (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(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 ; + + return ( +
+ + +
+ onSettingChange('metrics_retention_hours', e.target.value)} + className="w-24" + /> + hrs +
+
+ + +
+ onSettingChange('log_retention_days', e.target.value)} + className="w-24" + /> + days +
+
+ + +
+ onSettingChange('scan_history_per_image_limit', e.target.value)} + className="w-24" + /> + scans +
+
+ + {isPaid && ( + +
+ onSettingChange('audit_retention_days', e.target.value)} + className="w-24" + /> + days +
+
+ )} +
+ + + {!readOnly && ( + + {isSaving ? ( + <> + + Saving + + ) : ( + 'Save settings' + )} + + )} + +
+ ); +} diff --git a/frontend/src/components/settings/DeveloperSection.tsx b/frontend/src/components/settings/DeveloperSection.tsx index 9ba65a1b..09fe37e3 100644 --- a/frontend/src/components/settings/DeveloperSection.tsx +++ b/frontend/src/components/settings/DeveloperSection.tsx @@ -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 (
- -
); } -type DeveloperFields = Pick; +type DeveloperFields = Pick; 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({ ...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 = 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) { - - -
- onSettingChange('metrics_retention_hours', e.target.value)} - className="w-24" - /> - hrs -
-
- - -
- onSettingChange('log_retention_days', e.target.value)} - className="w-24" - /> - days -
-
- - -
- onSettingChange('scan_history_per_image_limit', e.target.value)} - className="w-24" - /> - scans -
-
- - {isPaid && ( - -
- onSettingChange('audit_retention_days', e.target.value)} - className="w-24" - /> - days -
-
- )} -
- {!readOnly && ( diff --git a/frontend/src/components/settings/DockerStorageSection.tsx b/frontend/src/components/settings/DockerStorageSection.tsx new file mode 100644 index 00000000..36937bdf --- /dev/null +++ b/frontend/src/components/settings/DockerStorageSection.tsx @@ -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 ( +
+ + + +
+ ); +} + +type DockerStorageFields = Pick; + +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({ ...DEFAULT_DOCKER_STORAGE }); + const serverSettingsRef = useRef({ ...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 = 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 = (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 ; + + return ( +
+ + + onSettingChange('docker_janitor_gb', v)} + suffix="GiB" + min={0} + step={0.5} + warnOver={10} + /> + + + onSettingChange('reclaim_hero', next ? '1' : '0')} + /> + + + + + + onSettingChange('prune_on_update', next ? '1' : '0')} + /> + + + + + {!readOnly && ( + + {isSaving ? ( + <> + + Saving + + ) : ( + 'Save settings' + )} + + )} + +
+ ); +} diff --git a/frontend/src/components/settings/FleetMeshSection.tsx b/frontend/src/components/settings/FleetMeshSection.tsx new file mode 100644 index 00000000..75155d04 --- /dev/null +++ b/frontend/src/components/settings/FleetMeshSection.tsx @@ -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 ( +
+ +
+ ); +} + +type FleetMeshFields = Pick; + +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({ ...DEFAULT_FLEET_MESH }); + const serverSettingsRef = useRef({ ...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 = 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 = (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 ; + + return ( +
+ + + onSettingChange('mesh_auto_recreate', next ? '1' : '0')} + /> + + + + + {!readOnly && ( + + {isSaving ? ( + <> + + Saving + + ) : ( + 'Save settings' + )} + + )} + +
+ ); +} diff --git a/frontend/src/components/settings/HostAlertsSection.tsx b/frontend/src/components/settings/HostAlertsSection.tsx new file mode 100644 index 00000000..ac728112 --- /dev/null +++ b/frontend/src/components/settings/HostAlertsSection.tsx @@ -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 ( +
+ + + + +
+ ); +} + +type HostAlertFields = Pick; + +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({ ...DEFAULT_HOST_ALERTS }); + const serverSettingsRef = useRef({ ...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 = 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 = (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 ; + + return ( +
+ + + onSettingChange('host_cpu_limit', v)} + suffix="%" + min={1} + max={100} + warnOver={95} + /> + + + onSettingChange('host_ram_limit', v)} + suffix="%" + min={1} + max={100} + warnOver={95} + /> + + + onSettingChange('host_disk_limit', v)} + suffix="%" + min={1} + max={100} + warnOver={95} + /> + + + onSettingChange('host_alert_suppression_mins', v)} + suffix="min" + min={1} + max={1440} + /> + + + + + + onSettingChange('global_crash', next ? '1' : '0')} + /> + + + + + {!readOnly && ( + + {isSaving ? ( + <> + + Saving + + ) : ( + 'Save alerts' + )} + + )} + +
+ ); +} diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index 16dc7561..f5d769cb 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -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 ; case 'registries': return ; case 'labels': return ; - case 'system': return handleDirtyChange('system', d)} />; + case 'host-alerts': return handleDirtyChange('host-alerts', d)} />; + case 'docker-storage': return handleDirtyChange('docker-storage', d)} />; + case 'fleet-mesh': return handleDirtyChange('fleet-mesh', d)} />; case 'notifications': return ; case 'notification-routing': return ; case 'webhooks': return ; case 'security': return ; case 'cloud-backup': return ; case 'developer': return handleDirtyChange('developer', d)} />; + case 'data-retention': return handleDirtyChange('data-retention', d)} />; case 'nodes': return ; case 'app-store': return ; case 'recovery': return ; case 'support': return ; case 'about': return ; - 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'; } diff --git a/frontend/src/components/settings/SystemControls.tsx b/frontend/src/components/settings/SystemControls.tsx new file mode 100644 index 00000000..a6ae68ed --- /dev/null +++ b/frontend/src/components/settings/SystemControls.tsx @@ -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(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 ( + + 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" + /> + {suffix} + + ); + } + + return ( + + ); +} diff --git a/frontend/src/components/settings/SystemSection.tsx b/frontend/src/components/settings/SystemSection.tsx deleted file mode 100644 index a206531b..00000000 --- a/frontend/src/components/settings/SystemSection.tsx +++ /dev/null @@ -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(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 ( - - 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" - /> - {suffix} - - ); - } - - return ( - - ); -} - -interface TogglePillProps { - checked: boolean; - onChange: (next: boolean) => void; -} - -function TogglePill({ checked, onChange }: TogglePillProps) { - return ( - - ); -} - -function SettingsSkeleton() { - return ( -
- - - - -
- ); -} - -type SystemFields = Pick; - -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({ ...DEFAULT_SYSTEM }); - const serverSettingsRef = useRef({ ...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 = 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 = (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 ; - - return ( -
- - - onSettingChange('host_cpu_limit', v)} - suffix="%" - min={1} - max={100} - warnOver={95} - /> - - - onSettingChange('host_ram_limit', v)} - suffix="%" - min={1} - max={100} - warnOver={95} - /> - - - onSettingChange('host_disk_limit', v)} - suffix="%" - min={1} - max={100} - warnOver={95} - /> - - - onSettingChange('host_alert_suppression_mins', v)} - suffix="min" - min={1} - max={1440} - /> - - - - - - onSettingChange('docker_janitor_gb', v)} - suffix="GiB" - min={0} - step={0.5} - warnOver={10} - /> - - - onSettingChange('global_crash', next ? '1' : '0')} - /> - - - onSettingChange('prune_on_update', next ? '1' : '0')} - /> - - - onSettingChange('reclaim_hero', next ? '1' : '0')} - /> - - - - {/* - 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 && ( - - - onSettingChange('mesh_auto_recreate', next ? '1' : '0')} - /> - - - )} - - - {!readOnly && ( - - {isSaving ? ( - <> - - Saving - - ) : ( - 'Save limits' - )} - - )} - -
- ); -} diff --git a/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx b/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx new file mode 100644 index 00000000..d223c369 --- /dev/null +++ b/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx @@ -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; +const mockedLicense = useLicense as unknown as ReturnType; + +const FULL_SETTINGS: Record = { + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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']); + }); +}); diff --git a/frontend/src/components/settings/__tests__/registry.test.ts b/frontend/src/components/settings/__tests__/registry.test.ts new file mode 100644 index 00000000..b4f84771 --- /dev/null +++ b/frontend/src/components/settings/__tests__/registry.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/components/settings/index.ts b/frontend/src/components/settings/index.ts index 1d43487e..82679fb1 100644 --- a/frontend/src/components/settings/index.ts +++ b/frontend/src/components/settings/index.ts @@ -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'; diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index 85a9c4f1..7bee792b 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -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'], diff --git a/frontend/src/components/settings/types.ts b/frontend/src/components/settings/types.ts index fdce455a..acb3d4ac 100644 --- a/frontend/src/components/settings/types.ts +++ b/frontend/src/components/settings/types.ts @@ -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'