chore: merge main into fix/ui-polish

This commit is contained in:
SaelixCode
2026-07-04 04:05:57 -04:00
136 changed files with 8570 additions and 1530 deletions
+10
View File
@@ -148,3 +148,13 @@ GITSOURCE_MAX_CLONE_BYTES=104857600
# working pull can be briefly silent while a large layer extracts; raise it on
# slow links or heavy local builds. Default 600000 (10 minutes).
# SENCHO_COMPOSE_STALL_TIMEOUT_MS=600000
# Container-side path to the OpenZFS ARC stats file, for ZFS-aware host memory.
# On ZFS hosts (TrueNAS SCALE, Proxmox, ZFS on Ubuntu/Debian) the ARC cache is
# reclaimable but the kernel reports it as used, which can trigger false
# host-memory alerts. When ARC stats are readable, Sencho adds reclaimable ARC
# back into available memory. Sencho checks this path first, then
# /host/proc/spl/kstat/zfs/arcstats, then /proc/spl/kstat/zfs/arcstats. Set this
# only if your ARC stats live at a non-standard path inside the container. If no
# ARC stats are readable, host memory reporting is unchanged.
# SENCHO_ZFS_ARCSTATS_PATH=
+1
View File
@@ -1,2 +1,3 @@
github: Studio-Saelix
custom:
- https://buymeacoffee.com/sencho
+1 -1
View File
@@ -101,7 +101,7 @@ runs:
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
+1 -1
View File
@@ -131,7 +131,7 @@ jobs:
run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
- name: Build Docker image (validation only)
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
push: false
+3 -3
View File
@@ -56,7 +56,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up QEMU
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
with:
platforms: arm64
@@ -101,7 +101,7 @@ jobs:
# builds apply. Scanning only amd64 is a defensible proxy for the multi-arch
# manifest: distro package CVEs are arch-agnostic.
- name: Build dev image for pre-publish scan (amd64, loaded)
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
push: false
@@ -149,7 +149,7 @@ jobs:
exit 1
- name: Build and push dev image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
push: true
+3 -43
View File
@@ -33,7 +33,6 @@ concurrency:
permissions:
contents: read
pull-requests: write
jobs:
push_preview_image:
@@ -78,7 +77,7 @@ jobs:
ref: refs/pull/${{ inputs.pr_number }}/head
- name: Set up QEMU
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
with:
platforms: arm64
@@ -140,7 +139,7 @@ jobs:
done <<< "$TAGS"
- name: Build preview image for pre-publish scan (amd64, loaded)
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
push: false
@@ -180,8 +179,7 @@ jobs:
exit 1
- name: Build and push preview image
id: build
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
push: true
@@ -192,41 +190,3 @@ jobs:
cache-to: type=gha,mode=max
build-args: |
APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }}
- name: Comment on pull request with pull instructions
continue-on-error: true
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
PR_NUMBER: ${{ inputs.pr_number }}
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
DIGEST: ${{ steps.build.outputs.digest }}
with:
script: |
const prNumber = Number(process.env.PR_NUMBER);
const shortSha = process.env.HEAD_SHA.slice(0, 7);
const digest = process.env.DIGEST;
const body = [
'## Preview image published',
'',
'Docker Hub preview tags for this PR:',
'',
'```bash',
`docker pull saelix/sencho:pr-${prNumber}`,
'```',
'',
'Pinned build:',
'',
'```bash',
`docker pull saelix/sencho:preview-${shortSha}`,
'```',
'',
`Manifest digest: \`${digest}\``,
'',
'These tags are for pre-merge validation only. They are not cosign-signed and are not suitable as a production pin.',
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
+3 -3
View File
@@ -44,7 +44,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up QEMU
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
with:
platforms: arm64
@@ -118,7 +118,7 @@ jobs:
# distro package CVEs are arch-agnostic at the manifest level, and
# arch-specific container-relevant CVEs are extraordinarily rare.
- name: Build release image for pre-publish scan (amd64, loaded)
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
push: false
@@ -177,7 +177,7 @@ jobs:
- name: Build and push Docker image
id: build
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
push: true
+1 -1
View File
@@ -76,7 +76,7 @@ jobs:
run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
- name: Build image from main HEAD
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
push: false
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.93.1"
".": "0.93.4"
}
+34
View File
@@ -4,6 +4,40 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.93.4](https://github.com/Studio-Saelix/sencho/compare/v0.93.3...v0.93.4) (2026-07-02)
### Fixed
* make host memory usage ZFS ARC-aware ([#1547](https://github.com/Studio-Saelix/sencho/issues/1547)) ([98667e0](https://github.com/Studio-Saelix/sencho/commit/98667e0d6f352daf53c7bdb6dff7b8370be0b809))
## [0.93.3](https://github.com/Studio-Saelix/sencho/compare/v0.93.2...v0.93.3) (2026-07-02)
### Added
* add GitHub Sponsors alongside Buy Me a Coffee ([#1535](https://github.com/Studio-Saelix/sencho/issues/1535)) ([7c759ff](https://github.com/Studio-Saelix/sencho/commit/7c759ff2b7614b2c26febe33463d38cd3e90fe14))
### Fixed
* **deploy-modal:** hide frozen countdown label in inline feedback style ([#1534](https://github.com/Studio-Saelix/sencho/issues/1534)) ([60de0dd](https://github.com/Studio-Saelix/sencho/commit/60de0ddec281187dd9ac6790168551630ebeeb9f))
* **deps:** patch frontend dompurify and @babel/core advisories ([#1539](https://github.com/Studio-Saelix/sencho/issues/1539)) ([d80538f](https://github.com/Studio-Saelix/sencho/commit/d80538f2541ac08feb7255b47fff30fb4332d72d))
* reflect toggle state in scheduled task Enabled/Disabled label ([#1533](https://github.com/Studio-Saelix/sencho/issues/1533)) ([90c69b1](https://github.com/Studio-Saelix/sencho/commit/90c69b1ea4135077d501399dd1e668b1b5113fa7))
* wrap auto-heal policy history in stack monitor sheet ([#1538](https://github.com/Studio-Saelix/sencho/issues/1538)) ([c3dcf31](https://github.com/Studio-Saelix/sencho/commit/c3dcf31cc8012cb891534985a0d678d72d381baa))
### Miscellaneous
* correct release version after mistagged sponsors PR ([#1537](https://github.com/Studio-Saelix/sencho/issues/1537)) ([ad8d874](https://github.com/Studio-Saelix/sencho/commit/ad8d87429409de7d09ff7e53741d42e2fa4c3930))
## [0.93.2](https://github.com/Studio-Saelix/sencho/compare/v0.93.1...v0.93.2) (2026-06-30)
### Fixed
* **ci:** correct github-script pin in preview workflow ([#1528](https://github.com/Studio-Saelix/sencho/issues/1528)) ([c9dcc3a](https://github.com/Studio-Saelix/sencho/commit/c9dcc3acf352f5c5cc717dc1d2d6f038832c81cb))
## [0.93.1](https://github.com/Studio-Saelix/sencho/compare/v0.93.0...v0.93.1) (2026-06-30)
+4 -4
View File
@@ -6,7 +6,7 @@ FROM --platform=$BUILDPLATFORM tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d
# Stage 1: Build Frontend
# Runs on the BUILD platform (amd64) - frontend has no native modules so the
# compiled output (JS/CSS/HTML) is entirely platform-agnostic.
FROM --platform=$BUILDPLATFORM node:26-alpine@sha256:a2dc166a387cc6ca1e62d0c8e265e49ca985d6e60abc9fe6e6c3d6ce8e63f606 AS frontend-builder
FROM --platform=$BUILDPLATFORM node:26-alpine@sha256:725aeba2364a9b16beae49e180d83bd597dbd0b15c47f1f28875c290bfd255b9 AS frontend-builder
WORKDIR /app/frontend
@@ -22,7 +22,7 @@ RUN npm run build
# Stage 2: Compile TypeScript
# Runs on the BUILD platform (amd64) - tsc output is platform-agnostic JS.
FROM --platform=$BUILDPLATFORM node:26-alpine@sha256:a2dc166a387cc6ca1e62d0c8e265e49ca985d6e60abc9fe6e6c3d6ce8e63f606 AS backend-builder
FROM --platform=$BUILDPLATFORM node:26-alpine@sha256:725aeba2364a9b16beae49e180d83bd597dbd0b15c47f1f28875c290bfd255b9 AS backend-builder
WORKDIR /app/backend
@@ -44,7 +44,7 @@ RUN npm run build
# tonistiigi/xx + clang as the cross-compiler.
# This avoids the Node.js v20 SIGILL crash that occurs when npm runs
# under QEMU because QEMU lacks ARMv8.1 LSE atomic instruction support.
FROM --platform=$BUILDPLATFORM node:26-alpine@sha256:a2dc166a387cc6ca1e62d0c8e265e49ca985d6e60abc9fe6e6c3d6ce8e63f606 AS prod-deps
FROM --platform=$BUILDPLATFORM node:26-alpine@sha256:725aeba2364a9b16beae49e180d83bd597dbd0b15c47f1f28875c290bfd255b9 AS prod-deps
# Copy xx cross-compilation tools into this stage
COPY --from=xx / /
@@ -237,7 +237,7 @@ RUN test -f /build/docker-compose \
# in this image; operators who want the feature install Trivy on the host
# and mount the binary into the container, or run a sidecar. See
# docs/operations/trivy-setup.mdx for the supported integration paths.
FROM node:26-alpine@sha256:a2dc166a387cc6ca1e62d0c8e265e49ca985d6e60abc9fe6e6c3d6ce8e63f606
FROM node:26-alpine@sha256:725aeba2364a9b16beae49e180d83bd597dbd0b15c47f1f28875c290bfd255b9
# Daily cache-bust for the apk upgrade layer. CI passes the current date
# (YYYY-MM-DD) as a build-arg, so this RUN layer's hash changes at most
+4 -3
View File
@@ -1,7 +1,7 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="frontend/public/sencho-logo-dark.png">
<img src="frontend/public/sencho-logo-light.png" alt="Sencho" width="220">
<source media="(prefers-color-scheme: dark)" srcset="frontend/public/sencho-logo-dark.svg">
<img src="frontend/public/sencho-logo-light.svg" alt="Sencho" width="220">
</picture>
### Self-hosted Docker Compose management for one machine or a fleet.
@@ -10,7 +10,8 @@
<a href="https://docs.sencho.io">Docs</a> ·
<a href="https://sencho.io">Website</a> ·
<a href="https://github.com/studio-saelix/sencho/discussions">Discussions</a> ·
<a href="https://buymeacoffee.com/sencho">Sponsor</a>
<a href="https://github.com/sponsors/Studio-Saelix">Sponsor</a> ·
<a href="https://buymeacoffee.com/sencho">Buy Me a Coffee</a>
</p>
[![Latest release](https://img.shields.io/github/v/release/studio-saelix/sencho?label=release)](https://github.com/studio-saelix/sencho/releases)
+1 -1
View File
@@ -10,7 +10,7 @@ Sencho is a solo-maintained project. Here is what to expect.
- **Feature requests** → [Feature request](https://github.com/studio-saelix/sencho/issues/new?template=feature_request.yml)
- **Security vulnerabilities** → [SECURITY.md](SECURITY.md). Do not open a public issue.
- **Licensing, billing, refunds** → licensing@sencho.io
- **Support the project** → [Buy Me a Coffee](https://buymeacoffee.com/sencho). Sponsorship is not required and does not change support response times; it helps fund the work.
- **Support the project** → [GitHub Sponsors](https://github.com/sponsors/Studio-Saelix) or [Buy Me a Coffee](https://buymeacoffee.com/sencho). Sponsorship is not required and does not change support response times; it helps fund the work.
## Response times
+256 -512
View File
File diff suppressed because it is too large Load Diff
+39 -2
View File
@@ -17,6 +17,7 @@
import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD } from './helpers/setupTestDb';
import { installArcstatsFsMock, arcstatsBody, DEFAULT_ARC_PATH, type ArcstatsFsMock } from './helpers/arcstatsFsMock';
import { GitSourceService } from '../services/GitSourceService';
import type { PublicGitSource } from '../services/GitSourceService';
@@ -80,9 +81,13 @@ let tmpDir: string;
let app: import('express').Express;
let authCookie: string;
let CacheService: typeof import('../services/CacheService').CacheService;
let arcFs: ArcstatsFsMock;
beforeAll(async () => {
tmpDir = await setupTestDb();
// Host memory reads ZFS ARC stats; intercept those reads so results do not
// depend on whether the CI host is itself ZFS. Default: no ARC present.
arcFs = installArcstatsFsMock();
({ app } = await import('../index'));
({ CacheService } = await import('../services/CacheService'));
@@ -98,6 +103,7 @@ afterAll(() => {
beforeEach(() => {
CacheService.getInstance().flush();
arcFs.clear();
mockGetAllContainers.mockReset();
mockGetBulkStackStatuses.mockReset();
@@ -178,8 +184,9 @@ describe('GET /api/system/stats caching', () => {
it('reports memory from the active working set, excluding reclaimable cache', async () => {
const res = await request(app).get('/api/system/stats').set('Cookie', authCookie);
expect(res.status).toBe(200);
// Figures come from mem.active / mem.available (cache-excluded), not the
// cache-inclusive mem.used / mem.free, so a busy host does not read ~100%.
// With no ARC present, effective used is total - available (which equals
// mem.active), not the cache-inclusive mem.used / mem.free, so a busy host
// does not read ~100%.
expect(res.body.memory).toMatchObject({
total: 1000,
used: 400, // mem.active, not mem.used (500)
@@ -187,6 +194,36 @@ describe('GET /api/system/stats caching', () => {
usagePercent: '40.0', // 400 / 1000, not 500 / 1000
});
});
it('adds reclaimable ZFS ARC back into available memory', async () => {
arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(300, 100)); // reclaimable 200
const res = await request(app).get('/api/system/stats').set('Cookie', authCookie);
expect(res.status).toBe(200);
// available 600 + 200 reclaimable ARC = 800 effective free; used drops to 200.
expect(res.body.memory).toMatchObject({
total: 1000,
used: 200,
free: 800,
usagePercent: '20.0',
});
});
});
// ── /api/fleet/overview (local node) ───────────────────────────────────
describe('GET /api/fleet/overview local-node memory', () => {
it('reports ARC-adjusted memory for the local node', async () => {
arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(300, 100)); // reclaimable 200
const res = await request(app).get('/api/fleet/overview').set('Cookie', authCookie);
expect(res.status).toBe(200);
const local = res.body.find((n: { type: string }) => n.type === 'local');
expect(local?.systemStats?.memory).toMatchObject({
total: 1000,
used: 200,
free: 800,
usagePercent: '20.0',
});
});
});
// ── /api/stacks/statuses ───────────────────────────────────────────────
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import {
containerBelongsToComposeService,
filterContainersByComposeService,
} from '../helpers/composeServiceMatch';
describe('composeServiceMatch', () => {
it('matches by Service field from docker compose ps', () => {
const c = { Id: 'abc', Service: 'mariadb', Names: ['/mariadb'] };
expect(containerBelongsToComposeService(c, 'mariadb')).toBe(true);
expect(containerBelongsToComposeService(c, 'phpmyadmin')).toBe(false);
});
it('matches by com.docker.compose.service label', () => {
const c = {
Id: 'abc',
Names: ['/custom-name'],
Labels: { 'com.docker.compose.service': 'mariadb' },
};
expect(containerBelongsToComposeService(c, 'mariadb')).toBe(true);
});
it('matches when container name equals service (container_name in compose)', () => {
const c = { Id: 'abc', Service: '', Names: ['/mariadb'] };
expect(containerBelongsToComposeService(c, 'mariadb')).toBe(true);
});
it('filterContainersByComposeService returns all replicas', () => {
const containers = [
{ Id: '1', Service: 'app', Names: ['/web-app-1'] },
{ Id: '2', Service: 'app', Names: ['/web-app-2'] },
{ Id: '3', Names: ['/db'] },
];
expect(filterContainersByComposeService(containers, 'app')).toHaveLength(2);
});
});
@@ -19,6 +19,7 @@ const {
mockGetComposeFilename, mockGetOverrideFilename, mockEnsureStackOverride,
mockMkdtempSync, mockWriteFileSync, mockUnlinkSync, mockRmdirSync,
mockGetGlobalSettings, mockPruneDanglingImages, mockGetBindMounts,
mockGetStackContent, mockGetEnvContent,
} = vi.hoisted(() => ({
mockSpawn: vi.fn(),
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
@@ -40,6 +41,8 @@ const {
mockGetGlobalSettings: vi.fn().mockReturnValue({}),
mockPruneDanglingImages: vi.fn().mockResolvedValue({ reclaimedBytes: 0 }),
mockGetBindMounts: vi.fn().mockResolvedValue(null),
mockGetStackContent: vi.fn().mockResolvedValue(''),
mockGetEnvContent: vi.fn().mockResolvedValue(''),
}));
vi.mock('child_process', () => ({ spawn: mockSpawn, execFile: vi.fn() }));
@@ -109,6 +112,8 @@ vi.mock('../services/FileSystemService', () => ({
restoreStackFiles: mockRestoreStackFiles,
getComposeFilename: mockGetComposeFilename,
getOverrideFilename: mockGetOverrideFilename,
getStackContent: mockGetStackContent,
getEnvContent: mockGetEnvContent,
}),
},
}));
@@ -16,6 +16,7 @@ let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let DockerController: typeof import('../services/DockerController').default;
let FileSystemService: typeof import('../services/FileSystemService').FileSystemService;
let SelfIdentityService: typeof import('../services/SelfIdentityService').default;
let ROLE_PERMISSIONS: typeof import('../middleware/permissions').ROLE_PERMISSIONS;
const VIEWER = 'container-read-viewer';
@@ -28,7 +29,7 @@ function viewerToken(): string {
}
/**
* Replace the DockerController / FileSystemService singletons with stubs so the
* Replace the DockerController / FileSystemService / SelfIdentityService singletons with stubs so the
* handlers run without a Docker daemon. The logs stub ends the response itself
* (the real streamContainerLogs flushes SSE headers and streams), so a request
* that clears the guard resolves instead of hanging. Returns the spies so a test
@@ -46,6 +47,11 @@ function stubDockerAndFs(): { docker: ReturnType<typeof vi.spyOn>; fs: ReturnTyp
const fs = vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({
getStacks: vi.fn().mockResolvedValue([]),
} as unknown as ReturnType<typeof FileSystemService.getInstance>);
vi.spyOn(SelfIdentityService, 'getInstance').mockReturnValue({
initialize: vi.fn().mockResolvedValue(undefined),
isOwnContainer: vi.fn().mockReturnValue(false),
isOwnImage: vi.fn().mockReturnValue(false),
} as unknown as ReturnType<typeof SelfIdentityService.getInstance>);
return { docker, fs };
}
@@ -54,6 +60,7 @@ beforeAll(async () => {
({ DatabaseService } = await import('../services/DatabaseService'));
({ default: DockerController } = await import('../services/DockerController'));
({ FileSystemService } = await import('../services/FileSystemService'));
({ default: SelfIdentityService } = await import('../services/SelfIdentityService'));
({ ROLE_PERMISSIONS } = await import('../middleware/permissions'));
({ app } = await import('../index'));
@@ -0,0 +1,92 @@
/**
* GET /api/containers must omit Sencho's own container from picker lists.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let DockerController: typeof import('../services/DockerController').default;
let SelfIdentityServiceMod: typeof import('../services/SelfIdentityService').default;
const VIEWER = 'container-self-filter-viewer';
function viewerToken(): string {
const user = DatabaseService.getInstance().getUserByUsername(VIEWER)!;
return jwt.sign({ username: VIEWER, role: 'viewer', tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' });
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ default: DockerController } = await import('../services/DockerController'));
({ default: SelfIdentityServiceMod } = await import('../services/SelfIdentityService'));
({ app } = await import('../index'));
const hash = await bcrypt.hash('password123', 1);
DatabaseService.getInstance().addUser({ username: VIEWER, password_hash: hash, role: 'viewer' });
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('GET /api/containers self-filter', () => {
beforeEach(() => {
vi.spyOn(SelfIdentityServiceMod, 'getInstance').mockReturnValue({
initialize: vi.fn().mockResolvedValue(undefined),
isOwnContainer: (idOrName: string) => idOrName === 'sencho' || idOrName.startsWith('sencho-id'),
isOwnImage: vi.fn().mockReturnValue(false),
} as unknown as ReturnType<typeof SelfIdentityServiceMod.getInstance>);
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getAllContainers: vi.fn().mockResolvedValue([
{ Id: 'sencho-id-full', Names: ['/sencho'], State: 'running', Status: 'Up 1 day' },
{ Id: 'other-id', Names: ['/mariadb'], State: 'running', Status: 'Up 1 day' },
]),
getRunningContainers: vi.fn().mockResolvedValue([]),
} as unknown as ReturnType<typeof DockerController.getInstance>);
});
it('excludes Sencho from all=true container list', async () => {
const res = await request(app)
.get('/api/containers?all=true')
.set('Authorization', `Bearer ${viewerToken()}`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].Names).toEqual(['/mariadb']);
});
it('excludes official Sencho images when SelfIdentity does not match by id', async () => {
vi.spyOn(SelfIdentityServiceMod, 'getInstance').mockReturnValue({
initialize: vi.fn().mockResolvedValue(undefined),
isOwnContainer: vi.fn().mockReturnValue(false),
isOwnImage: vi.fn().mockReturnValue(false),
} as unknown as ReturnType<typeof SelfIdentityServiceMod.getInstance>);
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getAllContainers: vi.fn().mockResolvedValue([
{ Id: 'remote-sencho', Names: ['/sencho'], Image: 'saelix/sencho:latest', State: 'running' },
{ Id: 'other-id', Names: ['/mariadb'], State: 'running' },
]),
getRunningContainers: vi.fn().mockResolvedValue([]),
} as unknown as ReturnType<typeof DockerController.getInstance>);
const res = await request(app)
.get('/api/containers?all=true')
.set('Authorization', `Bearer ${viewerToken()}`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].Names).toEqual(['/mariadb']);
});
});
@@ -978,6 +978,59 @@ describe('DockerController - inspectImage', () => {
});
});
// --- label / image inspection for the label inventory --------------------------
describe('DockerController - inspectImageLabels', () => {
it('returns the image label map', async () => {
mockDocker.getImage.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'org.opencontainers.image.title': 'Plex' } } }),
});
const dc = DockerController.getInstance(1);
const result = await dc.inspectImageLabels('sha256:img');
expect(result).toEqual({ labels: { 'org.opencontainers.image.title': 'Plex' } });
expect(mockDocker.getImage).toHaveBeenCalledWith('sha256:img');
});
it('returns null (not a silent empty map) and logs when the image inspect fails', async () => {
mockDocker.getImage.mockReturnValue({
inspect: vi.fn().mockRejectedValue(new Error('No such image')),
});
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const dc = DockerController.getInstance(1);
expect(await dc.inspectImageLabels('missing')).toBeNull();
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it('short-circuits an empty image id without inspecting', async () => {
const dc = DockerController.getInstance(1);
expect(await dc.inspectImageLabels('')).toBeNull();
expect(mockDocker.getImage).not.toHaveBeenCalled();
});
});
describe('DockerController - inspectContainerLabelsAndImage', () => {
it('returns labels and the image ref from container inspect', async () => {
mockDocker.getContainer.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'traefik.enable': 'true' } }, Image: 'sha256:imgref' }),
});
const dc = DockerController.getInstance(1);
const result = await dc.inspectContainerLabelsAndImage('c1');
expect(result).toEqual({ labels: { 'traefik.enable': 'true' }, imageId: 'sha256:imgref' });
});
it('returns null and logs when the container inspect fails', async () => {
mockDocker.getContainer.mockReturnValue({
inspect: vi.fn().mockRejectedValue(new Error('no such container')),
});
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const dc = DockerController.getInstance(1);
expect(await dc.inspectContainerLabelsAndImage('gone')).toBeNull();
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
});
// --- createNetwork validation --------------------------------------------------
describe('createNetwork', () => {
@@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest';
import { isPublishedSenchoImage } from '../helpers/excludeSelfContainers';
describe('isPublishedSenchoImage', () => {
it('matches Docker Hub and GHCR release paths', () => {
expect(isPublishedSenchoImage('saelix/sencho:latest')).toBe(true);
expect(isPublishedSenchoImage('ghcr.io/studio-saelix/sencho:0.93.1')).toBe(true);
expect(isPublishedSenchoImage('ghcr.io/studio-saelix/sencho-dev:dev')).toBe(true);
expect(isPublishedSenchoImage('lscr.io/linuxserver/mariadb:latest')).toBe(false);
});
});
@@ -21,6 +21,7 @@ const NODE_READ_ROUTES = [
'/api/fleet/dependency-map',
'/api/fleet/networking-summary',
'/api/fleet/update-status',
'/api/fleet/container-labels',
];
beforeAll(async () => {
@@ -61,4 +62,20 @@ describe('fleet topology reads require node:read', () => {
const res = await request(app).get('/api/fleet/overview');
expect(res.status).toBe(401);
});
it('denies ?reveal=1 on container-labels for a non-admin (viewer)', async () => {
const res = await request(app).get('/api/fleet/container-labels?reveal=1').set('Authorization', `Bearer ${viewerToken}`);
expect(res.status).toBe(403);
});
it('denies the node-local /api/system/container-labels for a role without node:read (deployer)', async () => {
const res = await request(app).get('/api/system/container-labels').set('Authorization', `Bearer ${deployerToken}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
});
it('denies ?reveal=1 on the node-local container-labels for a non-admin (viewer)', async () => {
const res = await request(app).get('/api/system/container-labels?reveal=1').set('Authorization', `Bearer ${viewerToken}`);
expect(res.status).toBe(403);
});
});
@@ -909,7 +909,9 @@ describe('GitSourceService.createStackFromGit', () => {
sha,
});
const svc = GitSourceService.getInstance();
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
try {
const result = await svc.createStackFromGit({
stackName: 'create-happy',
repoUrl: 'https://github.com/example/repo.git',
@@ -934,6 +936,9 @@ describe('GitSourceService.createStackFromGit', () => {
expect(onDisk).toContain('image: nginx');
await cleanupStackDir('create-happy');
} finally {
validateSpy.mockRestore();
}
});
it('multi-file create then pull reports no local changes (hash is path-independent)', async () => {
@@ -0,0 +1,98 @@
import { promises as fs } from 'fs';
import { vi } from 'vitest';
import { ARCSTATS_FIXED_PATHS } from '../../helpers/hostMemory';
/**
* Path-aware partial mock of `fs.promises` for ZFS arcstats reads.
*
* `helpers/hostMemory.ts` reads `/proc/spl/kstat/zfs/arcstats` (and optional
* variants) to compute reclaimable ARC. Tests may run on a ZFS host, so a real
* read would make results host-dependent. This installs a spy that intercepts
* ONLY registered/ARC-candidate paths and delegates every other
* `readFile`/`stat` to the real filesystem, so `setupTestDb` and
* `DatabaseService` keep working. Default behavior: ARC candidates reject with
* ENOENT (no ARC), so consumers fall back to the plain `active/total` reading.
*/
// Sourced from the helper so the mock cannot silently drift from the paths the
// production code actually reads.
export const ARC_CANDIDATE_PATHS = ARCSTATS_FIXED_PATHS;
/** Second fixed candidate; the default path fixtures are served from. */
export const DEFAULT_ARC_PATH = ARC_CANDIDATE_PATHS[1];
type StatDescriptor = { isFile: boolean; size: number };
export interface ArcstatsFsMock {
/** Serve `content` when `path` is read. */
setRead(path: string, content: string): void;
/** Reject a read of `path` with `err` (e.g. an EACCES/EIO error). */
setReadError(path: string, err: NodeJS.ErrnoException): void;
/** Control `stat(path)` result (for override-path guard tests). */
setStat(path: string, descriptor: StatDescriptor | NodeJS.ErrnoException): void;
/** Forget all registered paths (back to default no-ARC). */
clear(): void;
}
function enoent(path: string): NodeJS.ErrnoException {
return Object.assign(new Error(`ENOENT: no such file, open '${path}'`), { code: 'ENOENT' });
}
/**
* Install the spy. Call once per test file (e.g. in `beforeAll`); use the
* returned setters per test and `clear()` in `beforeEach`.
*/
export function installArcstatsFsMock(): ArcstatsFsMock {
const realReadFile = fs.readFile.bind(fs);
const realStat = fs.stat.bind(fs);
const reads = new Map<string, string | NodeJS.ErrnoException>();
const stats = new Map<string, StatDescriptor | NodeJS.ErrnoException>();
const isArcCandidate = (p: string): boolean => ARC_CANDIDATE_PATHS.includes(p);
vi.spyOn(fs, 'readFile').mockImplementation((async (p: unknown, ...rest: unknown[]) => {
const key = String(p);
if (reads.has(key)) {
const v = reads.get(key)!;
if (v instanceof Error) throw v;
return v;
}
if (isArcCandidate(key)) throw enoent(key);
return (realReadFile as (...a: unknown[]) => unknown)(p, ...rest);
}) as unknown as typeof fs.readFile);
vi.spyOn(fs, 'stat').mockImplementation((async (p: unknown, ...rest: unknown[]) => {
const key = String(p);
if (stats.has(key)) {
const v = stats.get(key)!;
if (v instanceof Error) throw v;
return { isFile: () => v.isFile, size: v.size };
}
// A registered read with no explicit stat implies a small regular file.
if (reads.has(key)) {
const v = reads.get(key);
const size = typeof v === 'string' ? Buffer.byteLength(v) : 0;
return { isFile: () => true, size };
}
if (isArcCandidate(key)) throw enoent(key);
return (realStat as (...a: unknown[]) => unknown)(p, ...rest);
}) as unknown as typeof fs.stat);
return {
setRead: (path, content) => reads.set(path, content),
setReadError: (path, err) => reads.set(path, err),
setStat: (path, descriptor) => stats.set(path, descriptor),
clear: () => { reads.clear(); stats.clear(); },
};
}
/** Build a minimal arcstats kstat body with the given `size` and `c_min` rows. */
export function arcstatsBody(sizeRow: string | number, cMinRow: string | number): string {
return [
'name type data',
`hits 4 123456`,
`c_min 4 ${cMinRow}`,
`size 4 ${sizeRow}`,
`c_max 4 9999999999`,
'',
].join('\n');
}
+204
View File
@@ -0,0 +1,204 @@
/**
* Unit tests for the ZFS ARC-aware host-memory helper.
*
* `adjustForArc` is exercised directly; `readReclaimableArc` and
* `parseArcstats` stay module-internal and are exercised through
* `getHostMemory` with a path-aware fs mock (see helpers/arcstatsFsMock.ts).
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
import {
installArcstatsFsMock,
arcstatsBody,
DEFAULT_ARC_PATH,
ARC_CANDIDATE_PATHS,
type ArcstatsFsMock,
} from './helpers/arcstatsFsMock';
const mockMem = vi.fn();
vi.mock('systeminformation', () => ({
default: { mem: (...args: unknown[]) => mockMem(...args) },
}));
import { getHostMemory, adjustForArc } from '../helpers/hostMemory';
// mem.active === total - available on Linux, so used/free below mirror the
// real systeminformation shape the helper consumes.
const memSample = (total: number, available: number) => ({
total,
available,
active: total - available,
used: total - available,
free: available,
buffcache: 0,
});
let arcFs: ArcstatsFsMock;
beforeAll(() => {
arcFs = installArcstatsFsMock();
});
beforeEach(() => {
arcFs.clear();
mockMem.mockReset();
delete process.env.SENCHO_ZFS_ARCSTATS_PATH;
});
describe('adjustForArc', () => {
it('reproduces active/total when reclaimable ARC is 0', () => {
const result = adjustForArc(memSample(1000, 600), 0);
expect(result).toEqual({ total: 1000, used: 400, free: 600, usagePercent: 40 });
});
it('adds reclaimable ARC back into available, lowering usage', () => {
const result = adjustForArc(memSample(1000, 600), 200);
expect(result).toEqual({ total: 1000, used: 200, free: 800, usagePercent: 20 });
});
it('clamps effective available to total when ARC exceeds the gap', () => {
const result = adjustForArc(memSample(1000, 600), 5000);
expect(result).toEqual({ total: 1000, used: 0, free: 1000, usagePercent: 0 });
});
it('guards against a zero total', () => {
const result = adjustForArc(memSample(0, 0), 0);
expect(result.usagePercent).toBe(0);
});
});
describe('getHostMemory ARC discovery', () => {
it('falls back to active/total when no ARC stats are present', async () => {
mockMem.mockResolvedValue(memSample(1000, 600));
const result = await getHostMemory();
expect(result).toEqual({ total: 1000, used: 400, free: 600, usagePercent: 40 });
});
it('subtracts reclaimable ARC (size - c_min) from used', async () => {
mockMem.mockResolvedValue(memSample(1000, 600));
arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(300, 100)); // reclaimable 200
const result = await getHostMemory();
expect(result).toEqual({ total: 1000, used: 200, free: 800, usagePercent: 20 });
});
it('prefers the operator override path over the fixed candidates', async () => {
process.env.SENCHO_ZFS_ARCSTATS_PATH = '/custom/arcstats';
mockMem.mockResolvedValue(memSample(2000, 600));
arcFs.setRead('/custom/arcstats', arcstatsBody(500, 100)); // reclaimable 400
arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(300, 100)); // fixed would be 200
const result = await getHostMemory();
expect(result.used).toBe(1000); // 2000 - (600 + 400 override); fixed would give 1200
expect(result.free).toBe(1000);
});
it('reads the host-mounted candidate and prefers it over /proc', async () => {
// ARC_CANDIDATE_PATHS[0] is /host/proc/..., the path docker-compose mounts
// into the container, so this covers the real deployment path and precedence.
mockMem.mockResolvedValue(memSample(2000, 600));
arcFs.setRead(ARC_CANDIDATE_PATHS[0], arcstatsBody(500, 100)); // /host/proc: reclaimable 400
arcFs.setRead(ARC_CANDIDATE_PATHS[1], arcstatsBody(300, 100)); // /proc: would be 200
const result = await getHostMemory();
expect(result.used).toBe(1000); // 2000 - (600 + 400); /proc winning would give 1200
});
it('falls through to a fixed candidate when the override is unreadable', async () => {
process.env.SENCHO_ZFS_ARCSTATS_PATH = '/custom/arcstats';
mockMem.mockResolvedValue(memSample(1000, 600));
arcFs.setReadError('/custom/arcstats', Object.assign(new Error('nope'), { code: 'ENOENT' }));
arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(300, 100)); // reclaimable 200
const result = await getHostMemory();
expect(result.used).toBe(200);
});
it('resolves immediately to 0 reclaimable when size < c_min (ARC at floor)', async () => {
mockMem.mockResolvedValue(memSample(1000, 600));
arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(50, 100)); // size < c_min
const result = await getHostMemory();
expect(result).toEqual({ total: 1000, used: 400, free: 600, usagePercent: 40 });
});
it.each([
['non-numeric size', arcstatsBody('abc', 100)],
['negative size', arcstatsBody(-5, 100)],
['non-numeric c_min', arcstatsBody(300, 'xyz')],
['negative c_min', arcstatsBody(300, -5)],
['missing c_min', 'size 4 300\n'],
['missing size', 'c_min 4 100\n'],
['empty file', ' \n'],
])('treats a %s record as unusable and yields no ARC', async (_label, body) => {
mockMem.mockResolvedValue(memSample(1000, 600));
arcFs.setRead(DEFAULT_ARC_PATH, body);
const result = await getHostMemory();
expect(result.used).toBe(400); // fell through to active/total
});
it.each([
['EACCES', 'EACCES'],
['EIO', 'EIO'],
['EMFILE', 'EMFILE'],
])('fails open (ARC 0) on a %s read error', async (_label, code) => {
mockMem.mockResolvedValue(memSample(1000, 600));
arcFs.setReadError(DEFAULT_ARC_PATH, Object.assign(new Error(code), { code }));
const result = await getHostMemory();
expect(result.used).toBe(400);
});
it('logs an unexpected read error (once per code) but stays silent on an expected one', async () => {
mockMem.mockResolvedValue(memSample(1000, 600));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
// Expected fs error: silent fall-through.
arcFs.setReadError(ARC_CANDIDATE_PATHS[0], Object.assign(new Error('denied'), { code: 'EACCES' }));
arcFs.setReadError(ARC_CANDIDATE_PATHS[1], Object.assign(new Error('denied'), { code: 'EACCES' }));
await getHostMemory();
expect(warn).not.toHaveBeenCalled();
// Unexpected fs error: logged, but only once per error code across calls.
// Uses a code no other test triggers, since the once-per-code memo is
// process-global.
arcFs.setReadError(ARC_CANDIDATE_PATHS[0], Object.assign(new Error('stale'), { code: 'ESTALE' }));
arcFs.setReadError(ARC_CANDIDATE_PATHS[1], Object.assign(new Error('stale'), { code: 'ESTALE' }));
await getHostMemory();
await getHostMemory();
const unexpectedLogs = warn.mock.calls.filter(([msg]) => String(msg).includes('ESTALE'));
expect(unexpectedLogs).toHaveLength(1);
warn.mockRestore();
});
it('skips an override path that is not a regular file', async () => {
process.env.SENCHO_ZFS_ARCSTATS_PATH = '/custom/arcstats';
mockMem.mockResolvedValue(memSample(1000, 600));
arcFs.setStat('/custom/arcstats', { isFile: false, size: 10 });
arcFs.setRead('/custom/arcstats', arcstatsBody(500, 100));
const result = await getHostMemory();
expect(result.used).toBe(400); // override skipped, no fixed ARC present
});
it('skips an override path that exceeds the size bound', async () => {
process.env.SENCHO_ZFS_ARCSTATS_PATH = '/custom/arcstats';
mockMem.mockResolvedValue(memSample(1000, 600));
arcFs.setStat('/custom/arcstats', { isFile: true, size: 2 * 1024 * 1024 });
arcFs.setRead('/custom/arcstats', arcstatsBody(500, 100));
const result = await getHostMemory();
expect(result.used).toBe(400);
});
it('logs the selected path once and never the file contents', async () => {
process.env.SENCHO_ZFS_ARCSTATS_PATH = '/log-once/arcstats';
mockMem.mockResolvedValue(memSample(1000, 600));
arcFs.setRead('/log-once/arcstats', arcstatsBody(300, 100));
const debug = vi.spyOn(console, 'debug').mockImplementation(() => {});
await getHostMemory();
await getHostMemory();
const pathLogs = debug.mock.calls.filter(([msg]) => String(msg).includes('/log-once/arcstats'));
expect(pathLogs).toHaveLength(1);
// The log names the path, never the kstat contents (size / c_min values).
expect(String(pathLogs[0][0])).not.toContain('300');
expect(String(pathLogs[0][0])).not.toContain('100');
debug.mockRestore();
});
});
afterEach(() => {
delete process.env.SENCHO_ZFS_ARCSTATS_PATH;
});
@@ -120,6 +120,26 @@ describe('hubOnlyGuard', () => {
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
});
it('rejects /api/notification-suppression-rules with 403 when nodeId targets a remote node', async () => {
const res = await request(app)
.get('/api/notification-suppression-rules/')
.set('Authorization', authHeader)
.set('x-node-id', String(remoteNodeId));
expect(res.status).toBe(403);
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
});
it('rejects /api/notification-suppression-rules (no trailing slash) with 403 when nodeId targets a remote node', async () => {
const res = await request(app)
.get('/api/notification-suppression-rules')
.set('Authorization', authHeader)
.set('x-node-id', String(remoteNodeId));
expect(res.status).toBe(403);
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
});
// Regression for the Global Observability admin gate: the logs feed's
// `requireAdmin` lives in the local route handler, which the proxy skips when
// forwarding a remote nodeId. Without these prefixes the guard would let the
@@ -0,0 +1,574 @@
/**
* Label inventory service, provenance, redaction, and GET routes.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
import { ComposeService } from '../services/ComposeService';
import DockerController from '../services/DockerController';
import { DatabaseService } from '../services/DatabaseService';
import { NodeRegistry } from '../services/NodeRegistry';
import {
buildNodeLabelInventory,
buildStackLabelInventory,
} from '../services/LabelInventoryService';
import { REDACTED_SENTINEL } from '../helpers/labelValueRedaction';
let tmpDir: string;
let app: import('express').Express;
let authCookie: string;
let nodeId: number;
function composeDir(): string { return process.env.COMPOSE_DIR as string; }
function writeStack(stack: string, files: Record<string, string>): void {
const dir = path.join(composeDir(), stack);
fs.mkdirSync(dir, { recursive: true });
for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), content);
}
function stubRender(serviceLabels: Record<string, Record<string, string>> | null): void {
const rendered = serviceLabels === null
? null
: JSON.stringify({
name: 'proj',
services: Object.fromEntries(
Object.entries(serviceLabels).map(([s, labels]) => [s, { labels }]),
),
});
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockResolvedValue({ rendered, stderr: '', code: rendered === null ? 1 : 0, timedOut: false }),
} as unknown as ComposeService);
}
interface StubRow {
id: string;
name: string;
state: string;
stack: string | null;
service: string | null;
labels: Record<string, string>;
inspectFailed?: boolean;
imageId?: string;
}
/**
* Stub DockerController for the label inventory. `images` maps an image id to its label
* map, or to `null` to simulate an image inspect failure. An image id absent from the map
* inspects successfully with no labels. Returns the `inspectImageLabels` spy so tests can
* assert deduplication.
*/
function stubDockerList(
rows: StubRow[],
opts: { images?: Record<string, Record<string, string> | null> } = {},
): { inspectImageLabels: ReturnType<typeof vi.fn> } {
const withDefaults = rows.map(r => ({ inspectFailed: false, imageId: 'img-default', ...r }));
const images = opts.images ?? {};
const inspectImageLabels = vi.fn(async (imageId: string) => {
if (imageId in images) {
const labels = images[imageId];
return labels === null ? null : { labels };
}
return { labels: {} };
});
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
listContainersForLabelInventory: vi.fn().mockResolvedValue(withDefaults),
getContainersByStack: vi.fn().mockImplementation(async (stack: string) =>
withDefaults.filter(r => r.stack === stack).map(r => ({
Id: r.id,
Names: [`/${r.name}`],
State: r.state,
Service: r.service,
})),
),
inspectContainerLabelsAndImage: vi.fn().mockImplementation(async (id: string) => {
const row = withDefaults.find(r => r.id === id);
if (!row || row.inspectFailed) return null;
return { labels: row.labels, imageId: row.imageId };
}),
inspectImageLabels,
} as unknown as DockerController);
return { inspectImageLabels };
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
authCookie = await loginAsTestAdmin(app);
nodeId = (DatabaseService.getInstance().getDb().prepare('SELECT id FROM nodes WHERE is_default = 1').get() as { id: number }).id;
});
afterAll(() => cleanupTestDb(tmpDir));
afterEach(() => vi.restoreAllMocks());
describe('buildNodeLabelInventory', () => {
it('builds inverted index and compose-system provenance', async () => {
stubDockerList([
{
id: 'c1',
name: 'web-1',
state: 'running',
stack: 'mys',
service: 'web',
imageId: 'img1',
labels: {
'com.docker.compose.service': 'web',
'traefik.enable': 'true',
},
},
]);
const inv = await buildNodeLabelInventory(nodeId);
expect(inv.containers).toHaveLength(1);
expect(inv.byLabel).toHaveLength(2);
const svc = inv.containers[0].labels.find(l => l.key === 'com.docker.compose.service');
expect(svc?.source).toBe('compose-system');
const traefik = inv.containers[0].labels.find(l => l.key === 'traefik.enable');
expect(traefik?.source).toBe('runtime');
expect(inv.partial).toBe(false);
});
it('attributes image-inherited labels to the image, but runtime overrides stay runtime', async () => {
stubDockerList([
{
id: 'c1',
name: 'plex-1',
state: 'running',
stack: 'media',
service: 'plex',
imageId: 'plex-img',
labels: {
'org.opencontainers.image.title': 'Plex',
'traefik.enable': 'true',
},
},
], { images: { 'plex-img': { 'org.opencontainers.image.title': 'Plex', 'traefik.enable': 'false' } } });
const inv = await buildNodeLabelInventory(nodeId);
const oci = inv.containers[0].labels.find(l => l.key === 'org.opencontainers.image.title');
expect(oci?.source).toBe('image');
// Same key on the image but a different value: the container overrides it, so runtime.
const traefik = inv.containers[0].labels.find(l => l.key === 'traefik.enable');
expect(traefik?.source).toBe('runtime');
expect(inv.partial).toBe(false);
});
it('marks labels unknown and the inventory partial when the image inspect fails', async () => {
stubDockerList([
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'broken', labels: { 'custom.label': 'v' } },
], { images: { broken: null } });
const inv = await buildNodeLabelInventory(nodeId);
expect(inv.containers[0].labels.find(l => l.key === 'custom.label')?.source).toBe('unknown');
expect(inv.partial).toBe(true);
});
it('treats an empty image id as unknown and partial without inspecting an empty id', async () => {
const { inspectImageLabels } = stubDockerList([
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: '', labels: { 'custom.label': 'v' } },
]);
const inv = await buildNodeLabelInventory(nodeId);
expect(inv.containers[0].labels.find(l => l.key === 'custom.label')?.source).toBe('unknown');
expect(inv.partial).toBe(true);
expect(inspectImageLabels).not.toHaveBeenCalledWith('');
});
it('inspects each shared image only once (dedup)', async () => {
const { inspectImageLabels } = stubDockerList([
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'shared', labels: { a: '1' } },
{ id: 'c2', name: 'a-2', state: 'running', stack: 's', service: 'a', imageId: 'shared', labels: { a: '1' } },
], { images: { shared: {} } });
await buildNodeLabelInventory(nodeId);
const sharedCalls = inspectImageLabels.mock.calls.filter(c => c[0] === 'shared');
expect(sharedCalls).toHaveLength(1);
});
it('redacts secret-like label values by default', async () => {
stubDockerList([
{
id: 'c1',
name: 'app-1',
state: 'running',
stack: 'sec',
service: 'app',
imageId: 'img1',
labels: { 'my.api.token': 'super-secret', 'traefik.enable': 'true' },
},
]);
const inv = await buildNodeLabelInventory(nodeId);
const token = inv.containers[0].labels.find(l => l.key === 'my.api.token');
expect(token?.value).toBe(REDACTED_SENTINEL);
expect(token?.redacted).toBe(true);
const plain = inv.containers[0].labels.find(l => l.key === 'traefik.enable');
expect(plain?.value).toBe('true');
expect(plain?.redacted).toBeUndefined();
});
it('redacts Traefik basicauth and digestauth label values', async () => {
stubDockerList([
{
id: 'c1', name: 'web-1', state: 'running', stack: 's', service: 'web', imageId: 'img1',
labels: {
'traefik.http.middlewares.foo.basicauth.users': 'admin:$apr1$abc123',
'traefik.http.middlewares.bar.digestauth.users': 'admin:realm:deadbeef',
'traefik.enable': 'true',
},
},
]);
const inv = await buildNodeLabelInventory(nodeId);
const basic = inv.containers[0].labels.find(l => l.key.endsWith('basicauth.users'));
const digest = inv.containers[0].labels.find(l => l.key.endsWith('digestauth.users'));
expect(basic?.value).toBe(REDACTED_SENTINEL);
expect(basic?.redacted).toBe(true);
expect(digest?.value).toBe(REDACTED_SENTINEL);
expect(digest?.redacted).toBe(true);
expect(inv.containers[0].labels.find(l => l.key === 'traefik.enable')?.redacted).toBeUndefined();
});
it('reveals secret-like values when revealSecrets is true', async () => {
stubDockerList([
{
id: 'c1',
name: 'app-1',
state: 'running',
stack: 'sec',
service: 'app',
imageId: 'img1',
labels: { 'api.token': 'visible-when-revealed' },
},
]);
const inv = await buildNodeLabelInventory(nodeId, { revealSecrets: true });
expect(inv.containers[0].labels[0].value).toBe('visible-when-revealed');
expect(inv.containers[0].labels[0].redacted).toBeUndefined();
});
});
describe('buildStackLabelInventory', () => {
it('reconciles declared and runtime labels', async () => {
writeStack('lbl1', {
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n traefik.enable: "true"\n compose.only: "1"\n',
});
stubRender({ web: { 'traefik.enable': 'true', 'compose.only': '1' } });
stubDockerList([
{
id: 'c1',
name: 'lbl1-web-1',
state: 'running',
stack: 'lbl1',
service: 'web',
imageId: 'img1',
labels: {
'traefik.enable': 'true',
'runtime.only': '1',
'com.docker.compose.service': 'web',
},
},
]);
const inv = await buildStackLabelInventory(nodeId, 'lbl1');
expect(inv.renderable).toBe(true);
expect(inv.partial).toBe(false);
const web = inv.services.find(s => s.service === 'web');
expect(web?.declaredLabels.map(l => l.key)).toEqual(['compose.only', 'traefik.enable']);
expect(web?.replicas[0].onlyInCompose).toEqual(['compose.only']);
expect(web?.replicas[0].onlyOnContainer).toContain('runtime.only');
expect(web?.replicas[0].inBoth).toContain('traefik.enable');
expect(web?.replicas[0].changed).toEqual([]);
});
it('flags a value that drifted between compose and runtime as changed, not inBoth', async () => {
writeStack('drift1', {
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n watchtower.enable: "true"\n',
});
stubRender({ web: { 'watchtower.enable': 'true' } });
stubDockerList([
{
id: 'c1',
name: 'drift1-web-1',
state: 'running',
stack: 'drift1',
service: 'web',
imageId: 'img1',
labels: { 'watchtower.enable': 'false' },
},
]);
const inv = await buildStackLabelInventory(nodeId, 'drift1');
const web = inv.services.find(s => s.service === 'web');
expect(web?.replicas[0].changed).toEqual(['watchtower.enable']);
expect(web?.replicas[0].inBoth).not.toContain('watchtower.enable');
});
it('detects drift on a secret-like key while its value stays redacted', async () => {
writeStack('drift2', {
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n auth.token: "declared"\n',
});
stubRender({ web: { 'auth.token': 'declared' } });
stubDockerList([
{ id: 'c1', name: 'drift2-web-1', state: 'running', stack: 'drift2', service: 'web', imageId: 'img1', labels: { 'auth.token': 'runtime' } },
]);
const inv = await buildStackLabelInventory(nodeId, 'drift2');
const web = inv.services.find(s => s.service === 'web');
expect(web?.replicas[0].changed).toEqual(['auth.token']);
const rt = web?.replicas[0].runtimeLabels.find(l => l.key === 'auth.token');
expect(rt?.value).toBe(REDACTED_SENTINEL);
expect(rt?.redacted).toBe(true);
});
it('marks a replica inspectFailed and skips reconciliation instead of reporting false drift', async () => {
writeStack('fail1', {
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n traefik.enable: "true"\n',
});
stubRender({ web: { 'traefik.enable': 'true' } });
stubDockerList([
{ id: 'c1', name: 'fail1-web-1', state: 'running', stack: 'fail1', service: 'web', imageId: 'img1', labels: {}, inspectFailed: true },
]);
const inv = await buildStackLabelInventory(nodeId, 'fail1');
const web = inv.services.find(s => s.service === 'web');
expect(web?.replicas[0].inspectFailed).toBe(true);
expect(web?.replicas[0].onlyInCompose).toEqual([]);
expect(web?.replicas[0].runtimeLabels).toEqual([]);
expect(inv.partial).toBe(true);
});
it('inspects each shared image only once across replicas (dedup)', async () => {
writeStack('ddup', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
stubRender({ web: {} });
const { inspectImageLabels } = stubDockerList([
{ id: 'c1', name: 'ddup-web-1', state: 'running', stack: 'ddup', service: 'web', imageId: 'shared', labels: { a: '1' } },
{ id: 'c2', name: 'ddup-web-2', state: 'running', stack: 'ddup', service: 'web', imageId: 'shared', labels: { a: '1' } },
], { images: { shared: {} } });
await buildStackLabelInventory(nodeId, 'ddup');
expect(inspectImageLabels.mock.calls.filter(c => c[0] === 'shared')).toHaveLength(1);
});
it('attributes provenance on the stack path: compose wins over image, image labels tagged image', async () => {
writeStack('prov1', {
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n foo: "bar"\n',
});
stubRender({ web: { foo: 'bar' } });
stubDockerList([
{ id: 'c1', name: 'prov1-web-1', state: 'running', stack: 'prov1', service: 'web', imageId: 'img1', labels: { foo: 'bar', 'org.opencontainers.image.title': 'Nginx' } },
], { images: { img1: { foo: 'bar', 'org.opencontainers.image.title': 'Nginx' } } });
const inv = await buildStackLabelInventory(nodeId, 'prov1');
const rep = inv.services.find(s => s.service === 'web')?.replicas[0];
// foo is on both the image and the Compose file with the same value: Compose wins.
expect(rep?.runtimeLabels.find(l => l.key === 'foo')?.source).toBe('compose');
expect(rep?.runtimeLabels.find(l => l.key === 'org.opencontainers.image.title')?.source).toBe('image');
expect(inv.partial).toBe(false);
});
it('marks stack runtime labels unknown and the inventory partial when the image inspect fails', async () => {
writeStack('prov2', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
stubRender({ web: {} });
stubDockerList([
{ id: 'c1', name: 'prov2-web-1', state: 'running', stack: 'prov2', service: 'web', imageId: 'broken', labels: { 'custom.label': 'v' } },
], { images: { broken: null } });
const inv = await buildStackLabelInventory(nodeId, 'prov2');
const rep = inv.services.find(s => s.service === 'web')?.replicas[0];
expect(rep?.runtimeLabels.find(l => l.key === 'custom.label')?.source).toBe('unknown');
expect(inv.partial).toBe(true);
});
it('parses list-form compose labels', async () => {
writeStack('lbl2', {
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n - "watchtower.enable=true"\n',
});
stubRender({ web: { 'watchtower.enable': 'true' } });
stubDockerList([]);
const inv = await buildStackLabelInventory(nodeId, 'lbl2');
expect(inv.services[0].declaredLabels[0]).toMatchObject({ key: 'watchtower.enable', value: 'true', source: 'compose' });
});
it('sets renderable false when compose render fails', async () => {
writeStack('lbl3', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
stubRender(null);
stubDockerList([]);
const inv = await buildStackLabelInventory(nodeId, 'lbl3');
expect(inv.renderable).toBe(false);
});
it('resolves non-system labels to unknown and skips reconciliation when render fails', async () => {
writeStack('rf1', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
stubRender(null);
stubDockerList([
{
id: 'c1', name: 'rf1-web-1', state: 'running', stack: 'rf1', service: 'web', imageId: 'img1',
labels: { 'traefik.enable': 'true', 'com.docker.compose.service': 'web' },
},
]);
const inv = await buildStackLabelInventory(nodeId, 'rf1');
expect(inv.renderable).toBe(false);
// Render failure is signalled by renderable, not partial (which is for inspect failures).
expect(inv.partial).toBe(false);
const rep = inv.services.find(s => s.service === 'web')?.replicas[0];
expect(rep?.runtimeLabels.find(l => l.key === 'traefik.enable')?.source).toBe('unknown');
expect(rep?.runtimeLabels.find(l => l.key === 'com.docker.compose.service')?.source).toBe('compose-system');
expect(rep?.onlyOnContainer).toEqual([]);
expect(rep?.changed).toEqual([]);
});
});
describe('GET /api/system/container-labels', () => {
it('requires authentication', async () => {
const res = await request(app).get('/api/system/container-labels');
expect(res.status).toBe(401);
});
it('returns node inventory', async () => {
stubDockerList([
{ id: 'c1', name: 'a', state: 'running', stack: 's', service: 'web', imageId: 'img1', labels: { foo: 'bar' } },
]);
const res = await request(app).get('/api/system/container-labels').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.nodeId).toBe(nodeId);
expect(res.body.containers).toHaveLength(1);
});
it('redacts secrets by default and reveals them for an admin with reveal=1', async () => {
stubDockerList([
{ id: 'c1', name: 'a', state: 'running', stack: 's', service: 'web', imageId: 'img1', labels: { 'api.token': 's3cr3t' } },
]);
const redacted = await request(app).get('/api/system/container-labels').set('Cookie', authCookie);
const rLabel = redacted.body.containers[0].labels.find((l: { key: string }) => l.key === 'api.token');
expect(rLabel.value).toBe(REDACTED_SENTINEL);
expect(rLabel.redacted).toBe(true);
stubDockerList([
{ id: 'c1', name: 'a', state: 'running', stack: 's', service: 'web', imageId: 'img1', labels: { 'api.token': 's3cr3t' } },
]);
const revealed = await request(app).get('/api/system/container-labels?reveal=1').set('Cookie', authCookie);
const vLabel = revealed.body.containers[0].labels.find((l: { key: string }) => l.key === 'api.token');
expect(vLabel.value).toBe('s3cr3t');
});
});
describe('GET /api/stacks/:stackName/label-inventory', () => {
it('returns 404 for unknown stack', async () => {
const res = await request(app).get('/api/stacks/missing-stack/label-inventory').set('Cookie', authCookie);
expect(res.status).toBe(404);
});
it('returns stack inventory', async () => {
writeStack('route1', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
stubRender({ web: {} });
stubDockerList([]);
const res = await request(app).get('/api/stacks/route1/label-inventory').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.stackName).toBe('route1');
});
});
describe('GET /api/fleet/container-labels', () => {
it('requires authentication', async () => {
const res = await request(app).get('/api/fleet/container-labels');
expect(res.status).toBe(401);
});
it('aggregates the local node with no node errors', async () => {
stubDockerList([
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'img1', labels: { 'shared.label': 'v' } },
{ id: 'c2', name: 'b-1', state: 'running', stack: 's', service: 'b', imageId: 'img1', labels: { 'shared.label': 'v' } },
]);
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.nodeErrors).toEqual({});
const shared = res.body.aggregatedByLabel.filter((r: { key: string }) => r.key === 'shared.label');
expect(shared).toHaveLength(1);
expect(shared[0].containers).toHaveLength(2);
});
it('keeps the same key=value distinct when the source differs', async () => {
stubDockerList([
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'ia', labels: { 'dup.label': 'v' } },
{ id: 'c2', name: 'b-1', state: 'running', stack: 's', service: 'b', imageId: 'ib', labels: { 'dup.label': 'v' } },
], { images: { ia: { 'dup.label': 'v' }, ib: {} } });
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
const dup = res.body.aggregatedByLabel.filter((r: { key: string }) => r.key === 'dup.label');
expect(dup).toHaveLength(2);
// The server sorts by key, value, then source; assert that order directly (no re-sort).
expect(dup.map((r: { source: string }) => r.source)).toEqual(['image', 'runtime']);
});
it('degrades an unreachable remote into nodeErrors without failing the whole request', async () => {
stubDockerList([
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'img1', labels: { foo: 'bar' } },
]);
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: 'remote-lbl', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('nope', { status: 502 }));
try {
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.nodeErrors[remoteId]).toBeDefined();
expect(res.body.aggregatedByLabel.some((r: { key: string }) => r.key === 'foo')).toBe(true);
} finally {
db.deleteNode(remoteId);
}
});
it('degrades a malformed remote payload into nodeErrors, not a 500', async () => {
stubDockerList([
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'img1', labels: { foo: 'bar' } },
]);
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: 'remote-bad', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
// byLabel row missing valid key/value/source: must be rejected by the deep guard.
const malformed = JSON.stringify({ nodeId: remoteId, containers: [], byLabel: [{ key: 123, containers: [] }], partial: false, generatedAt: 0 });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(malformed, { status: 200, headers: { 'content-type': 'application/json' } }));
try {
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.nodeErrors[remoteId]).toBeDefined();
} finally {
db.deleteNode(remoteId);
}
});
it('rejects a remote row with an invalid source value via the source allowlist', async () => {
stubDockerList([]);
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: 'remote-src', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
const badSource = JSON.stringify({ nodeId: remoteId, containers: [], partial: false, generatedAt: 0, byLabel: [{ key: 'k', value: 'v', source: 'not-a-source', containers: [{ id: 'c', name: 'n' }] }] });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badSource, { status: 200, headers: { 'content-type': 'application/json' } }));
try {
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.nodeErrors[remoteId]).toBe('Remote returned an unexpected label-inventory payload');
} finally {
db.deleteNode(remoteId);
}
});
it('degrades a remote with a malformed inventory container into nodeErrors', async () => {
stubDockerList([]);
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: 'remote-cont', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
const badContainer = JSON.stringify({ nodeId: remoteId, byLabel: [], partial: false, generatedAt: 0, containers: [{}] });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badContainer, { status: 200, headers: { 'content-type': 'application/json' } }));
try {
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.nodeErrors[remoteId]).toBeDefined();
} finally {
db.deleteNode(remoteId);
}
});
it('rejects a remote row with a malformed nested container ref', async () => {
stubDockerList([]);
const db = DatabaseService.getInstance();
const remoteId = db.addNode({ name: 'remote-ref', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
const badRef = JSON.stringify({ nodeId: remoteId, containers: [], partial: false, generatedAt: 0, byLabel: [{ key: 'k', value: 'v', source: 'runtime', containers: [{ id: 5 }] }] });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badRef, { status: 200, headers: { 'content-type': 'application/json' } }));
try {
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.nodeErrors[remoteId]).toBeDefined();
} finally {
db.deleteNode(remoteId);
}
});
});
+28 -1
View File
@@ -2,13 +2,15 @@
* Unit tests for MonitorService — alert state machine, metric calculations,
* cleanup delegation, global settings evaluation, and concurrency guards.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
import { installArcstatsFsMock, arcstatsBody, DEFAULT_ARC_PATH, type ArcstatsFsMock } from './helpers/arcstatsFsMock';
// ── Hoisted mocks ──────────────────────────────────────────────────────
const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContainerMetric,
mockCleanupOldMetrics, mockCleanupOldNotifications, mockCleanupOldAuditLogs,
mockUpdateStackAlertLastFired, mockGetSystemState, mockSetSystemState,
mockPruneScanHistoryPerImage, mockDeleteScansByImageRef,
mockGetRunningContainers, mockGetAllContainers, mockGetContainerStatsStream,
mockGetContainerRestartCount, mockGetDiskUsage, mockGetImages, mockGetStacks,
mockDispatchAlert,
@@ -29,6 +31,8 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine
mockUpdateStackAlertLastFired: vi.fn(),
mockGetSystemState: vi.fn().mockReturnValue(null),
mockSetSystemState: vi.fn(),
mockPruneScanHistoryPerImage: vi.fn().mockReturnValue(0),
mockDeleteScansByImageRef: vi.fn().mockReturnValue(0),
mockGetRunningContainers: vi.fn().mockResolvedValue([]),
mockGetAllContainers: vi.fn().mockResolvedValue([]),
mockGetContainerStatsStream: vi.fn().mockResolvedValue('{}'),
@@ -63,6 +67,8 @@ vi.mock('../services/DatabaseService', () => ({
updateStackAlertLastFired: mockUpdateStackAlertLastFired,
getSystemState: mockGetSystemState,
setSystemState: mockSetSystemState,
pruneScanHistoryPerImage: mockPruneScanHistoryPerImage,
deleteScansByImageRef: mockDeleteScansByImageRef,
}),
},
}));
@@ -138,8 +144,16 @@ vi.mock('util', () => ({
import { MonitorService, _resetHostAlertSuppressionStateForTests } from '../services/MonitorService';
// Host memory now reads ZFS ARC stats; intercept those reads so the suite does
// not depend on whether the machine running it is itself a ZFS host.
let arcFs: ArcstatsFsMock;
beforeAll(() => {
arcFs = installArcstatsFsMock();
});
beforeEach(() => {
vi.clearAllMocks();
arcFs.clear();
(MonitorService as any).instance = undefined;
_resetHostAlertSuppressionStateForTests();
mockGetSystemState.mockReturnValue(null);
@@ -363,6 +377,19 @@ describe('MonitorService - evaluateGlobalSettings', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Memory'));
});
it('does not alert when reclaimable ZFS ARC accounts for the memory pressure', async () => {
// Active working set reads 15G/16G (~94%, breaches 80%), but 5G of that is
// reclaimable ARC. Adding ARC back into available drops effective usage to
// ~62.5%, so no host-memory alert should fire.
mockMem.mockResolvedValue(memSample(15e9)); // available 1e9 -> 93.75%
arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(5e9, 0)); // reclaimable 5e9
const svc = MonitorService.getInstance();
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Memory'));
});
it('dispatches disk warning when over threshold', async () => {
mockFsSize.mockResolvedValue([{ mount: '/', use: 92 }]);
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest';
import {
matchesNotificationFilters,
ruleNeedsStackLabels,
appliesToBell,
appliesToExternal,
} from '../helpers/notificationMatchers';
import type { NotificationMatchContext } from '../helpers/notificationMatchers';
const baseCtx: NotificationMatchContext = {
localNodeId: 1,
stackName: 'my-app',
category: 'monitor_alert',
level: 'error',
stackLabelIds: [10],
};
describe('notificationMatchers', () => {
it('matches when all non-empty filters pass', () => {
expect(matchesNotificationFilters(baseCtx, {
node_id: null,
stack_patterns: ['my-app'],
label_ids: [10],
categories: ['monitor_alert'],
levels: ['error'],
})).toBe(true);
});
it('rejects when node_id does not match', () => {
expect(matchesNotificationFilters(baseCtx, {
node_id: 2,
stack_patterns: [],
label_ids: null,
categories: null,
})).toBe(false);
});
it('rejects when stack pattern does not match', () => {
expect(matchesNotificationFilters(baseCtx, {
node_id: null,
stack_patterns: ['other'],
label_ids: null,
categories: null,
})).toBe(false);
});
it('rejects when category does not match', () => {
expect(matchesNotificationFilters(baseCtx, {
node_id: null,
stack_patterns: [],
label_ids: null,
categories: ['deploy_success'],
})).toBe(false);
});
it('rejects when level does not match', () => {
expect(matchesNotificationFilters(baseCtx, {
node_id: null,
stack_patterns: [],
label_ids: null,
categories: null,
levels: ['info'],
})).toBe(false);
});
it('matches any when all matchers empty', () => {
expect(matchesNotificationFilters(baseCtx, {
node_id: null,
stack_patterns: [],
label_ids: null,
categories: null,
levels: null,
})).toBe(true);
});
it('detects when stack labels are needed', () => {
expect(ruleNeedsStackLabels([{ node_id: null, stack_patterns: [], label_ids: [1], categories: null }])).toBe(true);
expect(ruleNeedsStackLabels([{ node_id: null, stack_patterns: [], label_ids: null, categories: null }])).toBe(false);
});
it('applies_to helpers', () => {
expect(appliesToBell('bell')).toBe(true);
expect(appliesToBell('external')).toBe(false);
expect(appliesToExternal('external')).toBe(true);
expect(appliesToExternal('bell')).toBe(false);
expect(appliesToBell('both')).toBe(true);
expect(appliesToExternal('both')).toBe(true);
});
});
@@ -8,12 +8,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const {
mockGetEnabledNotificationRoutes,
mockGetEnabledNotificationSuppressionRules,
mockGetEnabledAgents,
mockGetStackLabelIds,
mockAddNotificationHistory,
mockUpdateNotificationDispatchError,
} = vi.hoisted(() => ({
mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]),
mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]),
mockGetEnabledAgents: vi.fn().mockReturnValue([]),
mockGetStackLabelIds: vi.fn().mockReturnValue([]),
mockAddNotificationHistory: vi.fn().mockReturnValue({
@@ -30,6 +32,7 @@ vi.mock('../services/DatabaseService', () => ({
DatabaseService: {
getInstance: () => ({
getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes,
getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules,
getEnabledAgents: mockGetEnabledAgents,
getStackLabelIds: mockGetStackLabelIds,
addNotificationHistory: mockAddNotificationHistory,
@@ -0,0 +1,176 @@
/**
* Integration tests for notification suppression rules CRUD.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let authCookie: string;
let viewerCookie: string;
const validBody = {
name: 'Mute staging',
stack_patterns: ['staging'],
categories: ['monitor_alert'],
levels: ['warning'],
applies_to: 'both',
enabled: true,
expires_at: null,
};
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
({ app } = await import('../index'));
authCookie = await loginAsTestAdmin(app);
const viewerHash = await bcrypt.hash('viewerpass', 1);
DatabaseService.getInstance().addUser({ username: 'viewer', password_hash: viewerHash, role: 'viewer' });
const viewerRes = await request(app)
.post('/api/auth/login')
.send({ username: 'viewer', password: 'viewerpass' });
const cookies = viewerRes.headers['set-cookie'] as string | string[];
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('Notification suppression - auth enforcement', () => {
it('GET returns 401 without auth', async () => {
const res = await request(app).get('/api/notification-suppression-rules');
expect(res.status).toBe(401);
});
it('GET returns 403 for viewer', async () => {
const res = await request(app)
.get('/api/notification-suppression-rules')
.set('Cookie', viewerCookie);
expect(res.status).toBe(403);
});
it('POST returns 403 for viewer', async () => {
const res = await request(app)
.post('/api/notification-suppression-rules')
.set('Cookie', viewerCookie)
.send(validBody);
expect(res.status).toBe(403);
});
});
describe('Notification suppression - CRUD', () => {
it('POST creates a rule on Community tier', async () => {
const res = await request(app)
.post('/api/notification-suppression-rules')
.set('Cookie', authCookie)
.send(validBody);
expect(res.status).toBe(201);
expect(res.body.name).toBe('Mute staging');
expect(res.body.applies_to).toBe('both');
if (typeof res.body?.id === 'number') {
DatabaseService.getInstance().deleteNotificationSuppressionRule(res.body.id);
}
});
it('GET lists rules', async () => {
const res = await request(app)
.get('/api/notification-suppression-rules')
.set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('POST rejects invalid applies_to', async () => {
const res = await request(app)
.post('/api/notification-suppression-rules')
.set('Cookie', authCookie)
.send({ ...validBody, applies_to: 'invalid' });
expect(res.status).toBe(400);
});
it('POST rejects invalid levels', async () => {
const res = await request(app)
.post('/api/notification-suppression-rules')
.set('Cookie', authCookie)
.send({ ...validBody, levels: ['critical'] });
expect(res.status).toBe(400);
});
it('POST accepts history-only category update_started (bell-visible)', async () => {
const res = await request(app)
.post('/api/notification-suppression-rules')
.set('Cookie', authCookie)
.send({
name: 'Mute stack updates',
stack_patterns: [],
categories: ['update_started'],
levels: null,
applies_to: 'both',
enabled: true,
expires_at: null,
});
expect(res.status).toBe(201);
expect(res.body.categories).toEqual(['update_started']);
if (typeof res.body?.id === 'number') {
DatabaseService.getInstance().deleteNotificationSuppressionRule(res.body.id);
}
});
it('POST accepts routable category image_update_available', async () => {
const res = await request(app)
.post('/api/notification-suppression-rules')
.set('Cookie', authCookie)
.send({
name: 'Mute image updates',
stack_patterns: [],
categories: ['image_update_available'],
levels: null,
applies_to: 'both',
enabled: true,
expires_at: null,
});
expect(res.status).toBe(201);
if (typeof res.body?.id === 'number') {
DatabaseService.getInstance().deleteNotificationSuppressionRule(res.body.id);
}
});
it('PUT updates a rule', async () => {
const created = await request(app)
.post('/api/notification-suppression-rules')
.set('Cookie', authCookie)
.send(validBody);
const id = created.body.id as number;
const res = await request(app)
.put(`/api/notification-suppression-rules/${id}`)
.set('Cookie', authCookie)
.send({ enabled: false });
expect(res.status).toBe(200);
expect(res.body.enabled).toBe(false);
DatabaseService.getInstance().deleteNotificationSuppressionRule(id);
});
it('DELETE removes a rule', async () => {
const created = await request(app)
.post('/api/notification-suppression-rules')
.set('Cookie', authCookie)
.send(validBody);
const id = created.body.id as number;
const res = await request(app)
.delete(`/api/notification-suppression-rules/${id}`)
.set('Cookie', authCookie);
expect(res.status).toBe(200);
});
});
@@ -0,0 +1,175 @@
/**
* Unit tests for notification suppression in NotificationService.dispatchAlert.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const {
mockGetEnabledNotificationRoutes,
mockGetEnabledAgents,
mockGetStackLabelIds,
mockAddNotificationHistory,
mockUpdateNotificationDispatchError,
mockGetEnabledNotificationSuppressionRules,
mockUpdateNotificationSuppressionMatch,
mockBroadcast,
} = vi.hoisted(() => ({
mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]),
mockGetEnabledAgents: vi.fn().mockReturnValue([]),
mockGetStackLabelIds: vi.fn().mockReturnValue([]),
mockAddNotificationHistory: vi.fn().mockReturnValue({
id: 1,
level: 'error',
message: 'test',
timestamp: Date.now(),
is_read: 0,
}),
mockUpdateNotificationDispatchError: vi.fn(),
mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]),
mockUpdateNotificationSuppressionMatch: vi.fn(),
mockBroadcast: vi.fn(),
}));
vi.mock('../services/DatabaseService', () => ({
DatabaseService: {
getInstance: () => ({
getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes,
getEnabledAgents: mockGetEnabledAgents,
getStackLabelIds: mockGetStackLabelIds,
addNotificationHistory: mockAddNotificationHistory,
updateNotificationDispatchError: mockUpdateNotificationDispatchError,
getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules,
updateNotificationSuppressionMatch: mockUpdateNotificationSuppressionMatch,
}),
},
}));
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getDefaultNodeId: () => 1,
getComposeDir: () => '/app/compose',
}),
},
}));
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal('fetch', mockFetch);
import { NotificationService } from '../services/NotificationService';
import { StackActivityMetricsService } from '../services/StackActivityMetricsService';
function makeSuppressionRule(overrides: Record<string, unknown> = {}) {
return {
id: 1,
name: 'Mute crashes',
node_id: null as number | null,
stack_patterns: [] as string[],
label_ids: null as number[] | null,
categories: ['monitor_alert'] as string[] | null,
levels: null as string[] | null,
applies_to: 'both' as const,
enabled: true,
expires_at: null as number | null,
created_at: Date.now(),
updated_at: Date.now(),
...overrides,
};
}
function makeRoute() {
return {
id: 1,
name: 'Prod Discord',
node_id: null,
stack_patterns: ['my-app'],
label_ids: null,
categories: null,
channel_type: 'discord' as const,
channel_url: 'https://discord.com/api/webhooks/123/abc',
priority: 0,
enabled: true,
created_at: Date.now(),
updated_at: Date.now(),
};
}
describe('NotificationService - suppression logic', () => {
let svc: NotificationService;
beforeEach(() => {
vi.clearAllMocks();
(NotificationService as unknown as { instance?: NotificationService }).instance = undefined;
svc = NotificationService.getInstance();
vi.spyOn(
svc as unknown as { broadcastToSubscribers: (n: unknown) => void },
'broadcastToSubscribers',
).mockImplementation(mockBroadcast);
vi.spyOn(StackActivityMetricsService.getInstance(), 'record').mockImplementation(() => {});
});
it('suppresses category via external dispatch', async () => {
mockGetEnabledNotificationSuppressionRules.mockReturnValue([
makeSuppressionRule({ categories: ['monitor_alert'], applies_to: 'external' }),
]);
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
await svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' });
expect(mockBroadcast).toHaveBeenCalled();
expect(mockFetch).not.toHaveBeenCalled();
});
it('suppresses severity via bell only', async () => {
mockGetEnabledNotificationSuppressionRules.mockReturnValue([
makeSuppressionRule({ levels: ['error'], applies_to: 'bell', categories: null }),
]);
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
await svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' });
expect(mockBroadcast).not.toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalled();
});
it('suppresses both bell and external', async () => {
mockGetEnabledNotificationSuppressionRules.mockReturnValue([
makeSuppressionRule({ categories: ['monitor_alert'], applies_to: 'both' }),
]);
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
await svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' });
expect(mockBroadcast).not.toHaveBeenCalled();
expect(mockFetch).not.toHaveBeenCalled();
expect(mockUpdateNotificationSuppressionMatch).toHaveBeenCalledWith(1, {
rules: [{ id: 1, name: 'Mute crashes' }],
bellSuppressed: true,
externalSuppressed: true,
});
});
it('allows dispatch when no suppression rules match', async () => {
mockGetEnabledNotificationSuppressionRules.mockReturnValue([]);
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
await svc.dispatchAlert('error', 'monitor_alert', 'Crash');
expect(mockBroadcast).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalled();
});
it('routing still works when suppression does not match', async () => {
mockGetEnabledNotificationSuppressionRules.mockReturnValue([
makeSuppressionRule({ categories: ['deploy_success'], applies_to: 'both' }),
]);
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
await svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' });
expect(mockBroadcast).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(
'https://discord.com/api/webhooks/123/abc',
expect.objectContaining({ method: 'POST' }),
);
});
});
@@ -19,7 +19,7 @@ const EXPECTED_ACTIONS: BackendScheduledAction[] = [
'auto_backup', 'auto_stop', 'auto_down', 'auto_start',
];
const ALL_TARGET_TYPES: TargetType[] = ['stack', 'fleet', 'system'];
const ALL_TARGET_TYPES: TargetType[] = ['stack', 'fleet', 'system', 'container'];
describe('scheduledActionRegistry', () => {
it('exposes exactly the known backend actions, in order', () => {
@@ -46,27 +46,27 @@ describe('scheduledActionRegistry', () => {
describe('validateActionTarget', () => {
const validPairs: Record<BackendScheduledAction, TargetType[]> = {
restart: ['stack'],
restart: ['stack', 'container'],
snapshot: ['fleet'],
prune: ['system'],
update: ['stack', 'fleet'],
scan: ['system'],
auto_backup: ['stack'],
auto_stop: ['stack'],
auto_stop: ['stack', 'container'],
auto_down: ['stack'],
auto_start: ['stack'],
auto_start: ['stack', 'container'],
};
const mismatchMessage: Record<BackendScheduledAction, string> = {
restart: 'Restart action requires target_type "stack".',
restart: 'Restart action requires target_type "stack" or "container".',
snapshot: 'Snapshot action requires target_type "fleet".',
prune: 'Prune action requires target_type "system".',
update: 'Update action requires target_type "stack" or "fleet".',
scan: 'Scan action requires target_type "system".',
auto_backup: 'auto_backup action requires target_type "stack".',
auto_stop: 'auto_stop action requires target_type "stack".',
auto_stop: 'auto_stop action requires target_type "stack" or "container".',
auto_down: 'auto_down action requires target_type "stack".',
auto_start: 'auto_start action requires target_type "stack".',
auto_start: 'auto_start action requires target_type "stack" or "container".',
};
for (const action of EXPECTED_ACTIONS) {
@@ -594,6 +594,64 @@ describe('POST /api/scheduled-tasks - new lifecycle actions', () => {
});
});
describe('POST /api/scheduled-tasks - container lifecycle', () => {
it('creates a container restart schedule', async () => {
const res = await request(app)
.post('/api/scheduled-tasks')
.set('Cookie', adminCookie)
.send({
name: 'daily-watchtower-restart',
target_type: 'container',
target_id: 'watchtower',
node_id: 1,
action: 'restart',
cron_expression: '0 3 * * *',
enabled: true,
});
expect(res.status).toBe(201);
expect(res.body.target_type).toBe('container');
expect(res.body.target_id).toBe('watchtower');
expect(res.body.action).toBe('restart');
});
it('rejects invalid container names', async () => {
const res = await request(app)
.post('/api/scheduled-tasks')
.set('Cookie', adminCookie)
.send({
name: 'bad-container',
target_type: 'container',
target_id: '../escape',
node_id: 1,
action: 'restart',
cron_expression: '0 3 * * *',
enabled: true,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/valid container name/);
});
for (const action of ['auto_stop', 'auto_start'] as const) {
it(`creates container ${action} schedule`, async () => {
const res = await request(app)
.post('/api/scheduled-tasks')
.set('Cookie', adminCookie)
.send({
name: `ctr-${action}`,
target_type: 'container',
target_id: 'sidecar',
node_id: 1,
action,
cron_expression: '0 4 * * *',
enabled: true,
});
expect(res.status).toBe(201);
expect(res.body.target_type).toBe('container');
expect(res.body.action).toBe(action);
});
}
});
describe('POST /api/scheduled-tasks - available on the Community tier', () => {
beforeEach(() => {
tierSpy.mockReturnValue('community');
+110 -2
View File
@@ -12,9 +12,10 @@ const {
mockUpdateScheduledTask, mockCleanupOldTaskRuns, mockGetScheduledTask, mockGetNodes, mockGetNode,
mockGetGlobalSettings, mockGetStackDossier,
mockCreateSnapshot, mockInsertSnapshotFiles, mockClearStackUpdateStatus,
mockMarkStaleRunsAsFailed, mockDeleteOldScans,
mockMarkStaleRunsAsFailed, mockMarkStaleScansAsFailed, mockDeleteOldScans,
mockGetTier, mockGetProxyHeaders,
mockGetContainersByStack, mockRestartContainer, mockPruneSystem,
mockGetContainersByStack, mockRestartContainer, mockFindContainerByName,
mockStartContainer, mockStopContainer, mockPruneSystem,
mockUpdateStack,
mockGetStacks, mockGetStackContent, mockGetEnvContent,
mockCheckImage,
@@ -43,11 +44,15 @@ const {
mockInsertSnapshotFiles: vi.fn(),
mockClearStackUpdateStatus: vi.fn(),
mockMarkStaleRunsAsFailed: vi.fn().mockReturnValue(0),
mockMarkStaleScansAsFailed: vi.fn().mockReturnValue(0),
mockDeleteOldScans: vi.fn().mockReturnValue(0),
mockGetTier: vi.fn().mockReturnValue('paid'),
mockGetProxyHeaders: vi.fn().mockReturnValue({ tier: 'paid' }),
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
mockRestartContainer: vi.fn().mockResolvedValue(undefined),
mockFindContainerByName: vi.fn().mockResolvedValue(null),
mockStartContainer: vi.fn().mockResolvedValue(undefined),
mockStopContainer: vi.fn().mockResolvedValue(undefined),
mockPruneSystem: vi.fn().mockResolvedValue({ success: true, reclaimedBytes: 0 }),
mockUpdateStack: vi.fn().mockResolvedValue(undefined),
mockGetStacks: vi.fn().mockResolvedValue([]),
@@ -89,6 +94,7 @@ vi.mock('../services/DatabaseService', () => ({
insertSnapshotFiles: mockInsertSnapshotFiles,
clearStackUpdateStatus: mockClearStackUpdateStatus,
markStaleRunsAsFailed: mockMarkStaleRunsAsFailed,
markStaleScansAsFailed: mockMarkStaleScansAsFailed,
deleteOldScans: mockDeleteOldScans,
deleteScheduledTask: mockDeleteScheduledTask,
getMatchingPolicy: mockGetMatchingPolicy,
@@ -117,6 +123,9 @@ vi.mock('../services/DockerController', () => ({
getInstance: () => ({
getContainersByStack: mockGetContainersByStack,
restartContainer: mockRestartContainer,
findContainerByName: mockFindContainerByName,
startContainer: mockStartContainer,
stopContainer: mockStopContainer,
pruneSystem: mockPruneSystem,
}),
},
@@ -530,6 +539,105 @@ describe('SchedulerService - executeRestart', () => {
expect.objectContaining({ status: 'failure', error: expect.stringContaining('No containers') })
);
});
it('restarts a standalone container by name', async () => {
mockGetScheduledTask.mockReturnValue({
id: 63,
name: 'restart-ctr',
action: 'restart',
target_type: 'container',
cron_expression: '0 3 * * *',
enabled: true,
target_id: 'watchtower',
node_id: 1,
created_by: 'admin',
last_status: null,
});
mockFindContainerByName.mockResolvedValue({
id: 'abc123deadbeef',
name: 'watchtower',
state: 'running',
image: 'containrrr/watchtower',
stackProject: null,
});
const svc = SchedulerService.getInstance();
await svc.triggerTask(63);
expect(mockFindContainerByName).toHaveBeenCalledWith('watchtower');
expect(mockRestartContainer).toHaveBeenCalledWith('abc123deadbeef');
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(
expect.any(Number),
expect.objectContaining({
status: 'success',
output: expect.stringContaining('watchtower'),
}),
);
});
it('records failure when container name is missing', async () => {
mockGetScheduledTask.mockReturnValue({
id: 64,
name: 'restart-missing',
action: 'restart',
target_type: 'container',
cron_expression: '0 3 * * *',
enabled: true,
target_id: 'gone-container',
node_id: 1,
created_by: 'admin',
last_status: null,
});
mockFindContainerByName.mockResolvedValue(null);
const svc = SchedulerService.getInstance();
await svc.triggerTask(64);
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(
expect.any(Number),
expect.objectContaining({
status: 'failure',
error: expect.stringContaining('gone-container'),
}),
);
});
it('resolves a new container ID when the name matches after recreation', async () => {
mockGetScheduledTask.mockReturnValue({
id: 65,
name: 'restart-recreated',
action: 'restart',
target_type: 'container',
cron_expression: '0 3 * * *',
enabled: true,
target_id: 'sidecar',
node_id: 1,
created_by: 'admin',
last_status: null,
});
mockFindContainerByName
.mockResolvedValueOnce({
id: 'old-id-111',
name: 'sidecar',
state: 'running',
image: 'sidecar:latest',
stackProject: null,
})
.mockResolvedValueOnce({
id: 'new-id-222',
name: 'sidecar',
state: 'running',
image: 'sidecar:latest',
stackProject: null,
});
const svc = SchedulerService.getInstance();
await svc.triggerTask(65);
await svc.triggerTask(65);
expect(mockRestartContainer).toHaveBeenNthCalledWith(1, 'old-id-111');
expect(mockRestartContainer).toHaveBeenNthCalledWith(2, 'new-id-222');
});
});
// ── executePrune ───────────────────────────────────────────────────────
@@ -140,6 +140,28 @@ describe('POST /api/stacks/:stackName/services/:serviceName/restart', () => {
expect(mockRestartContainer).toHaveBeenCalledWith('container-app-1');
expect(mockRestartContainer).not.toHaveBeenCalledWith('container-db-1');
});
it('matches smartFallback containers when Service is empty but container name equals service', async () => {
mockGetContainersByStack.mockResolvedValue([
{
Id: 'container-mariadb-1',
Service: '',
Names: ['/mariadb'],
State: 'running',
Status: 'Up 12 days',
Ports: [],
},
makeContainer('container-phpmyadmin-1', 'phpmyadmin'),
]);
const res = await request(app)
.post('/api/stacks/db-compose/services/mariadb/restart')
.set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.count).toBe(1);
expect(mockRestartContainer).toHaveBeenCalledWith('container-mariadb-1');
});
});
describe('POST /api/stacks/:stackName/services/:serviceName/stop', () => {
@@ -0,0 +1,29 @@
/**
* Match stack containers to a compose service name. `docker compose ps` sets
* `Service`, but smartFallback containers only had `Names` until Service was
* backfilled; these matchers also accept the compose service label and a
* container name equal to the service (common with `container_name:`).
*/
export interface ComposeServiceContainer {
Id: string;
Service?: string;
Names?: string[];
Labels?: Record<string, string>;
}
export function containerBelongsToComposeService(
container: ComposeServiceContainer,
serviceName: string,
): boolean {
if (container.Service === serviceName) return true;
if (container.Labels?.['com.docker.compose.service'] === serviceName) return true;
const containerName = container.Names?.[0]?.replace(/^\//, '');
return containerName === serviceName;
}
export function filterContainersByComposeService<T extends ComposeServiceContainer>(
containers: T[],
serviceName: string,
): T[] {
return containers.filter(c => containerBelongsToComposeService(c, serviceName));
}
@@ -0,0 +1,38 @@
import SelfIdentityService from '../services/SelfIdentityService';
export interface DockerContainerListRow {
Id: string;
Names?: string[];
Image?: string;
ImageID?: string;
Labels?: Record<string, string>;
}
/** Official Sencho release images (Docker Hub + GHCR). Used when SelfIdentity is unavailable on a peer. */
export function isPublishedSenchoImage(image: string): boolean {
const lower = image.toLowerCase();
return /(?:^|\/)saelix\/sencho(?:-dev)?(?:[:@]|$)/.test(lower)
|| /studio-saelix\/sencho(?:-dev)?(?:[:@]|$)/.test(lower);
}
function isLikelySenchoManagementContainer(c: DockerContainerListRow): boolean {
const name = c.Names?.[0]?.replace(/^\//, '').toLowerCase() ?? '';
if (name === 'sencho' || name === 'sencho-agent') return true;
if (c.Image && isPublishedSenchoImage(c.Image)) return true;
return false;
}
/** Drop the running Sencho instance from container picker lists. */
export async function excludeSelfContainers<T extends DockerContainerListRow>(containers: T[]): Promise<T[]> {
const self = SelfIdentityService.getInstance();
await self.initialize();
return containers.filter(c => {
const name = c.Names?.[0]?.replace(/^\//, '') ?? '';
if (self.isOwnContainer(c.Id)) return false;
if (name && self.isOwnContainer(name)) return false;
if (c.ImageID && self.isOwnImage(c.ImageID)) return false;
if (isLikelySenchoManagementContainer(c)) return false;
return true;
});
}
+141
View File
@@ -0,0 +1,141 @@
import si from 'systeminformation';
import { promises as fs } from 'fs';
/**
* Shared host-memory computation, ZFS ARC aware.
*
* `systeminformation.mem()` derives `active` as `total - available` on
* Linux/BSD/macOS, so keying usage off `active` already dodges page-cache
* inflation. It does NOT account for the OpenZFS ARC: the kernel's
* MemAvailable treats ARC as unavailable even though ARC shrinks under
* memory pressure, so on ZFS hosts a large ARC reads as hard-used memory
* and produces false host-memory alerts.
*
* When ARC kstats are readable we add the reclaimable portion
* (`max(size - c_min, 0)`) back into available memory. On non-ZFS hosts, or
* when the kstat file is not readable inside the container, ARC is treated as
* zero and the result is identical to the previous `active / total` behavior.
*/
/** Effective host memory after adding reclaimable ZFS ARC back into available. */
export interface HostMemory {
total: number;
/** Effective used bytes (ARC-adjusted). */
used: number;
/** Effective available bytes (ARC-adjusted). */
free: number;
/** Effective used as a percentage of total (0 when total is 0). */
usagePercent: number;
}
type MemData = Awaited<ReturnType<typeof si.mem>>;
/**
* Candidate arcstats paths in priority order. The operator override is only
* present when SENCHO_ZFS_ARCSTATS_PATH is set; the two fixed paths are the
* host-mounted and the standard container-visible kstat locations.
*/
export const ARCSTATS_FIXED_PATHS = [
'/host/proc/spl/kstat/zfs/arcstats',
'/proc/spl/kstat/zfs/arcstats',
];
/** Bound reads of the operator-supplied override path; arcstats is a few KB. */
const MAX_ARCSTATS_BYTES = 1024 * 1024;
// Memoized so a 30s monitor tick / dashboard poll does not log on every cycle.
const loggedSelectedPaths = new Set<string>();
const loggedErrorCodes = new Set<string>();
function overridePath(): string | undefined {
const raw = process.env.SENCHO_ZFS_ARCSTATS_PATH?.trim();
return raw ? raw : undefined;
}
function isExpectedFsError(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException)?.code;
return (
code === 'ENOENT' ||
code === 'EACCES' ||
code === 'EPERM' ||
code === 'EISDIR' ||
code === 'ENOTDIR' ||
code === 'ELOOP'
);
}
function logUnexpected(context: string, err: unknown): void {
const code = (err as NodeJS.ErrnoException)?.code ?? 'UNKNOWN';
if (loggedErrorCodes.has(code)) return;
loggedErrorCodes.add(code);
console.warn(`[HostMemory] Unexpected error reading ARC stats (${context}, ${code}); treating ARC as reclaimable=0`);
}
/** Parse the kstat table for the `size` and `c_min` rows (`<name> <type> <value>`). */
function parseArcstats(raw: string): { size?: number; cMin?: number } {
let size: number | undefined;
let cMin: number | undefined;
for (const line of raw.split('\n')) {
const parts = line.trim().split(/\s+/);
if (parts.length < 3) continue;
if (parts[0] === 'size') size = Number(parts[2]);
else if (parts[0] === 'c_min') cMin = Number(parts[2]);
}
return { size, cMin };
}
/**
* Reclaimable ARC in bytes, or 0 when ARC stats are unavailable/unusable.
* Never throws: any error resolves to 0 so ARC awareness can only lower a
* false-positive reading, never break host-memory reporting.
*/
async function readReclaimableArc(): Promise<number> {
const override = overridePath();
const candidates = override ? [override, ...ARCSTATS_FIXED_PATHS] : ARCSTATS_FIXED_PATHS;
for (const candidatePath of candidates) {
try {
// The override path is operator-supplied: verify it is a regular file
// of bounded size before reading (guards against a named pipe or an
// accidentally huge target). The fixed kstat paths are trusted.
if (candidatePath === override) {
const info = await fs.stat(candidatePath);
if (!info.isFile() || info.size > MAX_ARCSTATS_BYTES) continue;
}
const raw = await fs.readFile(candidatePath, 'utf8');
const { size, cMin } = parseArcstats(raw);
if (size === undefined || cMin === undefined) continue;
if (!Number.isFinite(size) || !Number.isFinite(cMin) || size < 0 || cMin < 0) continue;
// A valid record resolves the lookup, even when reclaimable is 0
// (size < c_min means ARC is at its floor).
if (!loggedSelectedPaths.has(candidatePath)) {
loggedSelectedPaths.add(candidatePath);
console.debug(`[HostMemory] Using ZFS ARC stats from ${candidatePath}`);
}
return Math.max(size - cMin, 0);
} catch (err) {
// Fail open: a missing or unreadable kstat is the normal non-ZFS case
// (expected fs errors); an unexpected error is logged once but still
// falls through so ARC awareness can only lower a false positive.
if (isExpectedFsError(err)) continue;
logUnexpected(candidatePath, err);
}
}
return 0;
}
/**
* Pure ARC adjustment. With `arcReclaimable === 0` this reproduces the prior
* `active / total` percentage exactly (since `active === total - available`).
*/
export function adjustForArc(mem: Pick<MemData, 'total' | 'available'>, arcReclaimable: number): HostMemory {
const effectiveAvailable = Math.min(mem.total, mem.available + Math.max(arcReclaimable, 0));
const effectiveUsed = Math.max(mem.total - effectiveAvailable, 0);
const usagePercent = mem.total > 0 ? (effectiveUsed / mem.total) * 100 : 0;
return { total: mem.total, used: effectiveUsed, free: effectiveAvailable, usagePercent };
}
/** Fetch host memory and reclaimable ARC concurrently, return the adjusted view. */
export async function getHostMemory(): Promise<HostMemory> {
const [mem, arcReclaimable] = await Promise.all([si.mem(), readReclaimableArc()]);
return adjustForArc(mem, arcReclaimable);
}
@@ -0,0 +1,18 @@
import type { Request } from 'express';
import type { LabelInventoryOptions } from '../services/LabelInventoryService';
import { requireAdmin } from '../middleware/tierGates';
/** Parse ?reveal=1; full values only when the caller is an admin. */
export function labelInventoryOptionsFromRequest(req: Request): LabelInventoryOptions {
const wantsReveal = req.query.reveal === '1' || req.query.reveal === 'true';
if (!wantsReveal) return { revealSecrets: false };
// requireAdmin is synchronous guard; routes call it before building inventory when reveal is requested.
return { revealSecrets: true };
}
/** Returns false and sends 403 when reveal was requested but caller is not admin. */
export function requireRevealAdmin(req: Request, res: import('express').Response): boolean {
const wantsReveal = req.query.reveal === '1' || req.query.reveal === 'true';
if (!wantsReveal) return true;
return requireAdmin(req, res);
}
@@ -0,0 +1,24 @@
import { isLikelySecretKey } from './secretClassification';
export const REDACTED_SENTINEL = '[redacted]';
/**
* Compound single-token segments specific to Docker/Compose labels that the generic
* env classifier does not split (e.g. Traefik `basicauth`/`digestauth` middleware keys,
* whose value carries inline `user:passwordhash` credentials).
*/
const SECRET_LABEL_SEGMENTS = new Set(['BASICAUTH', 'DIGESTAUTH']);
/** True when a Docker/Compose label key likely carries a sensitive value. */
export function isLikelySecretLabelKey(rawKey: string): boolean {
if (isLikelySecretKey(rawKey)) return true;
const segments = rawKey.trim().toUpperCase().split(/[^A-Z0-9]+/).filter(Boolean);
return segments.some(seg => SECRET_LABEL_SEGMENTS.has(seg));
}
export function redactLabelValue(key: string, value: string, revealSecrets: boolean): { value: string; redacted?: boolean } {
if (revealSecrets || !isLikelySecretLabelKey(key)) {
return { value };
}
return { value: REDACTED_SENTINEL, redacted: true };
}
@@ -0,0 +1,68 @@
import type { NotificationCategory } from '../services/NotificationService';
export type NotificationLevel = 'info' | 'warning' | 'error';
export type NotificationAppliesTo = 'bell' | 'external' | 'both';
export interface NotificationFilterRule {
node_id: number | null;
stack_patterns: string[];
label_ids: number[] | null;
categories: string[] | null;
levels?: NotificationLevel[] | null;
}
export interface NotificationMatchContext {
localNodeId: number;
stackName?: string;
category: NotificationCategory;
level: NotificationLevel;
stackLabelIds: number[];
}
/** True when all non-empty matchers on the rule match the alert context (AND). */
export function matchesNotificationFilters(
ctx: NotificationMatchContext,
rule: NotificationFilterRule,
): boolean {
if (rule.node_id != null && rule.node_id !== ctx.localNodeId) return false;
if (
rule.stack_patterns.length > 0
&& (ctx.stackName === undefined || !rule.stack_patterns.includes(ctx.stackName))
) {
return false;
}
if (
rule.label_ids != null
&& rule.label_ids.length > 0
&& !rule.label_ids.some((id) => ctx.stackLabelIds.includes(id))
) {
return false;
}
if (
rule.categories != null
&& rule.categories.length > 0
&& !rule.categories.includes(ctx.category)
) {
return false;
}
if (
rule.levels != null
&& rule.levels.length > 0
&& !rule.levels.includes(ctx.level)
) {
return false;
}
return true;
}
export function ruleNeedsStackLabels(rules: NotificationFilterRule[]): boolean {
return rules.some((r) => r.label_ids != null && r.label_ids.length > 0);
}
export function appliesToBell(appliesTo: NotificationAppliesTo): boolean {
return appliesTo === 'bell' || appliesTo === 'both';
}
export function appliesToExternal(appliesTo: NotificationAppliesTo): boolean {
return appliesTo === 'external' || appliesTo === 'both';
}
@@ -0,0 +1,100 @@
import { DatabaseService, type NotificationSuppressionRule, type Node } from '../services/DatabaseService';
import { NodeRegistry } from '../services/NodeRegistry';
import { LicenseService } from '../services/LicenseService';
import { PROXY_TIER_HEADER } from '../services/license-headers';
import { getErrorMessage } from '../utils/errors';
const SYNC_TIMEOUT_MS = 15_000;
function buildRemoteHeaders(apiToken: string): Record<string, string> {
const proxyHeaders = LicenseService.getInstance().getProxyHeaders();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
[PROXY_TIER_HEADER]: proxyHeaders.tier,
};
if (apiToken) headers.Authorization = `Bearer ${apiToken}`;
return headers;
}
function replicationTargets(rule: NotificationSuppressionRule): Node[] {
const db = DatabaseService.getInstance();
const remotes = db.getNodes().filter((n) => n.type === 'remote');
if (rule.node_id != null) {
const target = remotes.find((n) => n.id === rule.node_id);
return target ? [target] : [];
}
return remotes;
}
async function pushRuleToNode(node: Node, rule: NotificationSuppressionRule): Promise<void> {
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target?.apiUrl) {
console.warn(`[SuppressionSync] Skipping node "${node.name}": no proxy target`);
return;
}
const baseUrl = target.apiUrl.replace(/\/$/, '');
const res = await fetch(`${baseUrl}/api/notification-suppression-rules/replica`, {
method: 'POST',
headers: buildRemoteHeaders(target.apiToken),
body: JSON.stringify({ rule }),
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`);
}
}
async function deleteRuleOnNode(node: Node, ruleId: number): Promise<void> {
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target?.apiUrl) {
console.warn(`[SuppressionSync] Skipping node "${node.name}": no proxy target`);
return;
}
const baseUrl = target.apiUrl.replace(/\/$/, '');
const res = await fetch(`${baseUrl}/api/notification-suppression-rules/replica/${ruleId}`, {
method: 'DELETE',
headers: buildRemoteHeaders(target.apiToken),
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
});
if (!res.ok && res.status !== 404) {
const body = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`);
}
}
/** Best-effort push of a suppression rule to fleet nodes that should evaluate it. */
export function syncSuppressionRuleToFleet(rule: NotificationSuppressionRule): void {
const targets = replicationTargets(rule);
if (targets.length === 0) return;
void Promise.allSettled(
targets.map(async (node) => {
try {
await pushRuleToNode(node, rule);
} catch (err) {
console.error(
`[SuppressionSync] Failed to push rule ${rule.id} to node "${node.name}":`,
getErrorMessage(err, String(err)),
);
}
}),
);
}
/** Best-effort delete of a replicated rule on fleet nodes. */
export function deleteSuppressionRuleFromFleet(rule: NotificationSuppressionRule): void {
const targets = replicationTargets(rule);
if (targets.length === 0) return;
void Promise.allSettled(
targets.map(async (node) => {
try {
await deleteRuleOnNode(node, rule.id);
} catch (err) {
console.error(
`[SuppressionSync] Failed to delete rule ${rule.id} on node "${node.name}":`,
getErrorMessage(err, String(err)),
);
}
}),
);
}
+1
View File
@@ -52,6 +52,7 @@ export const HUB_ONLY_PREFIXES: readonly string[] = [
'/api/scheduled-tasks/',
'/api/audit-log/',
'/api/notification-routes/',
'/api/notification-suppression-rules/',
'/api/logs/global/',
'/api/system/log-stream-metrics/',
'/api/registries/',
@@ -16,6 +16,7 @@ const SECRET_SEGMENTS = new Set([
'SECRET', 'SECRETS',
'TOKEN', 'KEY', 'APIKEY',
'CREDENTIAL', 'CREDENTIALS', 'AUTH',
'BASIC',
]);
/** Connection strings whose value is sensitive but whose segments are innocuous. */
+2 -1
View File
@@ -36,7 +36,7 @@ import { agentsRouter } from './routes/agents';
import { metricsRouter } from './routes/metrics';
import { imageUpdatesRouter, autoUpdateRouter } from './routes/imageUpdates';
import { autoHealRouter } from './routes/autoHeal';
import { notificationsRouter, notificationRoutesRouter } from './routes/notifications';
import { notificationsRouter, notificationRoutesRouter, notificationSuppressionRouter } from './routes/notifications';
import { consoleRouter } from './routes/console';
import { ssoConfigRouter } from './routes/ssoConfig';
import { registriesRouter } from './routes/registries';
@@ -134,6 +134,7 @@ app.use('/api/auto-update', autoUpdateRouter);
app.use('/api/auto-heal', autoHealRouter);
app.use('/api/notifications', notificationsRouter);
app.use('/api/notification-routes', notificationRoutesRouter);
app.use('/api/notification-suppression-rules', notificationSuppressionRouter);
app.use('/api/system', consoleRouter);
app.use('/api/sso/config', ssoConfigRouter);
app.use('/api/registries', registriesRouter);
+6 -2
View File
@@ -1,6 +1,7 @@
import { Router, type Request, type Response } from 'express';
import DockerController from '../services/DockerController';
import { FileSystemService } from '../services/FileSystemService';
import { excludeSelfContainers } from '../helpers/excludeSelfContainers';
import { requireAdmin } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
@@ -11,8 +12,11 @@ containersRouter.get('/', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:read')) return;
try {
const dockerController = DockerController.getInstance(req.nodeId);
const containers = await dockerController.getRunningContainers();
res.json(containers);
const all = req.query.all === 'true' || req.query.all === '1';
const containers = all
? await dockerController.getAllContainers()
: await dockerController.getRunningContainers();
res.json(await excludeSelfContainers(containers));
} catch (error) {
res.status(500).json({ error: 'Failed to fetch containers' });
}
+174 -9
View File
@@ -10,6 +10,7 @@ import { FleetUpdateTrackerService, type UpdateTracker, type TerminalStatus, UPD
import { NodeRegistry } from '../services/NodeRegistry';
import { computeNodeNetworkingSummary, type NodeNetworkingSummary } from '../services/network/networkingSummary';
import DockerController from '../services/DockerController';
import { getHostMemory } from '../helpers/hostMemory';
import { FileSystemService } from '../services/FileSystemService';
import { ComposeService } from '../services/ComposeService';
import { StackOpLockService } from '../services/StackOpLockService';
@@ -46,6 +47,8 @@ import { runLocalLabelAssign, validateLabelTemplate, validateRemoteAssignResults
import { MAX_ASSIGNMENTS } from '../helpers/constants';
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
import { buildLocalGraph, mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult } from '../services/DependencyGraphService';
import { buildNodeLabelInventory, VALID_LABEL_SOURCES, type NodeLabelInventory } from '../services/LabelInventoryService';
import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest';
import { PROXY_TIER_HEADER } from '../services/license-headers';
import { LicenseService } from '../services/LicenseService';
@@ -222,11 +225,11 @@ async function getCompareTarget(gatewayVersion: string | null) {
async function fetchLocalNodeOverview(node: Node): Promise<FleetNodeOverview> {
try {
const composeDir = path.resolve(NodeRegistry.getInstance().getComposeDir(node.id));
const [allContainers, stacks, currentLoad, mem, fsSize] = await Promise.all([
const [allContainers, stacks, currentLoad, hostMem, fsSize] = await Promise.all([
DockerController.getInstance(node.id).getAllContainers(),
FileSystemService.getInstance(node.id).getStacks(),
si.currentLoad(),
si.mem(),
getHostMemory(),
si.fsSize(),
]);
@@ -255,13 +258,12 @@ async function fetchLocalNodeOverview(node: Node): Promise<FleetNodeOverview> {
systemStats: {
cpu: { usage: currentLoad.currentLoad.toFixed(1), cores: currentLoad.cpus.length },
memory: {
total: mem.total,
// Percentage is keyed off mem.active (the real working set), not mem.used:
// on Linux/BSD/macOS mem.used counts reclaimable page cache and reads ~99%
// on a busy host. mem.available is total - active, so used + free = total.
used: mem.active,
free: mem.available,
usagePercent: ((mem.active / mem.total) * 100).toFixed(1),
total: hostMem.total,
// ZFS ARC aware: reclaimable ARC is added back into available so a
// large ARC cache is not reported as hard-used. See helpers/hostMemory.ts.
used: hostMem.used,
free: hostMem.free,
usagePercent: hostMem.usagePercent.toFixed(1),
},
disk: mainDisk ? {
total: mainDisk.size,
@@ -727,6 +729,169 @@ fleetRouter.get('/dependency-map', authMiddleware, async (req: Request, res: Res
}
});
interface FleetNodeLabelInventoryResult {
nodeId: number;
nodeName: string;
status: 'ok' | 'error';
inventory: NodeLabelInventory | null;
error: string | null;
}
function isStringOrNull(v: unknown): boolean {
return typeof v === 'string' || v === null;
}
function isLabelIndexContainerRef(v: unknown): boolean {
if (!v || typeof v !== 'object') return false;
const o = v as Record<string, unknown>;
return typeof o.id === 'string'
&& typeof o.name === 'string'
&& isStringOrNull(o.stack)
&& isStringOrNull(o.service);
}
function isLabelValue(v: unknown): boolean {
if (!v || typeof v !== 'object') return false;
const o = v as Record<string, unknown>;
return typeof o.key === 'string'
&& typeof o.value === 'string'
&& typeof o.source === 'string'
&& VALID_LABEL_SOURCES.has(o.source);
}
function isContainerLabelRow(v: unknown): boolean {
if (!v || typeof v !== 'object') return false;
const o = v as Record<string, unknown>;
return typeof o.id === 'string'
&& typeof o.name === 'string'
&& typeof o.state === 'string'
&& isStringOrNull(o.stack)
&& isStringOrNull(o.service)
&& Array.isArray(o.labels)
&& o.labels.every(isLabelValue);
}
function isLabelIndexRow(v: unknown): boolean {
if (!v || typeof v !== 'object') return false;
const o = v as Record<string, unknown>;
return typeof o.key === 'string'
&& typeof o.value === 'string'
&& typeof o.source === 'string'
&& VALID_LABEL_SOURCES.has(o.source)
&& Array.isArray(o.containers)
&& o.containers.every(isLabelIndexContainerRef);
}
/**
* Validate a remote node's label-inventory payload deeply enough that neither the
* aggregation sort nor the Fleet UI ever receives a malformed row. A single bad
* `byLabel` or `containers` element would otherwise crash the whole fleet request (or
* the client) rather than degrading that node into `nodeErrors`. Only wire fields are
* checked; the internal `imageId` is not part of the shape sent over the wire.
*/
function isNodeLabelInventory(v: unknown): v is NodeLabelInventory {
if (!v || typeof v !== 'object') return false;
const o = v as Record<string, unknown>;
return typeof o.nodeId === 'number'
&& Array.isArray(o.containers)
&& o.containers.every(isContainerLabelRow)
&& Array.isArray(o.byLabel)
&& o.byLabel.every(isLabelIndexRow);
}
/**
* Fleet-wide Docker label inventory. Auth + node:read (Community). Fans out to
* each node's /api/system/container-labels; unreachable nodes degrade gracefully.
*/
fleetRouter.get('/container-labels', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'node:read')) return;
if (!requireRevealAdmin(req, res)) return;
const options = labelInventoryOptionsFromRequest(req);
try {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
const results = await Promise.allSettled(
nodes.map(async (node: Node): Promise<FleetNodeLabelInventoryResult> => {
if (node.type === 'local') {
const inventory = await buildNodeLabelInventory(node.id, options);
return { nodeId: node.id, nodeName: node.name, status: 'ok', inventory, error: null };
}
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target) {
return { nodeId: node.id, nodeName: node.name, status: 'error', inventory: null, error: formatNoTargetError(node) };
}
const revealQs = options.revealSecrets ? '?reveal=1' : '';
const resp = await fetch(
`${target.apiUrl.replace(/\/$/, '')}/api/system/container-labels${revealQs}`,
{
headers: { ...(target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}) },
signal: AbortSignal.timeout(30000),
},
);
if (!resp.ok) {
const errBody = await resp.json().catch(() => null) as { error?: string } | null;
return { nodeId: node.id, nodeName: node.name, status: 'error', inventory: null, error: errBody?.error ?? `Remote returned ${resp.status}` };
}
const inventory = await resp.json().catch(() => null);
if (!isNodeLabelInventory(inventory)) {
return { nodeId: node.id, nodeName: node.name, status: 'error', inventory: null, error: 'Remote returned an unexpected label-inventory payload' };
}
return { nodeId: node.id, nodeName: node.name, status: 'ok', inventory, error: null };
}),
);
const perNode: FleetNodeLabelInventoryResult[] = results.map((result, i) => {
if (result.status === 'fulfilled') return result.value;
console.error(`[Fleet] Container labels fetch failed for node ${nodes[i].name}:`, result.reason);
return { nodeId: nodes[i].id, nodeName: nodes[i].name, status: 'error', inventory: null, error: getErrorMessage(result.reason, 'Failed to reach node') };
});
const aggregatedByLabel = new Map<string, import('../services/LabelInventoryService').LabelIndexRow>();
for (const nodeResult of perNode) {
if (nodeResult.status !== 'ok' || !nodeResult.inventory) continue;
for (const row of nodeResult.inventory.byLabel) {
const key = `${row.key}\0${row.value}\0${row.source}`;
const existing = aggregatedByLabel.get(key);
if (!existing) {
aggregatedByLabel.set(key, {
...row,
containers: row.containers.map(c => ({
...c,
nodeId: nodeResult.nodeId,
nodeName: nodeResult.nodeName,
})),
});
} else {
existing.containers.push(...row.containers.map(c => ({
...c,
nodeId: nodeResult.nodeId,
nodeName: nodeResult.nodeName,
})));
}
}
}
const nodeErrors: Record<number, string> = {};
for (const n of perNode) {
if (n.status === 'error' && n.error) nodeErrors[n.nodeId] = n.error;
}
res.json({
nodes: perNode,
aggregatedByLabel: [...aggregatedByLabel.values()].sort((a, b) =>
a.key.localeCompare(b.key) || a.value.localeCompare(b.value) || a.source.localeCompare(b.source)),
nodeErrors,
generatedAt: Date.now(),
});
} catch (error) {
console.error('[Fleet] Container labels error:', error);
res.status(500).json({ error: 'Failed to build fleet container label inventory' });
}
});
interface FleetNetworkingSummaryNode {
nodeId: number;
nodeName: string;
+9 -9
View File
@@ -9,6 +9,7 @@ import { PilotTunnelManager } from '../services/PilotTunnelManager';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin } from '../middleware/tierGates';
import { STATS_CACHE_TTL_MS, SYSTEM_STATS_CACHE_TTL_MS } from '../helpers/constants';
import { getHostMemory } from '../helpers/hostMemory';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { isManagedByComposeDir } from '../utils/managed-containers';
@@ -296,9 +297,9 @@ metricsRouter.get('/system/stats', authMiddleware, async (req: Request, res: Res
async () => {
// Remote-node requests are intercepted and proxied upstream before
// reaching here; this fetcher only runs for local nodes.
const [currentLoad, mem, fsSize] = await Promise.all([
const [currentLoad, hostMem, fsSize] = await Promise.all([
si.currentLoad(),
si.mem(),
getHostMemory(),
si.fsSize(),
]);
@@ -310,13 +311,12 @@ metricsRouter.get('/system/stats', authMiddleware, async (req: Request, res: Res
cores: currentLoad.cpus.length,
},
memory: {
total: mem.total,
// Percentage is keyed off mem.active (the real working set), not mem.used:
// on Linux/BSD/macOS mem.used counts reclaimable page cache and reads ~99%
// on a busy host. mem.available is total - active, so used + free = total.
used: mem.active,
free: mem.available,
usagePercent: ((mem.active / mem.total) * 100).toFixed(1),
total: hostMem.total,
// ZFS ARC aware: reclaimable ARC is added back into available so a
// large ARC cache is not reported as hard-used. See helpers/hostMemory.ts.
used: hostMem.used,
free: hostMem.free,
usagePercent: hostMem.usagePercent.toFixed(1),
},
disk: mainDisk ? {
fs: mainDisk.fs,
+313 -5
View File
@@ -1,22 +1,29 @@
import { Router, type Request, type Response } from 'express';
import { DatabaseService } from '../services/DatabaseService';
import { NotificationService, ALL_NOTIFICATION_CATEGORIES } from '../services/NotificationService';
import { DatabaseService, type NotificationSuppressionAppliesTo, type NotificationSuppressionRule } from '../services/DatabaseService';
import { NotificationService, ALL_NOTIFICATION_CATEGORIES, ALL_SUPPRESSIBLE_CATEGORIES } from '../services/NotificationService';
import type { NotificationCategory } from '../services/NotificationService';
import { NodeRegistry } from '../services/NodeRegistry';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin } from '../middleware/tierGates';
import { requireAdmin, requireNodeProxy } from '../middleware/tierGates';
import {
NOTIFICATION_CHANNEL_TYPES,
validateHttpsUrl,
cleanStackPatterns,
maskWebhookUrl,
} from '../helpers/notificationChannels';
import {
deleteSuppressionRuleFromFleet,
syncSuppressionRuleToFleet,
} from '../helpers/notificationSuppressionSync';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import { parseIntParam } from '../utils/parseIntParam';
const VALID_CATEGORIES: ReadonlySet<NotificationCategory> = new Set(ALL_NOTIFICATION_CATEGORIES);
const VALID_SUPPRESSION_CATEGORIES: ReadonlySet<NotificationCategory> = new Set(ALL_SUPPRESSIBLE_CATEGORIES);
const VALID_LEVELS = new Set(['info', 'warning', 'error']);
const VALID_APPLIES_TO = new Set<NotificationSuppressionAppliesTo>(['bell', 'external', 'both']);
function validateNodeId(nodeId: unknown, res: Response): number | null | false {
if (nodeId === undefined || nodeId === null) return null;
@@ -41,15 +48,138 @@ function validateLabelIds(label_ids: unknown, res: Response): boolean {
return true;
}
function validateCategories(categories: unknown, res: Response): boolean {
function validateCategories(
categories: unknown,
res: Response,
allowed: ReadonlySet<NotificationCategory> = VALID_CATEGORIES,
): boolean {
if (categories === undefined || categories === null) return true;
if (!Array.isArray(categories) || categories.some((c: unknown) => typeof c !== 'string' || !VALID_CATEGORIES.has(c as NotificationCategory))) {
if (!Array.isArray(categories) || categories.some((c: unknown) => typeof c !== 'string' || !allowed.has(c as NotificationCategory))) {
res.status(400).json({ error: 'categories must be an array of valid category names' });
return false;
}
return true;
}
function validateSuppressionNodeId(nodeId: unknown, res: Response): number | null | false {
if (nodeId === undefined || nodeId === null) return null;
if (typeof nodeId !== 'number' || !Number.isInteger(nodeId)) {
res.status(400).json({ error: 'node_id must be an integer or null' });
return false;
}
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node) {
res.status(400).json({ error: 'node_id must reference a registered node or be null' });
return false;
}
return nodeId;
}
function validateLevels(levels: unknown, res: Response): boolean {
if (levels === undefined || levels === null) return true;
if (!Array.isArray(levels) || levels.some((l: unknown) => typeof l !== 'string' || !VALID_LEVELS.has(l))) {
res.status(400).json({ error: 'levels must be an array of info, warning, or error' });
return false;
}
return true;
}
function validateAppliesTo(applies_to: unknown, res: Response): NotificationSuppressionAppliesTo | false {
if (typeof applies_to !== 'string' || !VALID_APPLIES_TO.has(applies_to as NotificationSuppressionAppliesTo)) {
res.status(400).json({ error: 'applies_to must be bell, external, or both' });
return false;
}
return applies_to as NotificationSuppressionAppliesTo;
}
function validateExpiresAt(expires_at: unknown, res: Response): number | null | false | undefined {
if (expires_at === undefined) return undefined;
if (expires_at === null) return null;
if (typeof expires_at !== 'number' || !Number.isFinite(expires_at)) {
res.status(400).json({ error: 'expires_at must be a finite timestamp or null' });
return false;
}
return expires_at;
}
function parseSuppressionRuleBody(
req: Request,
res: Response,
isCreate: boolean,
): Omit<NotificationSuppressionRule, 'id' | 'created_at' | 'updated_at'> | null {
const {
name,
node_id: rawNodeId,
stack_patterns,
label_ids,
categories,
levels,
applies_to,
enabled,
expires_at,
} = req.body;
if (isCreate && (!name || typeof name !== 'string' || !name.trim())) {
res.status(400).json({ error: 'Name is required' });
return null;
}
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
res.status(400).json({ error: 'Name must be a non-empty string' });
return null;
}
if (name !== undefined && name.trim().length > 100) {
res.status(400).json({ error: 'Name must be 100 characters or fewer' });
return null;
}
const nodeIdResult = isCreate || 'node_id' in req.body
? validateSuppressionNodeId(rawNodeId, res)
: undefined;
if (nodeIdResult === false) return null;
let cleanedPatterns: string[] | undefined;
if (stack_patterns !== undefined) {
if (!Array.isArray(stack_patterns) || stack_patterns.some((p: unknown) => typeof p !== 'string')) {
res.status(400).json({ error: 'stack_patterns must be an array of strings' });
return null;
}
cleanedPatterns = cleanStackPatterns(stack_patterns);
} else if (isCreate) {
cleanedPatterns = [];
}
if (!validateLabelIds(label_ids, res)) return null;
if (!validateCategories(categories, res, VALID_SUPPRESSION_CATEGORIES)) return null;
if (!validateLevels(levels, res)) return null;
const appliesToResult = isCreate
? validateAppliesTo(applies_to, res)
: applies_to !== undefined
? validateAppliesTo(applies_to, res)
: undefined;
if (appliesToResult === false) return null;
const expiresAtResult = validateExpiresAt(expires_at, res);
if (expiresAtResult === false) return null;
if (enabled !== undefined && typeof enabled !== 'boolean') {
res.status(400).json({ error: 'enabled must be a boolean' });
return null;
}
return {
name: (name as string).trim(),
node_id: nodeIdResult ?? null,
stack_patterns: cleanedPatterns ?? [],
label_ids: Array.isArray(label_ids) && label_ids.length > 0 ? label_ids : null,
categories: Array.isArray(categories) && categories.length > 0 ? categories : null,
levels: Array.isArray(levels) && levels.length > 0 ? levels : null,
applies_to: (appliesToResult ?? 'both') as NotificationSuppressionAppliesTo,
enabled: enabled !== false,
expires_at: expiresAtResult ?? null,
};
}
export const notificationsRouter = Router();
notificationsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
@@ -293,3 +423,181 @@ notificationRoutesRouter.post('/:id/test', authMiddleware, async (req: Request,
}
});
export const notificationSuppressionRouter = Router();
notificationSuppressionRouter.post('/replica', authMiddleware, (req: Request, res: Response): void => {
if (!requireNodeProxy(req, res)) return;
try {
const rule = req.body?.rule as NotificationSuppressionRule | undefined;
if (!rule || typeof rule.id !== 'number' || typeof rule.name !== 'string') {
res.status(400).json({ error: 'rule object with id and name is required' });
return;
}
if (!VALID_APPLIES_TO.has(rule.applies_to)) {
res.status(400).json({ error: 'Invalid applies_to on rule' });
return;
}
DatabaseService.getInstance().upsertNotificationSuppressionRuleReplica(rule);
res.json({ success: true });
} catch (error) {
console.error('Failed to apply suppression rule replica:', error);
res.status(500).json({ error: 'Failed to apply suppression rule replica' });
}
});
notificationSuppressionRouter.delete('/replica/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireNodeProxy(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'suppression rule ID');
if (id === null) return;
DatabaseService.getInstance().deleteNotificationSuppressionRule(id);
res.json({ success: true });
} catch (error) {
console.error('Failed to delete suppression rule replica:', error);
res.status(500).json({ error: 'Failed to delete suppression rule replica' });
}
});
notificationSuppressionRouter.get('/', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
try {
const rules = DatabaseService.getInstance().getNotificationSuppressionRules();
res.json(rules);
} catch (error) {
console.error('Failed to fetch notification suppression rules:', error);
res.status(500).json({ error: 'Failed to fetch notification suppression rules' });
}
});
notificationSuppressionRouter.post('/', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
try {
const parsed = parseSuppressionRuleBody(req, res, true);
if (!parsed) return;
const now = Date.now();
const rule = DatabaseService.getInstance().createNotificationSuppressionRule({
...parsed,
created_at: now,
updated_at: now,
});
syncSuppressionRuleToFleet(rule);
console.log(`[Suppression] Rule "${sanitizeForLog(rule.name)}" created (id=${rule.id})`);
res.status(201).json(rule);
} catch (error) {
console.error('Failed to create notification suppression rule:', error);
res.status(500).json({ error: 'Failed to create notification suppression rule' });
}
});
notificationSuppressionRouter.put('/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'suppression rule ID');
if (id === null) return;
const existing = DatabaseService.getInstance().getNotificationSuppressionRule(id);
if (!existing) { res.status(404).json({ error: 'Suppression rule not found' }); return; }
const {
name,
node_id: rawNodeId,
stack_patterns,
label_ids,
categories,
levels,
applies_to,
enabled,
expires_at,
} = req.body;
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
res.status(400).json({ error: 'Name must be a non-empty string' });
return;
}
if (name !== undefined && name.trim().length > 100) {
res.status(400).json({ error: 'Name must be 100 characters or fewer' });
return;
}
let validatedNodeId: number | null | undefined;
if ('node_id' in req.body) {
const result = validateSuppressionNodeId(rawNodeId, res);
if (result === false) return;
validatedNodeId = result;
}
let cleanedPatterns: string[] | undefined;
if (stack_patterns !== undefined) {
if (!Array.isArray(stack_patterns) || stack_patterns.some((p: unknown) => typeof p !== 'string')) {
res.status(400).json({ error: 'stack_patterns must be an array of strings' });
return;
}
cleanedPatterns = cleanStackPatterns(stack_patterns);
}
if (!validateLabelIds(label_ids, res)) return;
if (!validateCategories(categories, res, VALID_SUPPRESSION_CATEGORIES)) return;
if (!validateLevels(levels, res)) return;
let validatedAppliesTo: NotificationSuppressionAppliesTo | undefined;
if (applies_to !== undefined) {
const result = validateAppliesTo(applies_to, res);
if (result === false) return;
validatedAppliesTo = result;
}
let validatedExpiresAt: number | null | undefined;
if ('expires_at' in req.body) {
const result = validateExpiresAt(expires_at, res);
if (result === false) return;
validatedExpiresAt = result;
}
if (enabled !== undefined && typeof enabled !== 'boolean') {
res.status(400).json({ error: 'enabled must be a boolean' });
return;
}
const updates: Partial<Omit<NotificationSuppressionRule, 'id' | 'created_at'>> = { updated_at: Date.now() };
if (name !== undefined) updates.name = name.trim();
if (validatedNodeId !== undefined) updates.node_id = validatedNodeId;
if (cleanedPatterns !== undefined) updates.stack_patterns = cleanedPatterns;
if ('label_ids' in req.body) updates.label_ids = Array.isArray(label_ids) && label_ids.length > 0 ? label_ids : null;
if ('categories' in req.body) updates.categories = Array.isArray(categories) && categories.length > 0 ? categories : null;
if ('levels' in req.body) updates.levels = Array.isArray(levels) && levels.length > 0 ? levels : null;
if (validatedAppliesTo !== undefined) updates.applies_to = validatedAppliesTo;
if (enabled !== undefined) updates.enabled = enabled;
if (validatedExpiresAt !== undefined) updates.expires_at = validatedExpiresAt;
const db = DatabaseService.getInstance();
db.updateNotificationSuppressionRule(id, updates);
const updated = db.getNotificationSuppressionRule(id)!;
syncSuppressionRuleToFleet(updated);
console.log(`[Suppression] Rule ${id} updated`);
res.json(updated);
} catch (error) {
console.error('Failed to update notification suppression rule:', error);
res.status(500).json({ error: 'Failed to update notification suppression rule' });
}
});
notificationSuppressionRouter.delete('/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'suppression rule ID');
if (id === null) return;
const existing = DatabaseService.getInstance().getNotificationSuppressionRule(id);
if (!existing) { res.status(404).json({ error: 'Suppression rule not found' }); return; }
DatabaseService.getInstance().deleteNotificationSuppressionRule(id);
deleteSuppressionRuleFromFleet(existing);
console.log(`[Suppression] Rule ${id} deleted`);
res.json({ success: true });
} catch (error) {
console.error('Failed to delete notification suppression rule:', error);
res.status(500).json({ error: 'Failed to delete notification suppression rule' });
}
});
+32 -6
View File
@@ -17,7 +17,7 @@ import { escapeCsvField } from '../utils/csv';
import { getErrorMessage } from '../utils/errors';
import { parseIntParam } from '../utils/parseIntParam';
import { sanitizeForLog } from '../utils/safeLog';
import { isValidStackName } from '../utils/validation';
import { isValidStackName, isValidContainerName } from '../utils/validation';
// Frontend listeners filter on scope === 'scheduled-tasks'. Wrapped so a
// broken subscriber socket cannot turn a successful mutation into a 500.
@@ -78,12 +78,30 @@ function validateStackTarget(targetType: TargetType, targetId: unknown, nodeId:
return null;
}
function validateContainerTarget(targetType: TargetType, targetId: unknown, nodeId: unknown): string | null {
if (targetType !== 'container') return null;
if (typeof targetId !== 'string' || !targetId.trim() || nodeId === null || nodeId === undefined) {
return 'Container operations require target_id and node_id.';
}
if (targetId !== targetId.trim() || !isValidContainerName(targetId)) {
return 'Container target_id must be a valid container name.';
}
if (parsePositiveNodeId(nodeId) === null) {
return 'Container operations require a valid node_id.';
}
return null;
}
/**
* Shared guard for non-stack actions that require a node. Stack actions use
* validateStackTarget because they also require target_id.
*/
function validateActionNode(action: BackendScheduledAction, targetType: TargetType, nodeId: unknown): string | null {
if (targetType === 'stack') return null;
if (targetType === 'stack' || targetType === 'container') return null;
const def = getScheduledActionDefinition(action);
if (!def?.requiresNode) return null;
@@ -226,7 +244,7 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
res.status(400).json({ error: 'Name is required' }); return;
}
if (!(VALID_TARGET_TYPES as readonly string[]).includes(target_type)) {
res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, or system.' }); return;
res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, system, or container.' }); return;
}
if (!(VALID_ACTIONS as readonly string[]).includes(action)) {
res.status(400).json({ error: INVALID_ACTION_MESSAGE }); return;
@@ -239,6 +257,8 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
const stackTargetErr = validateStackTarget(target_type, target_id, node_id);
if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; }
const containerTargetErr = validateContainerTarget(target_type, target_id, node_id);
if (containerTargetErr) { res.status(400).json({ error: containerTargetErr }); return; }
const optionalErr = validateOptionalFields(action, target_type, prune_targets, target_services, prune_label_filter);
if (optionalErr) { res.status(400).json({ error: optionalErr }); return; }
@@ -260,7 +280,8 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
const nextRun = (enabled === false)
? null
: (pinnedRunAt ?? scheduler.calculateNextRun(cron_expression));
const normalizedTargetId = target_type === 'stack' ? target_id : null;
const normalizedTargetId =
target_type === 'stack' || target_type === 'container' ? target_id : null;
const normalizedNodeId = actionRequiresNode(action) ? parsePositiveNodeId(node_id) : null;
const id = DatabaseService.getInstance().createScheduledTask({
@@ -330,7 +351,7 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
const finalAction = (action ?? existing.action) as BackendScheduledAction;
const finalTargetType = (target_type ?? existing.target_type) as TargetType;
const finalTargetId = finalTargetType === 'stack'
const finalTargetId = finalTargetType === 'stack' || finalTargetType === 'container'
? (target_id !== undefined ? target_id : existing.target_id)
: null;
const finalNodeId = actionRequiresNode(finalAction)
@@ -345,6 +366,9 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
const stackTargetErr = validateStackTarget(finalTargetType, finalTargetId, finalNodeId);
if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; }
const containerTargetErr = validateContainerTarget(finalTargetType, finalTargetId, finalNodeId);
if (containerTargetErr) { res.status(400).json({ error: containerTargetErr }); return; }
const optionalErr = validateOptionalFields(finalAction, finalTargetType, prune_targets, target_services, prune_label_filter);
if (optionalErr) { res.status(400).json({ error: optionalErr }); return; }
@@ -364,7 +388,9 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
updates.name = name.trim();
}
if (target_type !== undefined) updates.target_type = finalTargetType;
if (target_id !== undefined || finalTargetType !== 'stack') updates.target_id = finalTargetId || null;
if (target_id !== undefined || (finalTargetType !== 'stack' && finalTargetType !== 'container')) {
updates.target_id = finalTargetId || null;
}
if (node_id !== undefined || !actionRequiresNode(finalAction)) {
updates.node_id = finalNodeId != null ? parsePositiveNodeId(finalNodeId) : null;
}
+20 -1
View File
@@ -27,6 +27,8 @@ import { buildStackNetworkFacts } from '../services/network/composeNetworkInspec
import { buildStorageInventory } from '../services/storage/inventory';
import { buildEffectiveAnatomy } from '../services/effectiveAnatomy';
import { buildEnvInventory } from '../services/EnvInventoryService';
import { buildStackLabelInventory } from '../services/LabelInventoryService';
import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest';
import { EXPOSURE_INTENTS, type ExposureIntent } from '../services/network/types';
import { UpdateGuardService } from '../services/UpdateGuardService';
import { HealthGateService } from '../services/HealthGateService';
@@ -44,6 +46,7 @@ import { sanitizeForLog } from '../utils/safeLog';
import { sendGitSourceError } from '../utils/gitSourceHttp';
import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan, describePolicyBlock } from '../helpers/policyGate';
import { parseComposePreview, type ComposePreview } from '../helpers/composePreview';
import { filterContainersByComposeService } from '../helpers/composeServiceMatch';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
import { parseComposeSelection, defaultEnvPath } from '../helpers/gitSourceSelection';
import { resolveStackEnvSources, discoverStackLocalEnvFiles } from '../helpers/envFileResolution';
@@ -1321,6 +1324,22 @@ stacksRouter.get('/:stackName/env-inventory', async (req: Request, res: Response
}
});
// Docker/Compose label inventory: declared compose labels vs runtime container
// labels per service. Read-only; auto-proxies to the active node.
stacksRouter.get('/:stackName/label-inventory', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
if (!requireRevealAdmin(req, res)) return;
try {
res.json(await buildStackLabelInventory(req.nodeId, stackName, labelInventoryOptionsFromRequest(req)));
} catch (error) {
console.error('[Stacks] Failed to build label inventory for %s:', sanitizeForLog(stackName),
sanitizeForLog(inspect(error, { depth: 4 })));
res.status(500).json({ error: 'Failed to build label inventory' });
}
});
// Exposure intent: the user's per-stack (service '') and per-service exposure
// classification, stored separately from generated facts so mismatches stay
// detectable. Rows are stored independently; precedence (a service row taking
@@ -1646,7 +1665,7 @@ async function handleServiceAction(
res.status(404).json({ error: 'No containers found for this stack.' });
return;
}
const matching = all.filter(c => c.Service === serviceName);
const matching = filterContainersByComposeService(all, serviceName);
if (matching.length === 0) {
res.status(404).json({ error: `Service '${serviceName}' not found in stack '${stackName}'.` });
return;
+16
View File
@@ -9,6 +9,9 @@ import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import { withTimeout, TimeoutError } from '../utils/withTimeout';
import { buildNodeLabelInventory } from '../services/LabelInventoryService';
import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest';
import { requirePermission } from '../middleware/permissions';
// `docker system df` (the call backing estimateSystemReclaim) can take 30+
// seconds on Docker Desktop with many volumes; 8s matches the MonitorService
@@ -218,6 +221,19 @@ systemMaintenanceRouter.get('/docker-df', async (req: Request, res: Response) =>
}
});
// Node-wide Docker/Compose label inventory for fleet fan-out and local audit.
systemMaintenanceRouter.get('/container-labels', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'node:read')) return;
if (!requireRevealAdmin(req, res)) return;
try {
const inventory = await buildNodeLabelInventory(req.nodeId, labelInventoryOptionsFromRequest(req));
res.json(inventory);
} catch (error) {
console.error('Failed to build container label inventory:', error);
res.status(500).json({ error: 'Failed to build container label inventory' });
}
});
systemMaintenanceRouter.get('/resources', async (req: Request, res: Response) => {
try {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
@@ -25,6 +25,7 @@ export const CAPABILITIES = [
'network-topology',
'notifications',
'notification-routing',
'notification-suppression',
'host-console',
'container-exec',
'audit-log',
@@ -39,6 +40,7 @@ export const CAPABILITIES = [
'update-guard',
'compose-networking',
'env-inventory',
'container-label-inventory',
'project-env-files',
'compose-storage',
'cross-node-rbac',
+179 -1
View File
@@ -377,6 +377,7 @@ export interface NotificationHistory {
stack_name?: string;
container_name?: string;
actor_username?: string | null;
suppression_match?: string | null;
}
export interface FleetSnapshot {
@@ -536,7 +537,7 @@ export interface ApiToken {
export interface ScheduledTask {
id: number;
name: string;
target_type: 'stack' | 'fleet' | 'system';
target_type: 'stack' | 'fleet' | 'system' | 'container';
target_id: string | null;
node_id: number | null;
action: BackendScheduledAction;
@@ -600,6 +601,23 @@ export interface NotificationRoute {
updated_at: number;
}
export type NotificationSuppressionAppliesTo = 'bell' | 'external' | 'both';
export interface NotificationSuppressionRule {
id: number;
name: string;
node_id: number | null;
stack_patterns: string[];
label_ids: number[] | null;
categories: string[] | null;
levels: ('info' | 'warning' | 'error')[] | null;
applies_to: NotificationSuppressionAppliesTo;
enabled: boolean;
expires_at: number | null;
created_at: number;
updated_at: number;
}
export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';
export type VulnScanStatus = 'in_progress' | 'completed' | 'failed';
export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy' | 'deploy-preflight';
@@ -871,6 +889,7 @@ export class DatabaseService {
this.migrateNotificationRoutes();
this.migrateNotificationRoutesNodeId();
this.migrateNotificationRoutesMatchers();
this.migrateNotificationSuppressionRules();
this.migrateNotificationHistoryContext();
this.migrateScanPolicyFleetColumns();
this.migrateScanPolicyRiskColumns();
@@ -1814,9 +1833,31 @@ export class DatabaseService {
this.tryAddColumn('notification_routes', 'categories', 'TEXT NULL');
}
private migrateNotificationSuppressionRules(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS notification_suppression_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
node_id INTEGER NULL,
stack_patterns TEXT NOT NULL,
label_ids TEXT NULL,
categories TEXT NULL,
levels TEXT NULL,
applies_to TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
expires_at INTEGER NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notification_suppression_enabled
ON notification_suppression_rules(enabled, expires_at);
`);
}
private migrateNotificationHistoryContext(): void {
this.tryAddColumn('notification_history', 'stack_name', 'TEXT');
this.tryAddColumn('notification_history', 'container_name', 'TEXT');
this.tryAddColumn('notification_history', 'suppression_match', 'TEXT');
}
private migrateStackDossierHashes(): void {
@@ -2293,6 +2334,135 @@ export class DatabaseService {
return this.db.prepare('DELETE FROM notification_routes WHERE id = ?').run(id).changes;
}
// --- Notification Suppression Rules ---
private parseNotificationSuppressionRule(row: Record<string, unknown>): NotificationSuppressionRule {
return {
id: row.id as number,
name: row.name as string,
node_id: row.node_id != null ? (row.node_id as number) : null,
stack_patterns: JSON.parse(row.stack_patterns as string) as string[],
label_ids: row.label_ids ? JSON.parse(row.label_ids as string) as number[] : null,
categories: row.categories ? JSON.parse(row.categories as string) as string[] : null,
levels: row.levels ? JSON.parse(row.levels as string) as ('info' | 'warning' | 'error')[] : null,
applies_to: row.applies_to as NotificationSuppressionAppliesTo,
enabled: row.enabled === 1,
expires_at: row.expires_at != null ? (row.expires_at as number) : null,
created_at: row.created_at as number,
updated_at: row.updated_at as number,
};
}
public getNotificationSuppressionRules(): NotificationSuppressionRule[] {
return this.db.prepare('SELECT * FROM notification_suppression_rules ORDER BY created_at ASC')
.all()
.map((row) => this.parseNotificationSuppressionRule(row as Record<string, unknown>));
}
public getEnabledNotificationSuppressionRules(now = Date.now()): NotificationSuppressionRule[] {
return this.db.prepare(
'SELECT * FROM notification_suppression_rules WHERE enabled = 1 AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at ASC',
)
.all(now)
.map((row) => this.parseNotificationSuppressionRule(row as Record<string, unknown>));
}
public getNotificationSuppressionRule(id: number): NotificationSuppressionRule | undefined {
const row = this.db.prepare('SELECT * FROM notification_suppression_rules WHERE id = ?').get(id) as Record<string, unknown> | undefined;
return row ? this.parseNotificationSuppressionRule(row) : undefined;
}
public createNotificationSuppressionRule(
rule: Omit<NotificationSuppressionRule, 'id'>,
): NotificationSuppressionRule {
const result = this.db.prepare(
'INSERT INTO notification_suppression_rules (name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
).run(
rule.name,
rule.node_id ?? null,
JSON.stringify(rule.stack_patterns),
rule.label_ids ? JSON.stringify(rule.label_ids) : null,
rule.categories ? JSON.stringify(rule.categories) : null,
rule.levels ? JSON.stringify(rule.levels) : null,
rule.applies_to,
rule.enabled ? 1 : 0,
rule.expires_at ?? null,
rule.created_at,
rule.updated_at,
);
return this.getNotificationSuppressionRule(result.lastInsertRowid as number)!;
}
public upsertNotificationSuppressionRuleReplica(rule: NotificationSuppressionRule): void {
const existing = this.getNotificationSuppressionRule(rule.id);
if (existing) {
this.db.prepare(
`UPDATE notification_suppression_rules SET
name = ?, node_id = ?, stack_patterns = ?, label_ids = ?, categories = ?, levels = ?,
applies_to = ?, enabled = ?, expires_at = ?, updated_at = ?
WHERE id = ?`,
).run(
rule.name,
rule.node_id ?? null,
JSON.stringify(rule.stack_patterns),
rule.label_ids ? JSON.stringify(rule.label_ids) : null,
rule.categories ? JSON.stringify(rule.categories) : null,
rule.levels ? JSON.stringify(rule.levels) : null,
rule.applies_to,
rule.enabled ? 1 : 0,
rule.expires_at ?? null,
rule.updated_at,
rule.id,
);
return;
}
this.db.prepare(
`INSERT INTO notification_suppression_rules
(id, name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
rule.id,
rule.name,
rule.node_id ?? null,
JSON.stringify(rule.stack_patterns),
rule.label_ids ? JSON.stringify(rule.label_ids) : null,
rule.categories ? JSON.stringify(rule.categories) : null,
rule.levels ? JSON.stringify(rule.levels) : null,
rule.applies_to,
rule.enabled ? 1 : 0,
rule.expires_at ?? null,
rule.created_at,
rule.updated_at,
);
}
public updateNotificationSuppressionRule(
id: number,
updates: Partial<Omit<NotificationSuppressionRule, 'id' | 'created_at'>>,
): void {
const fields: string[] = [];
const values: unknown[] = [];
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if ('node_id' in updates) { fields.push('node_id = ?'); values.push(updates.node_id ?? null); }
if (updates.stack_patterns !== undefined) { fields.push('stack_patterns = ?'); values.push(JSON.stringify(updates.stack_patterns)); }
if ('label_ids' in updates) { fields.push('label_ids = ?'); values.push(updates.label_ids ? JSON.stringify(updates.label_ids) : null); }
if ('categories' in updates) { fields.push('categories = ?'); values.push(updates.categories ? JSON.stringify(updates.categories) : null); }
if ('levels' in updates) { fields.push('levels = ?'); values.push(updates.levels ? JSON.stringify(updates.levels) : null); }
if (updates.applies_to !== undefined) { fields.push('applies_to = ?'); values.push(updates.applies_to); }
if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
if ('expires_at' in updates) { fields.push('expires_at = ?'); values.push(updates.expires_at ?? null); }
if (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); }
if (fields.length === 0) return;
values.push(id);
this.db.prepare(`UPDATE notification_suppression_rules SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public deleteNotificationSuppressionRule(id: number): number {
return this.db.prepare('DELETE FROM notification_suppression_rules WHERE id = ?').run(id).changes;
}
// --- Global Settings ---
public getGlobalSettings(): Readonly<Record<string, string>> {
@@ -2806,6 +2976,7 @@ export class DatabaseService {
container_name: row.container_name ?? undefined,
category: row.category ?? undefined,
actor_username: row.actor_username ?? null,
suppression_match: row.suppression_match ?? null,
};
}
@@ -2914,6 +3085,13 @@ export class DatabaseService {
this.db.prepare('UPDATE notification_history SET dispatch_error = ? WHERE id = ?').run(error, id);
}
public updateNotificationSuppressionMatch(
id: number,
snapshot: { rules: { id: number; name: string }[]; bellSuppressed: boolean; externalSuppressed: boolean },
): void {
this.db.prepare('UPDATE notification_history SET suppression_match = ? WHERE id = ?').run(JSON.stringify(snapshot), id);
}
public getStackRestartSummary(nodeId: number, days: number): StackRestartSummary[] {
const since = Date.now() - days * 86400 * 1000;
return this.db.prepare(`
+135 -1
View File
@@ -8,6 +8,7 @@ import * as yaml from 'yaml';
import { NodeRegistry } from './NodeRegistry';
import { CacheService } from './CacheService';
import { FileSystemService } from './FileSystemService';
import SelfIdentityService from './SelfIdentityService';
import { isPathWithinBase } from '../utils/validation';
import { isDebugEnabled } from '../utils/debug';
@@ -222,6 +223,17 @@ export interface CreateNetworkOptions {
Attachable?: boolean;
}
export interface LabelInventoryRow {
id: string;
name: string;
state: string;
stack: string | null;
service: string | null;
labels: Record<string, string>;
imageId: string;
inspectFailed: boolean;
}
class DockerController {
private static readonly SYSTEM_NETWORKS = new Set(['bridge', 'host', 'none']);
/**
@@ -863,6 +875,118 @@ class DockerController {
return this.validateApiData<any[]>(containers);
}
/** Runtime labels + image ref from container inspect. Null (logged) when inspect fails. */
public async inspectContainerLabelsAndImage(
containerId: string,
): Promise<{ labels: Record<string, string>; imageId: string } | null> {
try {
const info = await this.docker.getContainer(containerId).inspect();
return { labels: info.Config?.Labels ?? {}, imageId: info.Image ?? '' };
} catch (err) {
console.error('[DockerController] Container inspect failed for %s:', sanitizeForLog(containerId), err);
return null;
}
}
/** Image-level labels for label provenance. Null (logged) when the image cannot be inspected. */
public async inspectImageLabels(imageId: string): Promise<{ labels: Record<string, string> } | null> {
if (!imageId) return null;
try {
const info = await this.docker.getImage(imageId).inspect();
return { labels: info.Config?.Labels ?? {} };
} catch (err) {
console.error('[DockerController] Image inspect failed for %s:', sanitizeForLog(imageId), err);
return null;
}
}
/**
* Containers with runtime labels for the label-inventory API. Resolves stack
* membership with the same multi-fallback strategy as bulk status. Captures the
* image ref so label provenance can distinguish image-inherited labels.
*/
public async listContainersForLabelInventory(): Promise<LabelInventoryRow[]> {
const knownStacks = await FileSystemService.getInstance(this.nodeId).getStacks();
const listed = await this.getAllContainers() as Array<{
Id?: string;
Names?: string[];
State?: string;
Labels?: Record<string, string>;
ImageID?: string;
}>;
const projectToStack = await DockerController.resolveProjectNameMap(knownStacks);
const absDirToStack = DockerController.buildAbsDirMap(knownStacks);
const resolvedBase = path.resolve(COMPOSE_DIR);
const knownStackSet = new Set(knownStacks);
const CONCURRENCY = 8;
const results: LabelInventoryRow[] = new Array(listed.length);
let index = 0;
const worker = async () => {
while (index < listed.length) {
const i = index++;
const c = listed[i];
const id = c.Id ?? '';
const name = (c.Names?.[0] ?? '').replace(/^\//, '');
const state = c.State ?? 'unknown';
const stack = DockerController.resolveContainerStack(
c.Labels,
projectToStack,
knownStackSet,
absDirToStack,
resolvedBase,
);
const service = c.Labels?.['com.docker.compose.service'] ?? null;
if (!id) {
results[i] = { id, name, state, stack, service, labels: {}, imageId: c.ImageID ?? '', inspectFailed: true };
continue;
}
let labels: Record<string, string>;
let imageId: string;
let inspectFailed = false;
try {
const info = await this.docker.getContainer(id).inspect();
labels = info.Config?.Labels ?? {};
imageId = info.Image ?? '';
} catch (err) {
console.error('[DockerController] Container inspect failed for %s:', sanitizeForLog(id), err);
labels = c.Labels ?? {};
imageId = c.ImageID ?? '';
inspectFailed = true;
}
results[i] = { id, name, state, stack, service, labels, imageId, inspectFailed };
}
};
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, listed.length) }, worker));
return results;
}
/** Resolve a container by its durable name (not ephemeral ID). */
public async findContainerByName(name: string): Promise<{
id: string;
name: string;
state: string;
image: string;
stackProject: string | null;
} | null> {
const normalized = name.replace(/^\//, '');
const containers = await this.getAllContainers();
for (const c of containers) {
const containerName = c.Names?.[0]?.replace(/^\//, '');
if (containerName === normalized) {
return {
id: c.Id,
name: containerName,
state: c.State ?? 'unknown',
image: c.Image ?? '',
stackProject: c.Labels?.['com.docker.compose.project'] ?? null,
};
}
}
return null;
}
/**
* Builds topology data with 2 Docker API calls instead of N+1.
* Fetches all networks + all containers in parallel, then maps
@@ -1488,10 +1612,13 @@ class DockerController {
// 2. Extract expected container names with legacy prefix support
const expectedNames: string[] = [];
const nameToService = new Map<string, string>();
for (const [serviceName, serviceConfig] of Object.entries(parsedYaml.services)) {
const config = serviceConfig as any;
const config = serviceConfig as { container_name?: string };
nameToService.set(serviceName, serviceName);
if (config.container_name) {
expectedNames.push(config.container_name);
nameToService.set(config.container_name, serviceName);
} else {
// Standard v2 naming
expectedNames.push(serviceName);
@@ -1516,6 +1643,11 @@ class DockerController {
// 5. Map to the frontend interface
return fallbackContainers.map(c => {
const strippedName = c.Names?.[0]?.replace(/^\//, '') ?? '';
const labelService = c.Labels?.['com.docker.compose.service'];
const service = (typeof labelService === 'string' && labelService.length > 0
? labelService
: nameToService.get(strippedName)) ?? '';
let Ports: { PrivatePort: number, PublicPort: number, Type?: string }[] = [];
if (c.Ports && Array.isArray(c.Ports)) {
Ports = c.Ports
@@ -1525,8 +1657,10 @@ class DockerController {
return {
Id: c.Id,
Names: c.Names,
Service: service,
State: c.State,
Status: c.Status,
Labels: c.Labels,
Ports
};
});
@@ -0,0 +1,415 @@
/**
* Per-node and per-stack Docker/Compose label inventory with provenance and
* optional value redaction for secret-like keys.
*/
import { ComposeService } from './ComposeService';
import DockerController from './DockerController';
import { redactLabelValue } from '../helpers/labelValueRedaction';
import { sanitizeForLog } from '../utils/safeLog';
export type LabelSource = 'compose' | 'runtime' | 'image' | 'compose-system' | 'unknown';
/**
* Every valid label source, typed as a set of strings so the wire validator can test an
* untrusted `string` without a cast. The literals are `LabelSource` members, so the union
* still documents the valid values.
*/
export const VALID_LABEL_SOURCES: ReadonlySet<string> = new Set<LabelSource>([
'compose', 'runtime', 'image', 'compose-system', 'unknown',
]);
export interface LabelValue {
key: string;
value: string;
source: LabelSource;
redacted?: boolean;
}
export interface ContainerLabelRow {
id: string;
name: string;
stack: string | null;
service: string | null;
state: string;
labels: LabelValue[];
}
export interface LabelIndexContainerRef {
id: string;
name: string;
stack: string | null;
service: string | null;
nodeId?: number;
nodeName?: string;
}
export interface LabelIndexRow {
key: string;
value: string;
redacted?: boolean;
source: LabelSource;
containers: LabelIndexContainerRef[];
}
export interface NodeLabelInventory {
nodeId: number;
containers: ContainerLabelRow[];
byLabel: LabelIndexRow[];
partial: boolean;
generatedAt: number;
}
export interface StackLabelReplica {
id: string;
name: string;
state: string;
runtimeLabels: LabelValue[];
onlyInCompose: string[];
onlyOnContainer: string[];
inBoth: string[];
/** Keys declared in Compose and present at runtime but with a different value. */
changed: string[];
/** Runtime labels could not be read for this replica; reconciliation is skipped. */
inspectFailed?: boolean;
}
export interface StackServiceLabelRow {
service: string;
declaredLabels: LabelValue[];
replicas: StackLabelReplica[];
}
export interface StackLabelInventory {
stackName: string;
renderable: boolean;
services: StackServiceLabelRow[];
/** A replica or its image could not be fully inspected; some provenance is unknown. */
partial: boolean;
generatedAt: number;
}
export interface LabelInventoryOptions {
revealSecrets?: boolean;
}
const INSPECT_CONCURRENCY = 8;
const COMPOSE_SYSTEM_PREFIX = 'com.docker.compose.';
function str(v: unknown): string | undefined {
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
return undefined;
}
/**
* Truthful provenance for a runtime label. Precedence: compose-system prefix, then
* (stack path only) a Compose-declared key with the same value, then an exact image
* label match, then plain runtime. When the image could not be inspected
* (`imageLabels === null`) an otherwise-unattributable label is `unknown`, not `runtime`.
*/
function resolveLabelSource(
key: string,
value: string,
imageLabels: Record<string, string> | null,
declared?: Record<string, string>,
): LabelSource {
if (key.startsWith(COMPOSE_SYSTEM_PREFIX)) return 'compose-system';
if (declared && declared[key] === value) return 'compose';
if (imageLabels) return imageLabels[key] === value ? 'image' : 'runtime';
return 'unknown';
}
/**
* Inspect each unique, non-empty image id once (deduped, bounded concurrency). Returns a
* map from image id to its label map, or `null` for an image that could not be inspected,
* and whether any inspection failed (so callers can mark the inventory partial).
*/
async function buildImageLabelMap(
docker: DockerController,
imageIds: string[],
): Promise<{ map: Map<string, Record<string, string> | null>; partial: boolean }> {
const unique = [...new Set(imageIds.filter(id => id.length > 0))];
const inspected = await mapWithConcurrency(unique, INSPECT_CONCURRENCY, async (imageId) => {
const result = await docker.inspectImageLabels(imageId);
return { imageId, labels: result ? result.labels : null };
});
const map = new Map<string, Record<string, string> | null>();
let partial = false;
for (const { imageId, labels } of inspected) {
map.set(imageId, labels);
if (labels === null) partial = true;
}
return { map, partial };
}
function parseLabelsMap(labels: unknown): Record<string, string> {
if (Array.isArray(labels)) {
const out: Record<string, string> = {};
for (const entry of labels) {
const raw = str(entry);
if (!raw) continue;
const eq = raw.indexOf('=');
if (eq === -1) {
out[raw] = '';
} else {
out[raw.slice(0, eq)] = raw.slice(eq + 1);
}
}
return out;
}
if (labels && typeof labels === 'object') {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(labels as Record<string, unknown>)) {
const val = str(v);
if (val !== undefined) out[k] = val;
}
return out;
}
return {};
}
function toLabelValue(
key: string,
value: string,
source: LabelSource,
revealSecrets: boolean,
): LabelValue {
const redacted = redactLabelValue(key, value, revealSecrets);
return { key, value: redacted.value, source, ...(redacted.redacted ? { redacted: true } : {}) };
}
function stripContainerName(names: string[] | undefined): string {
const first = names?.[0];
if (!first) return '';
return first.replace(/^\//, '');
}
async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let index = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (index < items.length) {
const i = index++;
results[i] = await fn(items[i]);
}
});
await Promise.all(workers);
return results;
}
function buildByLabelIndex(
containers: ContainerLabelRow[],
nodeId?: number,
nodeName?: string,
): LabelIndexRow[] {
const map = new Map<string, LabelIndexRow>();
for (const container of containers) {
for (const label of container.labels) {
const mapKey = `${label.key}\0${label.value}\0${label.source}`;
let row = map.get(mapKey);
if (!row) {
row = {
key: label.key,
value: label.value,
source: label.source,
...(label.redacted ? { redacted: true } : {}),
containers: [],
};
map.set(mapKey, row);
}
row.containers.push({
id: container.id,
name: container.name,
stack: container.stack,
service: container.service,
...(nodeId !== undefined ? { nodeId } : {}),
...(nodeName !== undefined ? { nodeName } : {}),
});
}
}
return [...map.values()].sort((a, b) =>
a.key.localeCompare(b.key) || a.value.localeCompare(b.value) || a.source.localeCompare(b.source));
}
function reconcileKeys(
declared: Record<string, string>,
runtime: Record<string, string>,
): { onlyInCompose: string[]; onlyOnContainer: string[]; inBoth: string[]; changed: string[] } {
const runtimeKeys = new Set(Object.keys(runtime));
const onlyInCompose: string[] = [];
const onlyOnContainer: string[] = [];
const inBoth: string[] = [];
const changed: string[] = [];
for (const k of Object.keys(declared)) {
if (!runtimeKeys.has(k)) onlyInCompose.push(k);
else if (declared[k] === runtime[k]) inBoth.push(k);
else changed.push(k);
}
for (const k of runtimeKeys) {
if (!(k in declared)) onlyOnContainer.push(k);
}
onlyInCompose.sort();
onlyOnContainer.sort();
inBoth.sort();
changed.sort();
return { onlyInCompose, onlyOnContainer, inBoth, changed };
}
/** Node-wide Docker label inventory for fleet and system routes. */
export async function buildNodeLabelInventory(
nodeId: number,
options: LabelInventoryOptions = {},
): Promise<NodeLabelInventory> {
const revealSecrets = options.revealSecrets === true;
const docker = DockerController.getInstance(nodeId);
const rows = await docker.listContainersForLabelInventory();
const { map: imageLabelMap, partial: imagePartial } = await buildImageLabelMap(docker, rows.map(r => r.imageId));
let missingImage = false;
const containers: ContainerLabelRow[] = rows.map((row) => {
const imageLabels = row.imageId ? (imageLabelMap.get(row.imageId) ?? null) : null;
if (!row.imageId) missingImage = true;
return {
id: row.id,
name: row.name,
stack: row.stack,
service: row.service,
state: row.state,
labels: Object.entries(row.labels).map(([key, value]) =>
toLabelValue(key, value, resolveLabelSource(key, value, imageLabels), revealSecrets),
).sort((a, b) => a.key.localeCompare(b.key)),
};
});
return {
nodeId,
containers,
byLabel: buildByLabelIndex(containers),
partial: rows.some(r => r.inspectFailed) || imagePartial || missingImage,
generatedAt: Date.now(),
};
}
/** Per-stack declared vs runtime label reconciliation for Stack Anatomy. */
export async function buildStackLabelInventory(
nodeId: number,
stackName: string,
options: LabelInventoryOptions = {},
): Promise<StackLabelInventory> {
const revealSecrets = options.revealSecrets === true;
const result = await ComposeService.getInstance(nodeId).renderConfig(stackName);
let renderable = false;
// A failed render leaves the declared map empty. Without the declared model we cannot
// tell a Compose-declared label from a runtime one, so non-system runtime labels are
// resolved to `unknown` and reconciliation is skipped rather than shown as false drift.
// `renderable === false` (not `partial`) signals this to the UI; `partial` is reserved
// for inspection failures so the two banners stay distinct.
let renderFailed = false;
const declaredByService = new Map<string, Record<string, string>>();
if (result.rendered !== null) {
try {
const parsed = JSON.parse(result.rendered) as { services?: Record<string, { labels?: unknown }> };
for (const [serviceName, svc] of Object.entries(parsed.services ?? {})) {
declaredByService.set(serviceName, parseLabelsMap(svc.labels));
}
renderable = true;
} catch (err) {
console.error('[LabelInventory] Failed to parse rendered compose for stack %s:', sanitizeForLog(stackName), err);
renderFailed = true;
}
} else {
console.error('[LabelInventory] Compose render failed for stack %s (code %s): %s',
sanitizeForLog(stackName), result.code, sanitizeForLog(result.stderr));
renderFailed = true;
}
const docker = DockerController.getInstance(nodeId);
const stackContainers = await docker.getContainersByStack(stackName) as Array<{ Id?: string; Names?: string[]; State?: string; Service?: string }>;
const inspected = await mapWithConcurrency(stackContainers, INSPECT_CONCURRENCY, async (c) => {
const id = c.Id ?? '';
const name = stripContainerName(c.Names);
const state = c.State ?? 'unknown';
const service = c.Service ?? null;
const result = id ? await docker.inspectContainerLabelsAndImage(id) : null;
return { id, name, state, service, labels: result?.labels ?? {}, imageId: result?.imageId ?? '', inspectFailed: result === null };
});
const { map: imageLabelMap, partial: imagePartial } = await buildImageLabelMap(docker, inspected.map(r => r.imageId));
let partial = imagePartial;
const replicasByService = new Map<string, typeof inspected>();
for (const replica of inspected) {
const svc = replica.service ?? '_unknown';
const list = replicasByService.get(svc) ?? [];
list.push(replica);
replicasByService.set(svc, list);
}
const serviceNames = new Set<string>([
...declaredByService.keys(),
...replicasByService.keys(),
]);
serviceNames.delete('_unknown');
const services: StackServiceLabelRow[] = [...serviceNames].sort().map((service) => {
const declared = declaredByService.get(service) ?? {};
const declaredLabels = Object.entries(declared)
.map(([key, value]) => toLabelValue(key, value, 'compose', revealSecrets))
.sort((a, b) => a.key.localeCompare(b.key));
const replicas: StackLabelReplica[] = (replicasByService.get(service) ?? []).map((rep) => {
// A failed inspect has no runtime labels; reconciling against {} would falsely
// report every declared label as Compose-only, so skip reconciliation and flag it.
if (rep.inspectFailed) {
partial = true;
return {
id: rep.id, name: rep.name, state: rep.state,
runtimeLabels: [], onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: [],
inspectFailed: true,
};
}
const imageLabels = rep.imageId ? (imageLabelMap.get(rep.imageId) ?? null) : null;
if (!rep.imageId) partial = true;
// Without a declared model, only compose-system keys can be attributed with
// confidence; everything else is unknown, and reconciliation is skipped.
const runtimeLabels = Object.entries(rep.labels)
.map(([key, value]) => {
const source: LabelSource = renderFailed && !key.startsWith(COMPOSE_SYSTEM_PREFIX)
? 'unknown'
: resolveLabelSource(key, value, imageLabels, declared);
return toLabelValue(key, value, source, revealSecrets);
})
.sort((a, b) => a.key.localeCompare(b.key));
const runtimeMap = Object.fromEntries(Object.entries(rep.labels));
const { onlyInCompose, onlyOnContainer, inBoth, changed } = renderFailed
? { onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: [] }
: reconcileKeys(declared, runtimeMap);
return {
id: rep.id,
name: rep.name,
state: rep.state,
runtimeLabels,
onlyInCompose,
onlyOnContainer,
inBoth,
changed,
};
});
return { service, declaredLabels, replicas };
});
return {
stackName,
renderable,
services,
partial,
generatedAt: Date.now(),
};
}
+6 -6
View File
@@ -9,6 +9,7 @@ import { NotificationService } from './NotificationService';
import { FleetUpdateTrackerService } from './FleetUpdateTrackerService';
import { isValidVersion, getSenchoVersion } from './CapabilityRegistry';
import { getLatestVersionInfo } from '../utils/version-check';
import { getHostMemory } from '../helpers/hostMemory';
import { isDebugEnabled } from '../utils/debug';
import { withTimeout, TimeoutError } from '../utils/withTimeout';
@@ -287,9 +288,9 @@ export class MonitorService {
// 1. Host Limits — fetch CPU, RAM, disk concurrently
if (settings['host_alerts_enabled'] !== '0') {
try {
const [currentLoad, mem, fsSize] = await Promise.all([
const [currentLoad, hostMem, fsSize] = await Promise.all([
withTimeout(si.currentLoad(), STATS_TIMEOUT_MS, 'host CPU stats'),
withTimeout(si.mem(), STATS_TIMEOUT_MS, 'host RAM stats'),
withTimeout(getHostMemory(), STATS_TIMEOUT_MS, 'host RAM stats'),
withTimeout(si.fsSize(), STATS_TIMEOUT_MS, 'host disk stats'),
]);
@@ -302,10 +303,9 @@ export class MonitorService {
this.clearHostMetricSuppression('cpu');
}
// Key off mem.active (the real working set), not mem.used: on Linux/BSD/macOS
// mem.used counts reclaimable page cache and reads ~99% on a busy host, which
// would fire spurious host-memory alerts.
const ramUsage = (mem.active / mem.total) * 100;
// ZFS ARC aware: reclaimable ARC is added back into available so a large ARC
// cache does not fire spurious host-memory alerts. See helpers/hostMemory.ts.
const ramUsage = hostMem.usagePercent;
const ramLimit = parseFloat(settings['host_ram_limit']);
if (!isNaN(ramLimit) && ramLimit > 0 && ramUsage > ramLimit) {
await this.dispatchHostMetricAlert('ram', 'warning', suppressionMs,
+53 -12
View File
@@ -6,6 +6,12 @@ import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import { sanitizeNotificationMessage } from '../utils/notificationMessage';
import { StackActivityMetricsService } from './StackActivityMetricsService';
import {
appliesToBell,
appliesToExternal,
matchesNotificationFilters,
ruleNeedsStackLabels,
} from '../helpers/notificationMatchers';
export type NotificationCategory =
| 'deploy_success'
@@ -44,6 +50,13 @@ export const ALL_NOTIFICATION_CATEGORIES: readonly NotificationCategory[] = [
'node_update_available', 'system',
];
/** Every category that can appear in notification history / the bell panel. */
export const ALL_SUPPRESSIBLE_CATEGORIES: readonly NotificationCategory[] = [
...ALL_NOTIFICATION_CATEGORIES,
'drift_detected', 'drift_resolved',
'update_started', 'health_gate_passed', 'health_gate_failed',
];
/** Webhook timeout: 10 seconds per external dispatch call. */
const WEBHOOK_TIMEOUT_MS = 10_000;
@@ -185,23 +198,51 @@ export class NotificationService {
});
}
const suppressionRules = this.dbService.getEnabledNotificationSuppressionRules();
const routes = this.dbService.getEnabledNotificationRoutes();
const needsStackLabels = stackName !== undefined && (
ruleNeedsStackLabels(suppressionRules)
|| routes.some((r) => r.label_ids != null && r.label_ids.length > 0)
);
const stackLabelIds = needsStackLabels
? this.dbService.getStackLabelIds(localNodeId, stackName!)
: [];
const matchCtx = {
localNodeId,
stackName,
category,
level,
stackLabelIds,
};
const matchedSuppression = suppressionRules.filter((r) => matchesNotificationFilters(matchCtx, r));
const suppressBell = matchedSuppression.some((r) => appliesToBell(r.applies_to));
const suppressExternal = matchedSuppression.some((r) => appliesToExternal(r.applies_to));
if (isDebugEnabled() && matchedSuppression.length > 0) {
console.log(`[Notify:diag] Suppression matched ${matchedSuppression.length} rule(s); bell=${suppressBell}, external=${suppressExternal}`);
}
if (matchedSuppression.length > 0 && notification.id != null) {
this.dbService.updateNotificationSuppressionMatch(notification.id, {
rules: matchedSuppression.map((r) => ({ id: r.id, name: r.name })),
bellSuppressed: suppressBell,
externalSuppressed: suppressExternal,
});
}
// 2. Push to connected browser clients via WebSocket
this.broadcastToSubscribers(notification);
if (!suppressBell) {
this.broadcastToSubscribers(notification);
}
if (suppressExternal) {
return;
}
// 3. Check notification routing rules — always evaluated, matchers compose AND
const errors: string[] = [];
const routes = this.dbService.getEnabledNotificationRoutes();
const needsLabels = stackName !== undefined && routes.some(r => r.label_ids != null && r.label_ids.length > 0);
const stackLabelIds = needsLabels ? this.dbService.getStackLabelIds(localNodeId, stackName!) : [];
const matched = routes.filter(r => {
if (r.node_id != null && r.node_id !== localNodeId) return false;
if (r.stack_patterns.length > 0 && (stackName === undefined || !r.stack_patterns.includes(stackName))) return false;
if (r.label_ids != null && r.label_ids.length > 0 && !r.label_ids.some(id => stackLabelIds.includes(id))) return false;
if (r.categories != null && r.categories.length > 0 && !r.categories.includes(category)) return false;
return true;
});
const matched = routes.filter(r => matchesNotificationFilters(matchCtx, r));
if (matched.length > 0) {
if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${sanitizeForLog(stackName ?? '(none)')}", category="${sanitizeForLog(category)}"`);
await Promise.allSettled(
+132 -1
View File
@@ -21,6 +21,8 @@ import type { ScanAllNodeImagesResult } from './TrivyService';
import TrivyInstaller from './TrivyInstaller';
import { CloudBackupService } from './CloudBackupService';
import { buildSystemPolicyGateOptions } from '../helpers/policyGate';
import { filterContainersByComposeService } from '../helpers/composeServiceMatch';
import { excludeSelfContainers } from '../helpers/excludeSelfContainers';
import { enforcePolicyPreDeploy } from './PolicyEnforcement';
import { summarizeBlockReasons } from '../utils/policy-risk';
@@ -424,6 +426,9 @@ export class SchedulerService {
}
private async executeRestart(task: ScheduledTask): Promise<string> {
if (task.target_type === 'container') {
return this.executeContainerRestart(task);
}
if (!task.target_id || task.node_id == null) {
throw new Error('Stack restart requires target_id and node_id');
}
@@ -439,7 +444,7 @@ export class SchedulerService {
let filtered = containers;
if (task.target_services) {
const serviceNames: string[] = JSON.parse(task.target_services);
filtered = containers.filter(c => c.Service && serviceNames.includes(c.Service));
filtered = serviceNames.flatMap(svc => filterContainersByComposeService(containers, svc));
if (filtered.length === 0) {
throw new Error(`No containers found matching services [${serviceNames.join(', ')}] in stack "${task.target_id}"`);
}
@@ -507,6 +512,9 @@ export class SchedulerService {
}
private async executeAutoStop(task: ScheduledTask): Promise<string> {
if (task.target_type === 'container') {
return this.executeContainerStop(task);
}
this.assertStackTarget(task, 'Auto-stop');
if (this.isRemoteNode(task.node_id)) {
await this.postToRemoteStack(task.node_id, `${encodeURIComponent(task.target_id)}/stop`);
@@ -537,6 +545,9 @@ export class SchedulerService {
}
private async executeAutoStart(task: ScheduledTask): Promise<string> {
if (task.target_type === 'container') {
return this.executeContainerStart(task);
}
this.assertStackTarget(task, 'Auto-start');
// Remote auto-start proxies to the remote's own deploy route, which runs
// that node's scan-policy gate against the images it actually holds. The
@@ -802,6 +813,126 @@ export class SchedulerService {
* `routeSuffix` is the path under `/api/stacks/`; the caller URL-encodes each
* segment.
*/
private containerNotFoundMessage(containerName: string, nodeId: number): string {
const nodeName = NodeRegistry.getInstance().getNode(nodeId)?.name ?? String(nodeId);
return `Container "${containerName}" not found on node "${nodeName}". It may have been renamed or removed.`;
}
private async resolveContainerId(task: ScheduledTask): Promise<{ id: string; name: string }> {
if (!task.target_id || task.node_id == null) {
throw new Error('Container operations require target_id and node_id');
}
const name = task.target_id;
if (this.isRemoteNode(task.node_id)) {
const containers = await this.getRemoteContainers(task.node_id);
const match = containers.find(
c => c.Names?.[0]?.replace(/^\//, '') === name,
);
if (!match) throw new Error(this.containerNotFoundMessage(name, task.node_id));
return { id: match.Id, name };
}
const found = await DockerController.getInstance(task.node_id).findContainerByName(name);
if (!found) throw new Error(this.containerNotFoundMessage(name, task.node_id));
return { id: found.id, name: found.name };
}
private async executeContainerRestart(task: ScheduledTask): Promise<string> {
const { id, name } = await this.resolveContainerId(task);
const nodeId = task.node_id!;
if (this.isRemoteNode(nodeId)) {
await this.postToRemoteContainer(nodeId, id, 'restart');
} else {
await DockerController.getInstance(nodeId).restartContainer(id);
}
const nodeName = NodeRegistry.getInstance().getNode(nodeId)?.name ?? String(nodeId);
return `Restarted container "${name}" (id ${id.slice(0, 12)}) on node "${nodeName}"`;
}
private async executeContainerStop(task: ScheduledTask): Promise<string> {
const { id, name } = await this.resolveContainerId(task);
const nodeId = task.node_id!;
if (this.isRemoteNode(nodeId)) {
await this.postToRemoteContainer(nodeId, id, 'stop');
} else {
await DockerController.getInstance(nodeId).stopContainer(id);
}
const nodeName = NodeRegistry.getInstance().getNode(nodeId)?.name ?? String(nodeId);
return `Stopped container "${name}" (id ${id.slice(0, 12)}) on node "${nodeName}"`;
}
private async executeContainerStart(task: ScheduledTask): Promise<string> {
const { id, name } = await this.resolveContainerId(task);
const nodeId = task.node_id!;
if (this.isRemoteNode(nodeId)) {
await this.postToRemoteContainer(nodeId, id, 'start');
} else {
await DockerController.getInstance(nodeId).startContainer(id);
}
const nodeName = NodeRegistry.getInstance().getNode(nodeId)?.name ?? String(nodeId);
return `Started container "${name}" (id ${id.slice(0, 12)}) on node "${nodeName}"`;
}
private async getRemoteContainers(nodeId: number): Promise<Array<{
Id: string;
Names?: string[];
State?: string;
Image?: string;
}>> {
const proxyTarget = NodeRegistry.getInstance().getProxyTarget(nodeId);
if (!proxyTarget) {
throw new Error('Remote node is not configured or missing API credentials');
}
const baseUrl = proxyTarget.apiUrl.replace(/\/$/, '');
const proxyHeaders = LicenseService.getInstance().getProxyHeaders();
const response = await fetch(`${baseUrl}/api/containers?all=true`, {
headers: {
'Authorization': `Bearer ${proxyTarget.apiToken}`,
[PROXY_TIER_HEADER]: proxyHeaders.tier,
},
signal: AbortSignal.timeout(60_000),
});
if (!response.ok) {
const body = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
throw new Error((body as { error?: string }).error || `Remote node returned ${response.status}`);
}
return excludeSelfContainers(await response.json() as Array<{
Id: string;
Names?: string[];
State?: string;
Image?: string;
ImageID?: string;
}>);
}
private async postToRemoteContainer(
nodeId: number,
containerId: string,
action: 'start' | 'stop' | 'restart',
): Promise<void> {
const proxyTarget = NodeRegistry.getInstance().getProxyTarget(nodeId);
if (!proxyTarget) {
throw new Error('Remote node is not configured or missing API credentials');
}
const baseUrl = proxyTarget.apiUrl.replace(/\/$/, '');
const proxyHeaders = LicenseService.getInstance().getProxyHeaders();
const response = await fetch(
`${baseUrl}/api/containers/${encodeURIComponent(containerId)}/${action}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${proxyTarget.apiToken}`,
[PROXY_TIER_HEADER]: proxyHeaders.tier,
},
signal: AbortSignal.timeout(300_000),
},
);
if (!response.ok) {
const body = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
throw new Error((body as { error?: string }).error || `Remote node returned ${response.status}`);
}
}
private async postToRemoteStack(nodeId: number, routeSuffix: string): Promise<void> {
const proxyTarget = NodeRegistry.getInstance().getProxyTarget(nodeId);
if (!proxyTarget) {
@@ -10,7 +10,7 @@
* each side.
*/
export const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const;
export const VALID_TARGET_TYPES = ['stack', 'fleet', 'system', 'container'] as const;
export type TargetType = typeof VALID_TARGET_TYPES[number];
export interface BackendScheduledActionDefinition {
@@ -26,15 +26,15 @@ export interface BackendScheduledActionDefinition {
* in `routes/scheduledTasks.ts` ("Must be restart, snapshot, prune, ...").
*/
export const BACKEND_SCHEDULED_ACTIONS = [
{ id: 'restart', targetTypes: ['stack'], requiresNode: true },
{ id: 'restart', targetTypes: ['stack', 'container'], requiresNode: true },
{ id: 'snapshot', targetTypes: ['fleet'], requiresNode: false },
{ id: 'prune', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' },
{ id: 'update', targetTypes: ['stack', 'fleet'], requiresNode: true },
{ id: 'scan', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' },
{ id: 'auto_backup', targetTypes: ['stack'], requiresNode: true },
{ id: 'auto_stop', targetTypes: ['stack'], requiresNode: true },
{ id: 'auto_stop', targetTypes: ['stack', 'container'], requiresNode: true },
{ id: 'auto_down', targetTypes: ['stack'], requiresNode: true },
{ id: 'auto_start', targetTypes: ['stack'], requiresNode: true },
{ id: 'auto_start', targetTypes: ['stack', 'container'], requiresNode: true },
] as const satisfies readonly BackendScheduledActionDefinition[];
export type BackendScheduledAction = typeof BACKEND_SCHEDULED_ACTIONS[number]['id'];
@@ -58,15 +58,15 @@ const ACTION_BY_ID = new Map<BackendScheduledAction, BackendScheduledActionDefin
* the API contract, so it is kept explicit rather than templated.
*/
const TARGET_MISMATCH_MESSAGE: Record<BackendScheduledAction, string> = {
restart: 'Restart action requires target_type "stack".',
restart: 'Restart action requires target_type "stack" or "container".',
snapshot: 'Snapshot action requires target_type "fleet".',
prune: 'Prune action requires target_type "system".',
update: 'Update action requires target_type "stack" or "fleet".',
scan: 'Scan action requires target_type "system".',
auto_backup: 'auto_backup action requires target_type "stack".',
auto_stop: 'auto_stop action requires target_type "stack".',
auto_stop: 'auto_stop action requires target_type "stack" or "container".',
auto_down: 'auto_down action requires target_type "stack".',
auto_start: 'auto_start action requires target_type "stack".',
auto_start: 'auto_start action requires target_type "stack" or "container".',
};
/**
+3
View File
@@ -72,6 +72,9 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
'PUT /notification-routes': 'Updated notification route',
'DELETE /notification-routes': 'Deleted notification route',
'POST /notification-routes/*/test': 'Tested notification route',
'POST /notification-suppression-rules': 'Created notification suppression rule',
'PUT /notification-suppression-rules': 'Updated notification suppression rule',
'DELETE /notification-suppression-rules': 'Deleted notification suppression rule',
// Webhooks
'POST /webhooks': 'Created webhook',
+4
View File
@@ -8,6 +8,10 @@ import { sanitizeForLog } from './safeLog';
export const isValidStackName = (name: string): boolean =>
/^[a-zA-Z0-9_-]+$/.test(name);
/** Docker container name (no path separators). Used for scheduled container targets. */
export const isValidContainerName = (name: string): boolean =>
!name.includes('..') && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,254}$/.test(name);
/**
* Validates that a remote node API URL is a safe, well-formed HTTP/HTTPS URL.
* Rejects loopback addresses to prevent SSRF against local services.
+15 -3
View File
@@ -29,15 +29,27 @@ services:
# (Optional but Recommended) Media/Data Drives
# Mount your media drives here so Docker Compose inside Sencho can validate paths during deployment.
- /path/to/your/media/drives:/path/to/your/media/drives
# (Optional, ZFS hosts only) OpenZFS ARC stats for ZFS-aware host memory.
# ARC cache is reclaimable but the kernel reports it as used, which can
# trigger false host-memory alerts. Uncomment to let Sencho treat ARC as
# available memory. Usually already visible in the container; only needed
# if your runtime does not expose /proc/spl/kstat/zfs/arcstats.
# - /proc/spl/kstat/zfs/arcstats:/host/proc/spl/kstat/zfs/arcstats:ro
environment:
# ENVIRONMENT VARIABLES FOR INSIDE THE CONTAINER
# This points to the Container Path (right side) of your 1:1 mount above
- COMPOSE_DIR=/path/to/your/docker/folder/compose
# This points to the Container Path (right side) of your database mount above. Leave this as /app/data.
- DATA_DIR=/app/data
# (Optional, ZFS hosts only) Container-side path to the OpenZFS ARC stats
# file, if it is not at a standard location. Leave empty to auto-detect
# /host/proc/spl/kstat/zfs/arcstats then /proc/spl/kstat/zfs/arcstats.
- SENCHO_ZFS_ARCSTATS_PATH=${SENCHO_ZFS_ARCSTATS_PATH:-}
# ⚠️ GLOBAL ENVIRONMENT VARIABLES ⚠️
# If your compose files rely on host-level shell variables (like $PUID, $TZ)
+1
View File
@@ -108,6 +108,7 @@
"features/compose-doctor",
"features/compose-networking",
"features/environment-guardrails",
"features/docker-label-audit",
"features/compose-storage",
"features/stack-labels",
"features/sidebar"
+30
View File
@@ -101,6 +101,36 @@ Three icon actions appear on the right edge of each card:
The masthead carries `SCOPE` (`global`), `ROUTES` (total), and `ENABLED` (count).
## Mute Rules
<Note>
Admin role is required to create, edit, or delete mute rules.
</Note>
Notification suppression rules **hide or drop** matching alerts. They do not send alerts elsewhere. Routing and suppression are separate: a rule can match the same alert as a route, but suppression is evaluated first and can block bell delivery, external channels, or both.
Open **Settings · Notifications · Mute Rules** and click **+ Add mute rule**.
| Field | Purpose |
|-------|---------|
| **Name** | A human label, up to 100 characters. |
| **Node scope** | `Any node` or a specific fleet node. Limits which node emits the alert before the rule can match. |
| **Stacks** *(optional)* | Stack names. Empty matches any stack. |
| **Labels** *(optional)* | Stack labels. Empty matches any label. |
| **Categories** *(optional)* | Notification categories. Empty matches any category. |
| **Severity** *(optional)* | One or more of info, warning, or error. Empty matches any severity. |
| **Apply to** | **Bell**, **External**, or **Both**. Bell skips the notification popover WebSocket push. External skips routing rules and global channels. |
| **Expiration** | Forever, 1 hour, 24 hours, or a custom date. Expired rules stop matching automatically. |
| **Enabled** | Toggle the rule without deleting it. |
All non-empty matchers must match (AND). Suppressed alerts are still written to stack activity history; only delivery is affected. When a mute rule blocks delivery, the activity row shows a **Suppressed** badge with the matched rule name in a tooltip.
Rules you create on the control instance replicate to remote nodes so alerts emitted on a remote stack honor the same suppression. From the bell, admins can open a row menu and choose **Mute this category**, **Mute notifications like this**, or **Mute this stack** to create a quick rule with default **Both** targeting. The same presets are available from stack menus (sidebar, stack header, activity tab), fleet node cards, and label groups.
### Built-in bell quieting (not a mute rule)
Sencho also hides one class of notification from the popover without a user rule: rows where the category is one of `deploy_success`, `stack_started`, `stack_stopped`, `stack_restarted`, or `image_update_applied`, and the row carries an `actor_username` other than `system`. These are confirmations of an action you just clicked. The rows are still persisted and still dispatched externally; only the bell render hides them. Use mute rules when you need configurable, operator-controlled muting.
## Notification categories
Every alert Sencho dispatches carries a category that you can filter on in the bell, target with a routing rule's **Categories** matcher, or reason about when reading audit history.
+4
View File
@@ -47,6 +47,10 @@ The gauge bars (and the corresponding numeric values) pick up amber at 80% and r
While the dashboard is loading the CPU tile reads `--` and the caption shows `collecting metrics…`; bars and sparklines render once the first sample arrives.
<Note>
**ZFS hosts:** the memory tile and host RAM alerts are ZFS ARC-aware. Reclaimable ARC cache is treated as available memory rather than used, so a large ARC does not inflate the gauge or trigger false low-memory alerts. See [ZFS ARC-aware host memory](/getting-started/configuration#zfs-arc-aware-host-memory) for how to expose ARC stats to a Docker install.
</Note>
## Stack health
A mono table of every stack discovered in the active node's `COMPOSE_DIR`, sorted so the stacks demanding attention sit at the top.
+55
View File
@@ -0,0 +1,55 @@
---
title: "Docker Label Audit"
description: "Audit Docker and Compose labels that drive external automation across the fleet and inside each stack."
---
Docker labels are metadata declared on Compose services or attached to running containers. They are different from Sencho Stack Labels used for organizing stacks and Node Labels used for Blueprint placement.
Sencho surfaces two read-only audit views for these labels:
- **Fleet · Docker Labels** for estate-wide visibility across every node
- **Stack · Compose Labels** for per-stack reconciliation between declared Compose labels and labels present on running containers
## Fleet Docker Labels tab
Open **Fleet** and select the **Docker Labels** tab (after **Map**). The panel is titled **Docker label audit** and offers two layouts:
| View | What it shows |
|------|----------------|
| **By container** | Each container with its label count. Expand a row to see every key, value, and provenance badge. |
| **By label** | Each unique `key=value` pair and every container that carries it, with node name chips on multi-node fleets. |
Use the search box to filter by label key, value, container name, stack, or node. When a container belongs to a known stack, **Open stack** jumps to that stack in the editor.
Every label carries a provenance badge so you can tell where it came from:
- **Image** for labels inherited from the container image (for example OCI `org.opencontainers.image.*` metadata)
- **Present at runtime** for other labels set on the running container
- **Docker Compose system label** for keys starting with `com.docker.compose.`
- **Unknown** when a container or its image could not be inspected
The fleet view reads container-level metadata and does not open the compose file, so it cannot tell a Compose-declared label from any other label set on the container: labels you declared in Compose appear here as **Present at runtime**. To see which labels come from the compose file, use the per-stack **Compose Labels** tab below. When a container or its image cannot be inspected, the affected labels show as **Unknown** and the panel names the nodes it could not fully inspect.
In the **By label** layout, open the **Filters** popover next to the search box. The **Defined by** section lists toggle pills for each provenance type present in the data (**Image**, **Runtime**, **System**, and **Unknown** when present). Turn a pill off to hide that source; the button shows a count badge while any filter is active, and **Clear filters** resets the popover. Automation tools such as Watchtower, Diun, and Traefik read these labels, so the audit makes it easy to confirm which containers are opted in or out.
Runtime labels are static until the container is recreated. Changes declared in Compose require save and redeploy before they appear on running containers.
## Stack Compose Labels tab
Inside the stack editor, open the **Compose Labels** tab in the anatomy strip. For each service you see:
- **Declared in Compose** labels from the effective rendered compose model
- **Present at runtime** labels read from each running replica, each with its provenance badge (Compose, Image, or runtime)
- Reconciliation hints: **only in Compose**, **only on running container**, **present in both**, or **value changed** when a key is declared and running but the values differ
Because this tab renders the compose model, it can identify Compose-declared labels accurately. A toolbar at the top combines a **search box** with a **Filters** popover. The search matches label keys and values as well as service and container names. The popover has a **Defined by** section with toggle pills for each provenance present (**Compose File**, **Image**, **Runtime**, **System**) and a **Services** section to show or hide individual service cards when the stack has more than one service. The Filters button shows a count badge for active facet and service filters; **Clear filters** resets both sections. Matching a service or container name reveals that parent's labels even when the text does not match a specific key or value. Reconciliation counts always reflect only the labels currently visible.
When Compose cannot be fully rendered, the panel warns that declared labels may be incomplete but still shows whatever runtime data is available. If a replica cannot be inspected, it is flagged with **Runtime labels unavailable** and the panel notes that provenance may be incomplete.
## Sensitive values
Some label keys look like secrets (for example keys containing `token`, `password`, or `auth`). Their values are redacted by default. Admins can reveal full values with the **Reveal** control, which re-fetches the inventory with elevated read access.
## Editing labels
Compose label editing from these panels is not available yet. To change labels today, edit the compose file directly and redeploy the stack.
+1
View File
@@ -80,6 +80,7 @@ The tab row always shows four tabs: **Anatomy**, **Activity**, **Dossier**, and
| **Dossier** | Yes | Exportable Markdown of the anatomy combined with operator notes. See [Stack Dossier](/features/stack-dossier). |
| **Drift** | Yes | Live comparison of the declared compose against the running containers. See [Stack Drift](/features/stack-drift). |
| **Environment** | When `env-inventory` capability is present | Variable inventory across all env files, with status for each variable. See [Environment Guardrails](/features/environment-guardrails). |
| **Compose Labels** | When `container-label-inventory` capability is present | Declared Compose labels vs runtime container labels per service. See [Docker Label Audit](/features/docker-label-audit). |
| **Networking** | When `compose-networking` capability is present | Port exposure summary per service with intent classification. See [Compose Networking](/features/compose-networking). |
| **Doctor** | When `compose-doctor` capability is present | Preflight check results grouped by severity. The tab gains a red dot for blocker findings and an amber dot for high-risk findings. See [Compose Doctor](/features/compose-doctor). |
| **Storage** | When `compose-storage` capability is present | Mount inventory with portability assessment and snapshot coverage. See [Compose Storage](/features/compose-storage). |
+8 -4
View File
@@ -26,8 +26,8 @@ Open the **Schedules** tab from the top navigation bar. The page opens on the Ti
The Timeline plots every firing of every enabled task across a rolling 24-hour window starting from the current minute.
- **Masthead.** A `NEXT 24 HOURS` kicker, an italic display heading, the window's start and end timestamps in a monospace range, and a right-anchored **Next** pill that reads out the time and task name of the next firing and a relative countdown.
- **Five lanes.** Stack lifecycle (label blue), Updates (success green), Security (label purple), Maintenance (warning amber), and Backups (brand cyan). The Stack lifecycle lane holds the five stack-lifecycle actions (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Stop Stack, Take Stack Down); Updates holds per-node and fleet image updates; Security holds vulnerability scans; Maintenance holds node resource prunes; Backups holds fleet snapshots.
- **Pills.** One pill per firing within the window, positioned proportionally to the firing's time. Each pill shows the firing time and a target: the stack for stack actions, the selected node for prune and scan, and "Entire fleet" for a fleet snapshot. Hover a pill for the full detail (action, task name, and node). Pills are color-matched to their lane. Click a pill to open the run history sheet for that task.
- **Five lanes.** Lifecycle (label blue), Updates (success green), Security (label purple), Maintenance (warning amber), and Backups (brand cyan). The Lifecycle lane holds stack lifecycle actions (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Stop Stack, Take Stack Down) and standalone container actions (Restart Container, Stop Container, Start Container); Updates holds per-node and fleet image updates; Security holds vulnerability scans; Maintenance holds node resource prunes; Backups holds fleet snapshots.
- **Pills.** One pill per firing within the window, positioned proportionally to the firing's time. Each pill shows the firing time and a target: the stack for stack actions, the container name for container actions, the selected node for prune and scan, and "Entire fleet" for a fleet snapshot. Hover a pill for the full detail (action, task name, and node). Pills are color-matched to their lane. Click a pill to open the run history sheet for that task.
- **Now rail.** A glowing vertical rail at the current minute, anchored to the left of the track at page open and drifting right as time passes (the page recomputes positions periodically).
- **Axis.** Six monospace time ticks run along the bottom, evenly spaced through the window.
@@ -47,7 +47,7 @@ The All tasks toggle swaps the lane track for a sortable table.
|---|---|
| **Name** | The task name. |
| **Action** | A badge labelling the operation (e.g. Restart Stack, Scan Node Images, Create Fleet Snapshot). |
| **Target** | The stack the task targets (with a service list in parentheses when restart is scoped to specific services), or the target type for non-stack actions (`system`, `fleet`). |
| **Target** | The stack the task targets (with a service list in parentheses when restart is scoped to specific services), the container name for container actions, or the target type for other non-stack actions (`system`, `fleet`). |
| **Schedule** | A human-readable description of the cron with the raw expression on a second line. |
| **Status** | The last run result: **Success** (green), **Failed** (red), or `Never run` if the task has not fired yet. |
| **Next Run** | The timestamp of the next firing, or a dash if the task is disabled or has no upcoming runs. |
@@ -68,10 +68,13 @@ The All tasks toggle swaps the lane track for a sortable table.
| **Stop Stack** | A specific stack on a specific node | Runs `docker compose stop`. Containers are stopped but preserved. Use for off-hours power saving when you want a fast restart later. |
| **Take Stack Down** | A specific stack on a specific node | Runs `docker compose down`. Containers are removed. Use to fully release resources when the stack is not needed for an extended period. |
| **Start / Bring Up Stack** | A specific stack on a specific node | Runs `docker compose up -d`. Works for both stopped and removed containers: if they exist they are started, if not they are created from the compose file. |
| **Restart Container** | A specific container by name on a specific node | Restarts one container directly through Docker. Use for third-party or standalone containers that are not managed as a Sencho stack. |
| **Stop Container** | A specific container by name on a specific node | Stops one container. The container remains on disk for a faster start later. |
| **Start Container** | A specific container by name on a specific node | Starts one stopped container by name. |
## Creating a scheduled task
Click **New Schedule** in the header. The form opens in a centered modal. The Action picker lists every supported operation, grouped by category: Stack lifecycle, Updates, Security, Maintenance, and Backups.
Click **New Schedule** in the header. The form opens in a centered modal. The Action picker lists every supported operation, grouped by category: Lifecycle, Updates, Security, Maintenance, and Backups.
<Frame>
<img src="/images/scheduled-operations/action-picker.png" alt="The New scheduled task modal with the Action combobox expanded. The dropdown groups actions under category headers: Stack lifecycle (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Stop Stack, Take Stack Down), Updates (Auto-update Stack, Auto-update All Stacks on Node), Security (Scan Node Images), Maintenance (Prune Node Resources), and Backups (Create Fleet Snapshot). Below the picker, partly visible, sit a Services row with 'echo' and 'prober' checkboxes, the Cron Expression input, the Enabled toggle, and the Delete after successful run checkbox." />
@@ -88,6 +91,7 @@ Common fields:
Conditional fields per action:
- **Stack actions** (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Auto-update Stack, Stop Stack, Take Stack Down) add a **Node** combobox and a **Stack** combobox. Restart Stack additionally renders a **Services** checkbox grid sourced from the stack's compose services on the selected node, so you can scope the restart to a subset instead of restarting the entire stack.
- **Container actions** (Restart Container, Stop Container, Start Container) add a **Node** combobox and a **Container** combobox listing every container on that node (running and stopped). The picker shows each container's name, state, and image. When the container is not part of a Sencho stack, helper text explains that the schedule targets the container by node and name.
- **Auto-update All Stacks on Node** adds a **Node** combobox. The helper text "Checks every stack on the selected node and updates stacks with newer images" appears above, next to the Runtime change badge.
- **Scan Node Images** adds a **Node** combobox listing local nodes only. The helper text "Runs Trivy against images on the selected local node and records the findings" and Read-only badge appear above.
- **Prune Node Resources** adds a **Node** combobox listing local nodes only, then a **Prune Targets** group (Containers, Images, Networks, Volumes; all selected by default) and a **Label Filter** input for scoping the prune to resources matching a Docker label.
+16
View File
@@ -43,9 +43,25 @@ These tune optional subsystems. Most deployments never set them; the defaults ar
| `GITSOURCE_MAX_CLONE_BYTES` | `104857600` | Maximum bytes a single [Git Source](/features/git-sources) clone may download before it is aborted (100 MB). A shallow Compose clone is tiny; raise it only if you track Compose files in a legitimately large repository. |
| `SENCHO_PUBLIC_URL` | *(request host)* | Set on the primary instance. Its externally reachable `http(s)://` URL, no trailing slash, baked into pilot enrollment so remote agents dial the public hostname rather than the address the admin used at setup. |
| `SENCHO_COMPOSE_STALL_TIMEOUT_MS` | `600000` | Idle-output backstop for deploy and update Compose steps (pull and recreate). If a step produces no output for this long while still running, Sencho stops it so a hung image pull surfaces a clear failure and the in-app recovery actions instead of spinning. Raise it on slow links or for heavy local image builds. |
| `SENCHO_ZFS_ARCSTATS_PATH` | *(auto)* | Path **inside the container** to the OpenZFS ARC kstat file, for [ZFS ARC-aware host memory](#zfs-arc-aware-host-memory). Sencho checks this path first, then `/host/proc/spl/kstat/zfs/arcstats`, then `/proc/spl/kstat/zfs/arcstats`. Set it only when your ARC stats live at a non-standard path. |
Running a remote host as a pilot agent uses four more variables (`SENCHO_MODE`, `SENCHO_PRIMARY_URL`, `SENCHO_ENROLL_TOKEN`, and `SENCHO_PILOT_CA_FILE`), set only on the remote agent container. Sencho bakes them into the enrollment Compose file it generates, so you rarely write them by hand. See [Pilot Agent](/features/pilot-agent) for the full enrollment walkthrough.
## ZFS ARC-aware host memory
On OpenZFS hosts (TrueNAS SCALE, Proxmox, ZFS on Ubuntu or Debian) the ZFS ARC cache can hold a large share of RAM. ARC is reclaimable on demand, but the Linux kernel reports it as unavailable, so a naive reading counts ARC as used memory and can raise false host-memory alerts.
Sencho reads the ARC kstat when it is available and adds the reclaimable portion back into available memory, so the dashboard memory gauge and host RAM alerts reflect real memory pressure. When no ARC stats are readable the behavior is unchanged.
The ARC kstat is usually visible inside the container at `/proc/spl/kstat/zfs/arcstats` with no extra configuration. If your runtime does not expose it, mount it read-only:
```yaml
volumes:
- /proc/spl/kstat/zfs/arcstats:/host/proc/spl/kstat/zfs/arcstats:ro
```
Sencho checks `SENCHO_ZFS_ARCSTATS_PATH`, then `/host/proc/spl/kstat/zfs/arcstats`, then `/proc/spl/kstat/zfs/arcstats`. Set `SENCHO_ZFS_ARCSTATS_PATH` only if your ARC stats live somewhere else inside the container.
## Listen port
Sencho always listens on `1852` inside the container. The port is fixed and is not read from an environment variable. To expose Sencho on a different host port, remap with Docker's `-p` flag (or the `ports:` key in your compose file):
+253 -8
View File
@@ -149,6 +149,148 @@ components:
type: boolean
example: true
LabelSource:
type: string
description: Provenance of a label. `unknown` when a container or image could not be inspected.
enum: [compose, runtime, image, compose-system, unknown]
LabelValue:
type: object
required: [key, value, source]
properties:
key: { type: string }
value:
type: string
description: Redacted to `[redacted]` for secret-like keys unless the caller is an admin and passes `reveal=1`.
source: { $ref: "#/components/schemas/LabelSource" }
redacted: { type: boolean }
LabelIndexContainerRef:
type: object
required: [id, name, stack, service]
properties:
id: { type: string }
name: { type: string }
stack: { type: string, nullable: true }
service: { type: string, nullable: true }
nodeId: { type: integer }
nodeName: { type: string }
LabelIndexRow:
type: object
description: One unique key/value/source and every container carrying it.
required: [key, value, source, containers]
properties:
key: { type: string }
value: { type: string }
source: { $ref: "#/components/schemas/LabelSource" }
redacted: { type: boolean }
containers:
type: array
items: { $ref: "#/components/schemas/LabelIndexContainerRef" }
ContainerLabelRow:
type: object
required: [id, name, stack, service, state, labels]
properties:
id: { type: string }
name: { type: string }
stack: { type: string, nullable: true }
service: { type: string, nullable: true }
state: { type: string }
labels:
type: array
items: { $ref: "#/components/schemas/LabelValue" }
StackLabelReplica:
type: object
required: [id, name, state, runtimeLabels, onlyInCompose, onlyOnContainer, inBoth, changed]
properties:
id: { type: string }
name: { type: string }
state: { type: string }
runtimeLabels:
type: array
items: { $ref: "#/components/schemas/LabelValue" }
onlyInCompose: { type: array, items: { type: string } }
onlyOnContainer: { type: array, items: { type: string } }
inBoth: { type: array, items: { type: string } }
changed:
type: array
items: { type: string }
description: Keys declared in Compose and present at runtime but with a different value.
inspectFailed:
type: boolean
description: Runtime labels could not be read for this replica; reconciliation was skipped.
StackServiceLabelRow:
type: object
required: [service, declaredLabels, replicas]
properties:
service: { type: string }
declaredLabels:
type: array
items: { $ref: "#/components/schemas/LabelValue" }
replicas:
type: array
items: { $ref: "#/components/schemas/StackLabelReplica" }
StackLabelInventory:
type: object
required: [stackName, renderable, services, partial, generatedAt]
properties:
stackName: { type: string }
renderable:
type: boolean
description: False when the Compose model could not be rendered; declared provenance is then unknown.
partial:
type: boolean
description: A replica or its image could not be fully inspected.
generatedAt: { type: integer }
services:
type: array
items: { $ref: "#/components/schemas/StackServiceLabelRow" }
NodeLabelInventory:
type: object
required: [nodeId, containers, byLabel, partial, generatedAt]
properties:
nodeId: { type: integer }
partial: { type: boolean }
generatedAt: { type: integer }
containers:
type: array
items: { $ref: "#/components/schemas/ContainerLabelRow" }
byLabel:
type: array
items: { $ref: "#/components/schemas/LabelIndexRow" }
FleetLabelInventory:
type: object
required: [nodes, aggregatedByLabel, nodeErrors, generatedAt]
properties:
generatedAt: { type: integer }
nodes:
type: array
items:
type: object
required: [nodeId, nodeName, status, inventory, error]
properties:
nodeId: { type: integer }
nodeName: { type: string }
status: { type: string, enum: [ok, error] }
inventory:
nullable: true
allOf: [{ $ref: "#/components/schemas/NodeLabelInventory" }]
error: { type: string, nullable: true }
aggregatedByLabel:
type: array
items: { $ref: "#/components/schemas/LabelIndexRow" }
nodeErrors:
type: object
additionalProperties: { type: string }
description: Map of node id to error for nodes that were unreachable or returned a malformed payload.
FailureClassification:
type: object
description: Classified cause of a failed deploy or update, with a suggested next step.
@@ -386,13 +528,13 @@ components:
type: string
target_type:
type: string
enum: [stack, fleet, system]
enum: [stack, fleet, system, container]
target_id:
type: ["string", "null"]
description: Stack name (when target_type is `stack`).
description: Stack or container name (when target_type is `stack` or `container`).
node_id:
type: ["integer", "null"]
description: Target node ID (when target_type is `stack`).
description: Target node ID (when target_type is `stack` or `container`).
action:
type: string
enum: [restart, snapshot, prune]
@@ -947,6 +1089,107 @@ paths:
"500":
$ref: "#/components/responses/InternalError"
/api/stacks/{stackName}/label-inventory:
get:
operationId: getStackLabelInventory
tags: [Stacks]
summary: Get Docker label inventory for a stack
description: >-
Returns declared Compose labels and runtime container labels per service,
with reconciliation hints (only in Compose, only on running container,
present in both, or value changed when the values differ) and provenance
for each runtime label (compose, image, runtime, compose-system, or unknown
when a container or image cannot be inspected). Secret-like label values are
redacted unless the caller is an admin and passes `reveal=1`. Requires
`stack:read` permission.
parameters:
- $ref: "#/components/parameters/stackName"
- $ref: "#/components/parameters/nodeId"
- name: reveal
in: query
required: false
schema:
type: string
enum: ['1', 'true']
responses:
"200":
description: Stack label inventory.
content:
application/json:
schema:
$ref: "#/components/schemas/StackLabelInventory"
"403":
$ref: "#/components/responses/Forbidden"
"404":
description: Stack not found.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"500":
$ref: "#/components/responses/InternalError"
/api/system/container-labels:
get:
operationId: getNodeContainerLabels
tags: [Fleet]
summary: Get the Docker label inventory for the active node
description: >-
Returns every container on the node with its labels and provenance
(compose, image, runtime, compose-system, or unknown), plus an inverted
`byLabel` index. Marks `partial` when a container or image inspect fails.
Secret-like values are redacted unless the caller is an admin and passes
`reveal=1`. Requires `node:read` permission.
parameters:
- $ref: "#/components/parameters/nodeId"
- name: reveal
in: query
required: false
schema:
type: string
enum: ['1', 'true']
responses:
"200":
description: Node container label inventory.
content:
application/json:
schema:
$ref: "#/components/schemas/NodeLabelInventory"
"403":
$ref: "#/components/responses/Forbidden"
"500":
$ref: "#/components/responses/InternalError"
/api/fleet/container-labels:
get:
operationId: getFleetContainerLabels
tags: [Fleet]
summary: Get the Docker label inventory aggregated across the fleet
description: >-
Fans out to every node's container label inventory and aggregates the
inverted index by key, value, and source. Unreachable or malformed nodes
degrade into `nodeErrors` rather than failing the whole request. Secret-like
values are redacted unless the caller is an admin and passes `reveal=1`.
Requires `node:read` permission.
parameters:
- name: reveal
in: query
required: false
schema:
type: string
enum: ['1', 'true']
responses:
"200":
description: Fleet-wide container label inventory.
content:
application/json:
schema:
$ref: "#/components/schemas/FleetLabelInventory"
"403":
$ref: "#/components/responses/Forbidden"
"500":
$ref: "#/components/responses/InternalError"
/api/stacks/{stackName}/env:
get:
operationId: getStackEnv
@@ -2717,7 +2960,8 @@ paths:
summary: Create scheduled task
description: |
Creates a new recurring task. Action-target rules:
- `restart` requires `target_type: stack` (with `target_id` and `node_id`)
- `restart` requires `target_type: stack` or `target_type: container` (with `target_id` and `node_id`)
- `auto_stop` and `auto_start` accept `target_type: stack` or `target_type: container`
- `snapshot` requires `target_type: fleet`
- `prune` requires `target_type: system`
@@ -2735,13 +2979,13 @@ paths:
example: Nightly restart
target_type:
type: string
enum: [stack, fleet, system]
enum: [stack, fleet, system, container]
target_id:
type: string
description: Stack name (required when target_type is `stack`).
description: Stack or container name (required when target_type is `stack` or `container`).
node_id:
type: integer
description: Target node ID (required when target_type is `stack`).
description: Target node ID (required when target_type is `stack` or `container`).
action:
type: string
enum: [restart, snapshot, prune]
@@ -2828,9 +3072,10 @@ paths:
type: string
target_type:
type: string
enum: [stack, fleet, system]
enum: [stack, fleet, system, container]
target_id:
type: string
description: Stack or container name when target_type is `stack` or `container`.
node_id:
type: integer
action:
+26
View File
@@ -419,6 +419,32 @@ See [Notification Routing](/features/alerts-notifications#notification-routing)
---
## Mute Rules
<Note>
Creating, editing, and deleting mute rules is admin-only.
</Note>
**Scope:** Global, admin-only
Create notification suppression rules that mute or drop matching alerts from the bell, external channels, or both. Suppression is evaluated before routing. The masthead publishes **RULES** and **ACTIVE** counts.
| Field | Description |
|-------|-------------|
| **Name** | Operator-facing label for the rule. |
| **Node** | Specific node, or all nodes if left empty. |
| **Stack patterns** | Stack names to match (multi-select). |
| **Labels** | Match stacks that carry any selected label. |
| **Categories** | Notification categories to suppress. |
| **Severity** | Info, warning, and/or error levels to suppress. |
| **Apply to** | Bell only, external channels only, or both. |
| **Expiration** | Forever, 1 hour, 24 hours, or a custom timestamp. |
| **Enabled** toggle | Disable a rule without deleting it. |
See [Mute Rules](/features/alerts-notifications#mute-rules) for the full walkthrough, compose-first shortcuts, and bell quick-mute.
---
## Image update checks
**Scope:** Per-node (applies to the currently selected node)
+3 -3
View File
@@ -3,9 +3,9 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/sencho-logo-dark.png" media="(prefers-color-scheme: dark)" />
<link rel="icon" type="image/png" href="/sencho-logo-light.png" media="(prefers-color-scheme: light)" />
<link rel="apple-touch-icon" href="/sencho-logo-light.png" />
<link rel="icon" type="image/svg+xml" href="/sencho-logo-dark.svg" media="(prefers-color-scheme: dark)" />
<link rel="icon" type="image/svg+xml" href="/sencho-logo-light.svg" media="(prefers-color-scheme: light)" />
<link rel="apple-touch-icon" href="/sencho-logo-light.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sencho</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
+583 -578
View File
File diff suppressed because it is too large Load Diff
+26 -25
View File
@@ -18,21 +18,21 @@
"dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-alert-dialog": "^1.1.17",
"@radix-ui/react-checkbox": "^1.3.5",
"@radix-ui/react-context-menu": "^2.3.1",
"@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-hover-card": "^1.1.17",
"@radix-ui/react-label": "^2.1.10",
"@radix-ui/react-popover": "^1.1.17",
"@radix-ui/react-scroll-area": "^1.2.12",
"@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-separator": "^1.1.10",
"@radix-ui/react-slider": "^1.4.1",
"@radix-ui/react-alert-dialog": "^1.1.18",
"@radix-ui/react-checkbox": "^1.3.6",
"@radix-ui/react-context-menu": "^2.3.2",
"@radix-ui/react-dialog": "^1.1.18",
"@radix-ui/react-dropdown-menu": "^2.1.19",
"@radix-ui/react-hover-card": "^1.1.18",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-popover": "^1.1.18",
"@radix-ui/react-scroll-area": "^1.2.13",
"@radix-ui/react-select": "^2.3.2",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slider": "^1.4.2",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-tabs": "^1.1.15",
"@radix-ui/react-tooltip": "^1.2.10",
"@radix-ui/react-tabs": "^1.1.16",
"@radix-ui/react-tooltip": "^1.2.11",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-serialize": "^0.14.0",
@@ -41,42 +41,43 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cronstrue": "^3.21.0",
"cronstrue": "^3.24.0",
"date-fns": "^4.4.0",
"fflate": "^0.8.2",
"geist": "^1.7.2",
"lucide-react": "^1.21.0",
"lucide-react": "^1.23.0",
"monaco-editor": "^0.55.1",
"motion": "^12.41.0",
"motion": "^12.42.2",
"qrcode.react": "^4.2.0",
"radix-ui": "^1.6.0",
"radix-ui": "^1.6.1",
"react": "^19.2.7",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.7",
"react-is": "^19.2.7",
"react-markdown": "^10.1.0",
"react-use-measure": "^2.1.7",
"recharts": "^3.9.0",
"recharts": "^3.9.1",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"yaml": "^2.9.0"
},
"overrides": {
"dompurify": "^3.4.0"
"dompurify": "^3.4.11",
"@babel/core": "^7.29.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.1",
"@tailwindcss/vite": "^4.3.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^26.0.0",
"@types/node": "^26.1.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "^10.5.0",
"eslint": "^10.6.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
@@ -84,8 +85,8 @@
"rollup-plugin-visualizer": "^7.0.1",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.0",
"vite": "^8.1.0",
"typescript-eslint": "^8.62.1",
"vite": "^8.1.2",
"vitest": "^4.1.9"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

@@ -229,7 +229,7 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
rowCount={logRows.length}
errorMessage={errorMessage}
countdown={countdown}
showCountdown={!gateHoldsOpen}
showCountdown={canAutoClose}
gateStatus={healthGate?.status ?? null}
/>
<Button
@@ -413,7 +413,7 @@ interface StatusIndicatorProps {
rowCount: number;
errorMessage?: string;
countdown: number;
/** False while a health gate is observing or terminal-unhealthy (no auto-close). */
/** False when auto-close is not eligible: inline style, or while a gate is observing or terminal-unhealthy. */
showCountdown: boolean;
/** Active health gate status, or null when no gate was started. */
gateStatus: 'observing' | 'passed' | 'failed' | 'unknown' | null;
+11
View File
@@ -40,6 +40,7 @@ import type { SidebarActivityAction } from '@/components/sidebar/SidebarActivity
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
import { useTopNavAlign } from '@/hooks/use-top-nav-align';
import { useStackMuteActions } from '@/hooks/useMuteRuleActions';
import { toast } from '@/components/ui/toast-store';
import { useIsMobile } from '@/hooks/use-is-mobile';
import { MobileTabBar } from './MobileTabBar';
@@ -179,11 +180,14 @@ export default function EditorLayout() {
fleetTab, setFleetTab,
filterNodeId, setFilterNodeId,
schedulePrefill,
muteRulePrefill,
mobileNavOpen, setMobileNavOpen,
handleOpenSettings,
handlePrefillConsumed,
handleMutePrefillConsumed,
handleNavigate,
navItems,
openMuteRulesWithPrefill,
} = navState;
const {
@@ -284,6 +288,8 @@ export default function EditorLayout() {
const loadingAction = selectedFile ? (stackActionMap[selectedFile] ?? null) : null;
const stackName = selectedFile || '';
const stackDisplayName = selectedFile ? selectedFile.replace(/\.(yml|yaml)$/, '') : '';
const stackMuteActions = useStackMuteActions(stackDisplayName, openMuteRulesWithPrefill);
const { isDarkMode } = useTheme();
@@ -500,6 +506,7 @@ export default function EditorLayout() {
onMobileBack={goToMobileList}
onCloseEditor={() => stackActions.attemptLeaveEditor(() => setEditingCompose(false))}
hasUnsavedChanges={stackActions.hasUnsavedChanges}
stackMuteActions={selectedFile ? stackMuteActions : undefined}
/>
);
@@ -680,6 +687,7 @@ export default function EditorLayout() {
},
filterChip,
onOpenCreate: can('stack:create') ? openCreateDialog : undefined,
openMuteRulesWithPrefill,
}}
activitySummary={activitySummary}
onActivityAction={handleActivityAction}
@@ -755,9 +763,12 @@ export default function EditorLayout() {
onClearScheduledOpsFilter={() => setFilterNodeId(null)}
schedulePrefill={schedulePrefill}
onPrefillConsumed={handlePrefillConsumed}
muteRulePrefill={muteRulePrefill}
onMutePrefillConsumed={handleMutePrefillConsumed}
notifications={notifications}
onNavigateToStack={(stackFile) => { void stackActions.loadFile(stackFile); }}
onOpenSettingsSection={(section) => openSettings(section)}
onOpenMuteRulesWithPrefill={openMuteRulesWithPrefill}
onClearNotifications={clearAllNotifications}
fleetUpdatesIntent={fleetUpdatesIntent}
onFleetUpdatesIntentConsumed={handleFleetUpdatesIntentConsumed}
@@ -46,6 +46,7 @@ import { retryHandlerFor } from './recovery-retry';
import type { NotificationItem } from '../dashboard/types';
import type { Node } from '@/context/NodeContext';
import type { useAuth } from '@/context/AuthContext';
import type { useStackMuteActions } from '@/hooks/useMuteRuleActions';
export interface ContainerInfo {
Id: string;
@@ -214,6 +215,8 @@ export interface EditorViewProps {
// Mobile-only: notifications + more-menu cluster for the detail header right
// slot (the global TopBar is dropped on the full-screen detail surface).
headerActions?: React.ReactNode;
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
}
export function EditorView(props: EditorViewProps) {
@@ -270,6 +273,7 @@ export function EditorView(props: EditorViewProps) {
onRefreshState,
onDismissRecovery,
panelStartedAt,
stackMuteActions,
} = props;
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
@@ -376,6 +380,7 @@ export function EditorView(props: EditorViewProps) {
rollbackStack={rollbackStack}
scanStackConfig={scanStackConfig}
requestDeleteStack={requestDeleteStack}
stackMuteActions={stackMuteActions}
/>
</div>
{recoveryResult && loadingAction == null && (
@@ -616,6 +621,7 @@ export function EditorView(props: EditorViewProps) {
applying={loadingAction === 'update'}
canEdit={can('stack:edit', 'stack', stackName)}
notifications={notifications}
stackMuteActions={stackMuteActions}
/>
)}
</div>
@@ -78,6 +78,7 @@ export function MobileStackDetail(props: EditorViewProps) {
onRefreshState,
onDismissRecovery,
panelStartedAt,
stackMuteActions,
} = props;
const [segment, setSegment] = useState<Segment>('logs');
@@ -152,6 +153,7 @@ export function MobileStackDetail(props: EditorViewProps) {
rollbackStack={rollbackStack}
scanStackConfig={scanStackConfig}
requestDeleteStack={requestDeleteStack}
stackMuteActions={stackMuteActions}
/>
</div>
@@ -243,6 +245,7 @@ export function MobileStackDetail(props: EditorViewProps) {
applying={loadingAction === 'update'}
canEdit={canEditStack}
notifications={notifications}
stackMuteActions={stackMuteActions}
/>
</div>
)}
@@ -12,6 +12,7 @@ import ResourcesView from '../ResourcesView';
import HomeDashboard from '../HomeDashboard';
import type { NotificationItem } from '../dashboard/types';
import type { ScheduleTaskPrefill } from '../ScheduledOperationsView';
import type { MuteRuleDraft } from '@/lib/muteRules';
import type { ActiveView } from './hooks/useViewNavigationState';
import type { SecurityTab, FleetTab } from '@/lib/events';
@@ -81,9 +82,12 @@ export interface ViewRouterProps {
onClearScheduledOpsFilter: () => void;
schedulePrefill: ScheduleTaskPrefill | null;
onPrefillConsumed: () => void;
muteRulePrefill: MuteRuleDraft | null;
onMutePrefillConsumed: () => void;
notifications: NotificationItem[];
onNavigateToStack: (stackFile: string) => void;
onOpenSettingsSection: (section: SectionId) => void;
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
onClearNotifications: () => void;
securityTab: SecurityTab;
onSecurityTabChange: (tab: SecurityTab) => void;
@@ -110,9 +114,12 @@ export function ViewRouter({
onClearScheduledOpsFilter,
schedulePrefill,
onPrefillConsumed,
muteRulePrefill,
onMutePrefillConsumed,
notifications,
onNavigateToStack,
onOpenSettingsSection,
onOpenMuteRulesWithPrefill,
onClearNotifications,
securityTab,
onSecurityTabChange,
@@ -128,6 +135,9 @@ export function ViewRouter({
<SettingsPage
currentSection={settingsSection}
onSectionChange={onSettingsSectionChange}
muteRulePrefill={muteRulePrefill}
onMutePrefillConsumed={onMutePrefillConsumed}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
/>
);
}
@@ -186,6 +196,7 @@ export function ViewRouter({
<FleetView
onNavigateToNode={onFleetNavigateToNode}
onOpenSettingsSection={onOpenSettingsSection}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
fleetUpdatesIntent={fleetUpdatesIntent}
onFleetUpdatesIntentConsumed={onFleetUpdatesIntentConsumed}
fleetTab={fleetTab}
@@ -27,6 +27,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { StackMuteSubmenu } from '@/components/mute/MuteMenuItems';
import type { useStackMuteActions } from '@/hooks/useMuteRuleActions';
import { Sparkline } from '../ui/sparkline';
import { ImageSourceMenu } from '../ImageSourceMenu';
import { cn } from '@/lib/utils';
@@ -129,6 +131,7 @@ export interface StackIdentityHeaderProps {
rollbackStack: () => Promise<void>;
scanStackConfig: () => Promise<void>;
requestDeleteStack: () => void;
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
}
// Breadcrumb + serif title + state pill + image ref + action bar. The action
@@ -154,6 +157,7 @@ export function StackIdentityHeader({
rollbackStack,
scanStackConfig,
requestDeleteStack,
stackMuteActions,
}: StackIdentityHeaderProps) {
return (
<div className="flex flex-col gap-3">
@@ -228,8 +232,9 @@ export function StackIdentityHeader({
const canDelete = can('stack:delete', 'stack', stackName);
const canRollback = canDeploy && backupInfo.exists;
const canScan = trivy.available && isAdmin;
const canMute = stackMuteActions?.canMute ?? false;
const hasOverflowExtras = canRollback || canScan;
const hasOverflow = hasOverflowExtras || canDelete;
const hasOverflow = hasOverflowExtras || canDelete || canMute;
if (!canDeploy && !hasOverflow) return null;
return (
<div className="flex items-center gap-2 flex-wrap">
@@ -287,7 +292,8 @@ export function StackIdentityHeader({
{stackMisconfigScanning ? 'Scanning...' : 'Scan config'}
</DropdownMenuItem>
)}
{hasOverflowExtras && canDelete && <DropdownMenuSeparator />}
{stackMuteActions && <StackMuteSubmenu actions={stackMuteActions} />}
{(canRollback || canScan || stackMuteActions?.canMute) && canDelete && <DropdownMenuSeparator />}
{canDelete && (
<DropdownMenuItem
className="text-destructive focus:text-destructive focus:bg-destructive/10"
@@ -3,6 +3,10 @@ import { renderHook } from '@testing-library/react';
import { useSidebarContextMenu } from './useSidebarContextMenu';
import type { Node } from '@/context/NodeContext';
vi.mock('@/context/NodeContext', () => ({
useNodes: () => ({ hasCapability: () => false }),
}));
// buildMenuCtx derives canOpenApp from the active node plus the stack's
// published port; only the fields it reads need to be real, the handler
// closures are never invoked here.
@@ -2,6 +2,15 @@ import { useCallback } from 'react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { buildServiceUrl } from '@/lib/serviceUrl';
import {
createMuteRuleWithToast,
stackMuteAllDraft,
stackMuteDeploySuccessDraft,
stackMuteMonitorDraft,
labelMuteAllDraft,
labelMuteExternalDraft,
labelMuteLowPriorityDraft,
} from '@/lib/muteRules';
import type { StackMenuCtx } from '@/components/sidebar/sidebar-types';
import type { Label as StackLabel, LabelColor } from '../../label-types';
import type { OverlayState } from './useOverlayState';
@@ -9,6 +18,7 @@ import type { StackActionsHook } from './useStackActions';
import type { useStackListState } from './useStackListState';
import type { useViewNavigationState } from './useViewNavigationState';
import type { Node } from '@/context/NodeContext';
import { useNodes } from '@/context/NodeContext';
import type { PermissionAction } from '@/context/AuthContext';
type StackListState = ReturnType<typeof useStackListState>;
@@ -33,6 +43,7 @@ export function useSidebarContextMenu({
isAdmin,
can,
}: UseSidebarContextMenuOptions) {
const { hasCapability } = useNodes();
const buildMenuCtx = useCallback((file: string): StackMenuCtx => {
const sName = file.replace(/\.(yml|yaml)$/, '');
const mainPort = stackListState.stackPorts[file];
@@ -40,6 +51,8 @@ export function useSidebarContextMenu({
// lifecycle affordances; the menu's status union stays three-state.
const rawStatus = stackListState.stackStatuses[file] ?? 'unknown';
const stackStatus = rawStatus === 'partial' ? 'running' : rawStatus;
const nodeId = activeNode?.id ?? null;
const canMuteNotifications = isAdmin && hasCapability('notification-suppression');
return {
stackStatus,
// Only offer "Open App" when a browser-reachable URL can actually be built
@@ -125,6 +138,31 @@ export function useSidebarContextMenu({
navState.setSchedulePrefill({ stackName: sName, nodeId: activeNode?.id ?? null });
navState.setActiveView('scheduled-ops');
},
canMuteNotifications,
muteStackAll: () => {
void createMuteRuleWithToast(stackMuteAllDraft(sName, nodeId));
},
muteStackDeploySuccess: () => {
void createMuteRuleWithToast(stackMuteDeploySuccessDraft(sName, nodeId));
},
muteStackMonitor: () => {
void createMuteRuleWithToast(stackMuteMonitorDraft(sName, nodeId));
},
openStackMuteRules: () => {
navState.openMuteRulesWithPrefill(stackMuteAllDraft(sName, nodeId));
},
muteLabelAll: (labelId: number, labelName: string) => {
void createMuteRuleWithToast(labelMuteAllDraft(labelId, labelName, nodeId));
},
muteLabelExternal: (labelId: number, labelName: string) => {
void createMuteRuleWithToast(labelMuteExternalDraft(labelId, labelName, nodeId));
},
muteLabelLowPriority: (labelId: number, labelName: string) => {
void createMuteRuleWithToast(labelMuteLowPriorityDraft(labelId, labelName, nodeId));
},
openLabelMuteRules: (labelId: number, labelName: string) => {
navState.openMuteRulesWithPrefill(labelMuteAllDraft(labelId, labelName, nodeId));
},
};
// Handlers from useStackActions, useOverlayState, useViewNavigationState are
// useCallback-stabilized at their owner hooks, so listing the menu surface
@@ -134,7 +172,8 @@ export function useSidebarContextMenu({
}, [
stackListState.stackStatuses, stackListState.stackPorts, isAdmin,
stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap,
stackListState.pin, stackListState.unpin, activeNode?.type, activeNode?.api_url,
stackListState.pin, stackListState.unpin, activeNode?.type, activeNode?.api_url, activeNode?.id,
hasCapability, navState.openMuteRulesWithPrefill,
]);
return buildMenuCtx;
@@ -12,6 +12,7 @@ import type { SenchoNavigateDetail } from '@/components/NodeManager';
import type { SecurityTab, FleetTab } from '@/lib/events';
import type { SectionId } from '@/components/settings/types';
import type { ScheduleTaskPrefill } from '@/components/ScheduledOperationsView';
import type { MuteRuleDraft } from '@/lib/muteRules';
export type ActiveView =
| 'dashboard'
@@ -64,6 +65,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
const [fleetTab, setFleetTab] = useState<FleetTab | null>(null);
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(null);
const [muteRulePrefill, setMuteRulePrefill] = useState<MuteRuleDraft | null>(null);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const handleOpenSettings = useCallback((section?: SectionId) => {
@@ -73,6 +75,14 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
}, []);
const handlePrefillConsumed = useCallback(() => setSchedulePrefill(null), []);
const handleMutePrefillConsumed = useCallback(() => setMuteRulePrefill(null), []);
const openMuteRulesWithPrefill = useCallback((draft: MuteRuleDraft) => {
setMuteRulePrefill(draft);
setSettingsSection('notification-suppression');
setActiveView('settings');
setFilterNodeId(null);
}, []);
const handleNavigate = useCallback((value: string) => {
if (value === activeView) return;
@@ -166,9 +176,12 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
fleetTab, setFleetTab,
filterNodeId, setFilterNodeId,
schedulePrefill, setSchedulePrefill,
muteRulePrefill, setMuteRulePrefill,
mobileNavOpen, setMobileNavOpen,
handleOpenSettings,
handlePrefillConsumed,
handleMutePrefillConsumed,
openMuteRulesWithPrefill,
handleNavigate,
navItems,
} as const;
+21 -2
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import {
RefreshCw, Camera, FileDown,
Network, SlidersHorizontal,
Send, KeyRound, ArrowLeftRight, Wrench, Workflow,
Send, KeyRound, ArrowLeftRight, Wrench, Workflow, Tag,
} from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { FleetMasthead } from './fleet/FleetMasthead';
@@ -21,6 +21,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightI
import { springs } from '@/lib/motion';
import { useLicense } from '@/context/LicenseContext';
import { useAuth } from '@/context/AuthContext';
import { useNodes } from '@/context/NodeContext';
import { PaidGate } from './PaidGate';
import FleetSnapshots from './FleetSnapshots';
import { FleetConfiguration } from './fleet/FleetConfiguration';
@@ -30,14 +31,17 @@ import { DeploymentsTab } from './blueprints/DeploymentsTab';
import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab';
import { SecretsTab } from './fleet/secrets/SecretsTab';
import { DependencyMapTab } from './fleet/DependencyMapTab';
import { ContainerLabelsTab } from './fleet/ContainerLabelsTab';
import { useNodeActions } from './nodes/useNodeActions';
import type { FleetTab } from '@/lib/events';
import type { SectionId } from '@/components/settings/types';
import type { MuteRuleDraft } from '@/lib/muteRules';
interface FleetViewProps {
onNavigateToNode: (nodeId: number, stackName: string) => void;
/** Opens a Settings section (used to send "Add node" to Settings > Nodes). */
onOpenSettingsSection?: (section: SectionId) => void;
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null;
onFleetUpdatesIntentConsumed?: () => void;
/** Deep-link target tab (e.g. 'snapshots' from the stack storage warning). */
@@ -45,9 +49,11 @@ interface FleetViewProps {
onFleetTabConsumed?: () => void;
}
export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) {
export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteRulesWithPrefill, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) {
const { isPaid } = useLicense();
const { isAdmin } = useAuth();
const { hasCapability } = useNodes();
const containerLabelsEnabled = hasCapability('container-label-inventory');
const { prefs, updatePrefs } = useFleetPreferences();
const updateStatus = useFleetUpdateStatus();
@@ -131,6 +137,13 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdate
<Workflow className="w-4 h-4 mr-1.5" />Map
</TabsTrigger>
</TabsHighlightItem>
{containerLabelsEnabled && (
<TabsHighlightItem value="container-labels">
<TabsTrigger value="container-labels">
<Tag className="w-4 h-4 mr-1.5" />Docker Labels
</TabsTrigger>
</TabsHighlightItem>
)}
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
{isPaid && (
<TabsHighlightItem value="deployments">
@@ -234,6 +247,7 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdate
onCordonChange={() => { void overview.fetchOverview(true); }}
onEditNode={isAdmin ? openEdit : undefined}
onDeleteNode={isAdmin ? openDelete : undefined}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
onAddNode={isAdmin && onOpenSettingsSection ? () => onOpenSettingsSection('nodes') : undefined}
onCheckUpdates={updateStatus.checkUpdates}
checkingUpdates={updateStatus.checkingUpdates}
@@ -255,6 +269,11 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdate
<TabsContent value="dependencies">
<DependencyMapTab />
</TabsContent>
{containerLabelsEnabled && (
<TabsContent value="container-labels">
<ContainerLabelsTab onNavigateToNode={onNavigateToNode} />
</TabsContent>
)}
{isPaid && (
<TabsContent value="deployments">
<DeploymentsTab />
+12 -2
View File
@@ -14,6 +14,9 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { NodeMuteSubmenu } from '@/components/mute/MuteMenuItems';
import { useNodeMuteActions } from '@/hooks/useMuteRuleActions';
import type { MuteRuleDraft } from '@/lib/muteRules';
import { formatBytes } from '@/lib/utils';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
@@ -42,6 +45,7 @@ export interface NodeCardProps {
onCordonChange?: () => void;
onEdit?: (node: Node) => void;
onDelete?: (node: Node) => void;
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
}
// --- Sub-Components ---
@@ -59,7 +63,7 @@ function UsageBar({ percent, color }: { percent: number; color: string }) {
// --- Main Export ---
export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete }: NodeCardProps) {
export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete, onOpenMuteRulesWithPrefill }: NodeCardProps) {
const [expanded, setExpanded] = useState(false);
const [stacks, setStacks] = useState<string[] | null>(node.stacks);
const [loadingStacks, setLoadingStacks] = useState(false);
@@ -77,7 +81,12 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
// (requirePermission('node:manage','node',id) + requirePaid). Gating on tier
// alone would surface the control to deployer/viewer/auditor users whose calls 403.
const canCordon = isPaid && can('node:manage', 'node', String(node.id));
const showMenu = canEdit || canDelete || canCordon;
const nodeMuteActions = useNodeMuteActions(
node.id,
node.name,
onOpenMuteRulesWithPrefill ?? (() => {}),
);
const showMenu = canEdit || canDelete || canCordon || (nodeMuteActions.canMute && Boolean(onOpenMuteRulesWithPrefill));
const isOnline = node.status === 'online';
const isLocal = node.type === 'local';
@@ -182,6 +191,7 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
{node.cordoned ? 'Uncordon node' : 'Cordon node'}
</DropdownMenuItem>
)}
{onOpenMuteRulesWithPrefill && <NodeMuteSubmenu actions={nodeMuteActions} />}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -8,6 +8,7 @@ import type { FleetTopologyNode, LayoutMode, SavedPositions } from '@/lib/fleet-
import type { Label as StackLabel } from '../label-types';
import type { Node } from '@/context/NodeContext';
import type { FleetNode, NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './types';
import type { MuteRuleDraft } from '@/lib/muteRules';
interface OverviewTabProps {
loading: boolean;
@@ -35,6 +36,7 @@ interface OverviewTabProps {
onCordonChange?: () => void;
onEditNode?: (node: Node) => void;
onDeleteNode?: (node: Node) => void;
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
topologyMode: LayoutMode;
onTopologyModeChange: (mode: LayoutMode) => void;
topologyPositions: SavedPositions;
@@ -70,6 +72,7 @@ export function OverviewTab({
onCordonChange,
onEditNode,
onDeleteNode,
onOpenMuteRulesWithPrefill,
topologyMode,
onTopologyModeChange,
topologyPositions,
@@ -149,6 +152,7 @@ export function OverviewTab({
onCordonChange={onCordonChange}
onEdit={onEditNode}
onDelete={onDeleteNode}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
/>
))}
</div>
@@ -34,7 +34,7 @@ function baseProps(node: FleetNode) {
}
beforeEach(() => {
useNodesMock.mockReturnValue({ nodes: [] });
useNodesMock.mockReturnValue({ nodes: [], hasCapability: vi.fn(() => false) });
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
useLicenseMock.mockReturnValue({ isPaid: false });
});
+64 -12
View File
@@ -9,6 +9,7 @@ import {
Trash2,
SlidersHorizontal,
CheckCheck,
MoreHorizontal,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -22,10 +23,18 @@ import {
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import type { NotificationCategory, NotificationItem } from './dashboard/types';
import type { Node } from '@/context/NodeContext';
import { createMuteFromNotification } from '@/lib/muteRules';
import { useAuth } from '@/context/AuthContext';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { CATEGORY_LABELS } from '@/lib/notificationCategories';
import { countVisibleUnread, filterPanelVisible } from '@/lib/notificationVisibility';
import type { NotificationCategory, NotificationItem } from './dashboard/types';
import type { Node } from '@/context/NodeContext';
const NODE_FILTER_ALL = 'all' as const;
const CATEGORY_FILTER_ALL = 'all' as const;
@@ -128,6 +137,7 @@ export function NotificationPanel({
onNavigate,
onNavigateChangelog,
}: NotificationPanelProps) {
const { isAdmin } = useAuth();
const [filter, setFilter] = useState<NotifFilter>('all');
const [nodeFilter, setNodeFilter] = useState<NodeFilter>(NODE_FILTER_ALL);
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>(CATEGORY_FILTER_ALL);
@@ -369,6 +379,7 @@ export function NotificationPanel({
onDelete={onDelete}
onNavigate={onNavigate ? handleNavigate : undefined}
onNavigateChangelog={onNavigateChangelog}
canMute={isAdmin}
/>
))}
</div>
@@ -386,9 +397,17 @@ interface NotificationRowProps {
onDelete: (notif: NotificationItem) => void;
onNavigate?: (notif: NotificationItem) => void;
onNavigateChangelog?: (notif: NotificationItem) => void;
canMute?: boolean;
}
function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigateChangelog }: NotificationRowProps) {
async function createQuickSuppressionRule(
notif: NotificationItem,
mode: 'category' | 'similar' | 'stack',
): Promise<void> {
await createMuteFromNotification(notif, mode);
}
function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigateChangelog, canMute }: NotificationRowProps) {
const config = LEVEL_CONFIG[notif.level];
const Icon = config.icon;
const isUnread = !notif.is_read;
@@ -470,15 +489,48 @@ function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigate
) : (
<div className={surfaceClasses}>{content}</div>
)}
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-2 z-20 h-6 w-6 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
onClick={() => onDelete(notif)}
title="Dismiss"
>
<X className="h-3 w-3" strokeWidth={1.5} />
</Button>
<div className="absolute right-2 top-2 z-20 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{canMute ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="Mute options"
aria-label="Mute options"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3 w-3" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
{notif.category ? (
<DropdownMenuItem onClick={() => void createQuickSuppressionRule(notif, 'category')}>
Mute this category
</DropdownMenuItem>
) : null}
<DropdownMenuItem onClick={() => void createQuickSuppressionRule(notif, 'similar')}>
Mute notifications like this
</DropdownMenuItem>
{notif.stack_name ? (
<DropdownMenuItem onClick={() => void createQuickSuppressionRule(notif, 'stack')}>
Mute this stack
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => onDelete(notif)}
title="Dismiss"
>
<X className="h-3 w-3" strokeWidth={1.5} />
</Button>
</div>
</div>
);
}
@@ -14,6 +14,7 @@ import { toast } from '@/components/ui/toast-store';
import { copyToClipboard } from '@/lib/clipboard';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { apiFetch, fetchForNode } from '@/lib/api';
import { excludeLikelySenchoContainers } from '@/lib/senchoContainerFilter';
import { Combobox } from '@/components/ui/combobox';
import { SegmentedControl } from '@/components/ui/segmented-control';
import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling';
@@ -48,6 +49,18 @@ const DEFAULT_SIMPLE_SCHEDULE: SimpleSchedule = {
const TIMELINE_WINDOW_HOURS = 24;
const TIMELINE_WINDOW_MS = TIMELINE_WINDOW_HOURS * 60 * 60 * 1000;
interface ContainerListItem {
Id: string;
Names?: string[];
State?: string;
Image?: string;
Labels?: Record<string, string>;
}
function containerDisplayName(c: ContainerListItem): string {
return c.Names?.[0]?.replace(/^\//, '') || c.Id.slice(0, 12);
}
function formatHourTick(ts: number): string {
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
@@ -108,8 +121,9 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
const [runsTotal, setRunsTotal] = useState(0);
const runsLimit = 20;
// Available stacks and nodes for selection
// Available stacks, containers, and nodes for selection
const [stacks, setStacks] = useState<string[]>([]);
const [containers, setContainers] = useState<ContainerListItem[]>([]);
const [nodes, setNodes] = useState<NodeOption[]>([]);
const filteredTasks = filterNodeId != null
@@ -148,6 +162,20 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
}
}, []);
const fetchContainers = useCallback(async (nodeId: string) => {
try {
const res = await fetchForNode('/containers?all=true', parseInt(nodeId, 10));
if (res.ok) {
const rows = (await res.json()) as ContainerListItem[];
setContainers(excludeLikelySenchoContainers(rows));
} else {
setContainers([]);
}
} catch {
setContainers([]);
}
}, []);
const fetchNodes = useCallback(async () => {
try {
const res = await apiFetch('/nodes', { localOnly: true });
@@ -194,7 +222,11 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
? await fetchForNode(endpoint, parseInt(formNodeId, 10))
: await apiFetch(endpoint);
if (res.ok && !cancelled) {
setAvailableServices(await res.json());
const services = (await res.json()) as string[];
setAvailableServices(services);
if (services.length <= 1) {
setFormTargetServices([]);
}
}
} catch {
// Non-critical
@@ -204,17 +236,20 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
return () => { cancelled = true; };
}, [formAction, formTargetId, formNodeId]);
// Re-fetch stacks when the node changes. Clearing a stale stack selection is
// done in the Node picker's onValueChange (a user-driven change), not here, so
// a prefilled or edited node keeps its stack instead of being wiped on open.
useEffect(() => {
if (!dialogOpen) return;
if (formNodeId) {
const actionDef = getActionById(formAction);
if (actionDef?.requiresContainer && formNodeId) {
fetchContainers(formNodeId);
fetchStacks(formNodeId);
} else if (formNodeId) {
fetchStacks(formNodeId);
setContainers([]);
} else {
setStacks([]);
setContainers([]);
}
}, [formNodeId, dialogOpen, fetchStacks]);
}, [formNodeId, formAction, dialogOpen, fetchStacks, fetchContainers]);
const openCreate = (prefillData?: { stackName: string; nodeId: string }) => {
const nodeId = prefillData?.nodeId ?? (filterNodeId != null ? String(filterNodeId) : '');
@@ -301,7 +336,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
enabled: formEnabled,
delete_after_run: formDeleteAfterRun,
run_at: runAt,
target_id: actionDef.requiresStack ? formTargetId : null,
target_id: (actionDef.requiresStack || actionDef.requiresContainer) ? formTargetId : null,
node_id: actionDef.requiresNode && formNodeId ? parseInt(formNodeId, 10) : null,
prune_targets: formAction === 'prune' && formPruneTargets.length > 0 ? formPruneTargets : null,
target_services: actionDef.supportsServiceSelection && formTargetServices.length > 0 ? formTargetServices : null,
@@ -455,13 +490,31 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
[nodes],
);
const currentNodeOptions = currentAction?.nodeScope === 'local' ? localNodeOptions : nodeOptions;
const containerOptions = useMemo(
() => containers.map(c => {
const name = containerDisplayName(c);
const state = c.State ?? 'unknown';
const image = (c.Image ?? '').split('@')[0];
return { value: name, label: `${name} · ${state} · ${image}` };
}),
[containers],
);
const selectedContainer = useMemo(
() => containers.find(c => containerDisplayName(c) === formTargetId),
[containers, formTargetId],
);
const selectedContainerStack = selectedContainer?.Labels?.['com.docker.compose.project'];
const isUnmanagedContainer = !!selectedContainer && (
!selectedContainerStack || !stacks.includes(selectedContainerStack)
);
const scheduleInvalid = scheduleMode === 'simple'
? !!simpleCronError
: (!formCron || !!cronFieldError);
const isSaveDisabled =
saving || !currentAction || !formName || scheduleInvalid
|| (!!currentAction?.requiresStack && (!formTargetId || !formNodeId))
|| (!!currentAction?.requiresNode && !currentAction.requiresStack && !formNodeId)
|| (!!currentAction?.requiresContainer && (!formTargetId || !formNodeId))
|| (!!currentAction?.requiresNode && !currentAction.requiresStack && !currentAction.requiresContainer && !formNodeId)
|| (formAction === 'prune' && formPruneTargets.length === 0);
const windowEnd = now + TIMELINE_WINDOW_MS;
@@ -689,6 +742,8 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
? task.target_services
? `${task.target_id} (${(JSON.parse(task.target_services) as string[]).join(', ')})`
: task.target_id
: task.target_type === 'container'
? task.target_id
: task.action === 'update'
? 'All eligible stacks'
: task.target_type}
@@ -817,6 +872,40 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
)}
</div>
{currentAction?.requiresContainer && (
<>
<div className="space-y-2">
<Label>Node</Label>
<Combobox
options={nodeOptions}
value={formNodeId}
onValueChange={(val) => { setFormNodeId(val); setFormTargetId(''); }}
placeholder="Select node..."
/>
</div>
<div className="space-y-2">
<Label>Container</Label>
<Combobox
options={containerOptions}
value={formTargetId}
onValueChange={(val) => { setFormTargetId(val); setFormTargetServices([]); }}
placeholder={formNodeId ? 'Select container...' : 'Select a node first'}
disabled={!formNodeId}
/>
</div>
{isUnmanagedContainer && (
<p className="text-xs text-muted-foreground">
This container is not associated with a Sencho stack. The schedule will target the container by node and name.
</p>
)}
{selectedContainerStack && stacks.includes(selectedContainerStack) && (
<p className="text-xs text-muted-foreground">
Part of stack: {selectedContainerStack}
</p>
)}
</>
)}
{currentAction?.requiresStack && (
<>
<div className="space-y-2">
@@ -833,12 +922,12 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
<Combobox
options={stacks.map(s => ({ value: s, label: s }))}
value={formTargetId}
onValueChange={setFormTargetId}
onValueChange={(val) => { setFormTargetId(val); setFormTargetServices([]); }}
placeholder={formNodeId ? "Select stack..." : "Select a node first"}
disabled={!formNodeId}
/>
</div>
{currentAction.supportsServiceSelection && formTargetId && availableServices.length > 0 && (
{currentAction.supportsServiceSelection && formTargetId && availableServices.length > 1 && (
<div className="space-y-2">
<Label>Services <span className="text-xs text-muted-foreground">(leave empty for all)</span></Label>
<div className="grid grid-cols-2 gap-2">
@@ -871,7 +960,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
</div>
)}
{currentAction?.requiresNode && !currentAction.requiresStack && (
{currentAction?.requiresNode && !currentAction.requiresStack && !currentAction.requiresContainer && (
<div className="space-y-2">
<Label>Node</Label>
<Combobox
@@ -955,7 +1044,12 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
<div className="flex items-center gap-2">
<TogglePill checked={formEnabled} onChange={setFormEnabled} id="task-enabled" />
<Label htmlFor="task-enabled">Enabled</Label>
<label
htmlFor="task-enabled"
className="text-sm text-stat-value cursor-pointer select-none"
>
{formEnabled ? 'Enabled' : 'Disabled'}
</label>
</div>
<label className="flex items-start gap-2 cursor-pointer">
+30 -21
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
AlertDialog,
AlertDialogAction,
@@ -738,13 +739,13 @@ function PolicyRow({ policy, onDelete, onToggle, deleting, saving, isAdmin }: Po
};
return (
<div className="flex flex-col gap-0 border-b border-card-border/40 last:border-b-0 text-sm py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="font-mono text-foreground truncate">
<div className="flex flex-col gap-0 border-b border-card-border/40 last:border-b-0 text-sm py-2 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="font-mono text-foreground break-words">
{policy.service_name ?? <span className="text-muted-foreground font-sans">All services</span>}
</span>
<span className="text-muted-foreground text-xs">
<span className="text-muted-foreground text-xs break-words">
Unhealthy for {policy.unhealthy_duration_mins} min
&bull; Cooldown: {policy.cooldown_mins} min
&bull; Max {policy.max_restarts_per_hour}/hr
@@ -798,27 +799,35 @@ function PolicyRow({ policy, onDelete, onToggle, deleting, saving, isAdmin }: Po
</div>
{historyOpen && (
<div className="border-t border-card-border/40 mt-2 pt-2 space-y-1.5">
<div className="border-t border-card-border/40 mt-2 pt-2 min-w-0">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">Recent activity</p>
{history.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-2">No history yet.</p>
) : (
history.map((entry) => (
<div key={entry.id} className="flex items-start gap-2 text-xs">
<span className="text-muted-foreground shrink-0 tabular-nums font-mono">
{new Date(entry.timestamp).toLocaleString()}
</span>
<span className="font-mono text-foreground shrink-0 truncate max-w-[100px]">
{entry.container_name}
</span>
<span className={`shrink-0 font-medium ${actionColorClass(entry.action)}`}>
{actionLabel(entry.action)}
</span>
<span className="text-muted-foreground truncate">
{entry.reason}
</span>
<ScrollArea className="h-[200px] pr-2">
<div className="space-y-1.5">
{history.map((entry) => (
<div key={entry.id} className="py-1">
<div className="flex items-baseline justify-between gap-2 text-xs">
<span className={`font-medium ${actionColorClass(entry.action)}`}>
{actionLabel(entry.action)}
</span>
<span className="text-muted-foreground shrink-0 tabular-nums font-mono text-[10px]">
{new Date(entry.timestamp).toLocaleString()}
</span>
</div>
<div className="mt-0.5 min-w-0 font-mono text-[11px] text-foreground break-all">
{entry.container_name}
</div>
{entry.reason ? (
<div className="mt-0.5 text-[11px] text-muted-foreground break-words">
{entry.reason}
</div>
) : null}
</div>
))}
</div>
))
</ScrollArea>
)}
</div>
)}
@@ -15,9 +15,12 @@ import DriftPanel from './stack/DriftPanel';
import PreflightPanel from './stack/PreflightPanel';
import StoragePanel from './stack/StoragePanel';
import EnvironmentPanel from './stack/EnvironmentPanel';
import ComposeLabelsPanel from './stack/ComposeLabelsPanel';
import StackNetworkingPanel from './stack/StackNetworkingPanel';
import { useNodes } from '@/context/NodeContext';
import type { NotificationItem } from '@/components/dashboard/types';
import { ActivityMuteKebab } from '@/components/mute/MuteMenuItems';
import type { useStackMuteActions } from '@/hooks/useMuteRuleActions';
interface StackAnatomyPanelProps {
stackName: string;
@@ -32,6 +35,7 @@ interface StackAnatomyPanelProps {
canEdit: boolean;
applying?: boolean;
notifications?: NotificationItem[];
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
}
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
@@ -82,6 +86,7 @@ export default function StackAnatomyPanel({
canEdit,
applying = false,
notifications,
stackMuteActions,
}: StackAnatomyPanelProps) {
const anatomy = useMemo(() => parseAnatomy(content), [content]);
const envKeys = useMemo(() => parseEnvKeys(envContent), [envContent]);
@@ -97,6 +102,7 @@ export default function StackAnatomyPanel({
const networkingEnabled = hasCapability('compose-networking');
const storageEnabled = hasCapability('compose-storage');
const envInventoryEnabled = hasCapability('env-inventory');
const composeLabelsEnabled = hasCapability('container-label-inventory');
const [gitSource, setGitSource] = useState<{ stack: string; info: GitSourceInfo; multiFile: boolean } | null>(null);
// Merged effective facts (services/ports/volumes/networks/restart) for a
@@ -366,6 +372,9 @@ export default function StackAnatomyPanel({
{envInventoryEnabled && (
<TabsTrigger value="environment" data-testid="environment-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Environment</TabsTrigger>
)}
{composeLabelsEnabled && (
<TabsTrigger value="compose-labels" data-testid="compose-labels-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Compose Labels</TabsTrigger>
)}
{networkingEnabled && (
<TabsTrigger value="networking" data-testid="networking-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Networking</TabsTrigger>
)}
@@ -409,6 +418,7 @@ export default function StackAnatomyPanel({
edit
</button>
)}
{stackMuteActions && <ActivityMuteKebab actions={stackMuteActions} />}
</div>
</div>
<TabsContent value="activity" className="flex-1 min-h-0 overflow-y-auto px-3 mt-0">
@@ -618,6 +628,11 @@ export default function StackAnatomyPanel({
<EnvironmentPanel stackName={stackName} />
</TabsContent>
)}
{composeLabelsEnabled && (
<TabsContent value="compose-labels" className="flex flex-col flex-1 min-h-0 mt-0">
<ComposeLabelsPanel stackName={stackName} />
</TabsContent>
)}
{doctorEnabled && (
<TabsContent value="doctor" className="flex flex-col flex-1 min-h-0 mt-0">
<PreflightPanel stackName={stackName} />
@@ -78,6 +78,20 @@ describe('DeployFeedbackModal Inline vs Modal style', () => {
expect(onMinimize).not.toHaveBeenCalled();
});
it('inline style: succeeded state shows no auto-close countdown label', () => {
mockStyle = 'inline';
mockPanelState = panel({ status: 'succeeded' });
render(<DeployFeedbackModal isMinimized={false} onMinimize={onMinimize} />);
expect(screen.queryByText(/closes in/i)).toBeNull();
});
it('modal style: succeeded state shows auto-close countdown label', () => {
mockStyle = 'modal';
mockPanelState = panel({ status: 'succeeded' });
render(<DeployFeedbackModal isMinimized={false} onMinimize={onMinimize} />);
expect(screen.getByText(/closes in/i)).toBeInTheDocument();
});
it('modal style owns the live terminal; inline style renders no terminal (single socket)', () => {
mockStyle = 'modal';
const view = render(<DeployFeedbackModal isMinimized={false} onMinimize={onMinimize} />);
@@ -6,7 +6,7 @@
* hub-local endpoint.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ScheduledTask } from '@/types/scheduling';
@@ -338,10 +338,42 @@ describe('ScheduledOperationsView', () => {
);
});
it('hides service checkboxes when the stack has only one service', async () => {
mockedFetchForNode.mockImplementation(async (url: string) => {
if (url.endsWith('/services')) return jsonResponse(['mariadb']);
return jsonResponse(['db-compose']);
});
render(<ScheduledOperationsView />);
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
await userEvent.click(screen.getAllByRole('combobox')[1]);
await userEvent.click(await screen.findByRole('button', { name: 'edge' }));
await userEvent.click(screen.getAllByRole('combobox')[2]);
await userEvent.click(await screen.findByRole('button', { name: 'db-compose' }));
await waitFor(() =>
expect(mockedFetchForNode).toHaveBeenCalledWith('/stacks/db-compose/services', 2),
);
expect(screen.queryByText(/^Services/)).not.toBeInTheDocument();
});
it('shows service checkboxes when the stack has multiple services', async () => {
render(<ScheduledOperationsView />);
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
await userEvent.click(screen.getAllByRole('combobox')[1]);
await userEvent.click(await screen.findByRole('button', { name: 'edge' }));
await userEvent.click(screen.getAllByRole('combobox')[2]);
await userEvent.click(await screen.findByRole('button', { name: 'web' }));
const servicesBlock = (await screen.findByText(/^Services/)).closest<HTMLElement>('.space-y-2');
expect(within(servicesBlock!).getAllByRole('checkbox')).toHaveLength(2);
});
it('renders the five registry category lanes in the timeline view', async () => {
render(<ScheduledOperationsView />);
// Timeline is the default view; the lane track always renders.
for (const lane of ['Stack lifecycle', 'Updates', 'Security', 'Maintenance', 'Backups']) {
for (const lane of ['Lifecycle', 'Updates', 'Security', 'Maintenance', 'Backups']) {
expect(await screen.findByText(lane)).toBeInTheDocument();
}
});
@@ -425,6 +457,54 @@ describe('ScheduledOperationsView', () => {
await selectAction('Prune Node Resources');
expect(screen.getByText('Prune Targets')).toBeInTheDocument();
expect(screen.getByText('Node')).toBeInTheDocument();
// Container action: Node + Container, no Stack.
await selectAction('Restart Container');
expect(screen.getByText('Node')).toBeInTheDocument();
expect(screen.getByText('Container')).toBeInTheDocument();
expect(screen.queryByText('Stack')).not.toBeInTheDocument();
});
it('emits container target payload for a container restart save', async () => {
mockedFetchForNode.mockImplementation(async (url: string) => {
if (url === '/stacks') return jsonResponse(['web']);
if (url.startsWith('/containers')) {
return jsonResponse([
{ Id: 'abc', Names: ['/watchtower'], State: 'running', Image: 'containrrr/watchtower' },
]);
}
return jsonResponse([]);
});
render(<ScheduledOperationsView />);
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'daily-watchtower');
await userEvent.click(screen.getAllByRole('combobox')[0]);
await userEvent.click(await screen.findByRole('button', { name: 'Restart Container' }));
await userEvent.click(screen.getAllByRole('combobox')[1]);
await userEvent.click(await screen.findByRole('button', { name: 'hub' }));
await userEvent.click(screen.getAllByRole('combobox')[2]);
await userEvent.click(await screen.findByRole('button', { name: /watchtower/ }));
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
const postCall = mockedFetch.mock.calls.find(
([url, opts]) => url === '/scheduled-tasks' && opts?.method === 'POST',
);
expect(postCall).toBeTruthy();
const body = JSON.parse(postCall![1].body);
expect(body).toMatchObject({
name: 'daily-watchtower',
target_type: 'container',
action: 'restart',
target_id: 'watchtower',
node_id: 1,
});
});
});
it('emits node_id and target_id for a stack update save', async () => {
@@ -54,6 +54,11 @@ export type NotificationCategory =
| 'autoheal_triggered'
| 'monitor_alert'
| 'scan_finding'
| 'drift_detected'
| 'drift_resolved'
| 'update_started'
| 'health_gate_passed'
| 'health_gate_failed'
| 'node_update_available'
| 'system';

Some files were not shown because too many files have changed in this diff Show More