feat(settings): surface security, notifications, and app store on remote nodes (#716)

Flip Security (Trivy), Notifications (agents + history), and App Store from
global-and-hidden-on-remote to node-scoped so operators can manage them when a
remote node is selected in the node picker. The primary instance proxies the
calls to each remote, which resolves the correct per-instance binary state,
agent config, and template registry.

Backend: key `agents` and `notification_history` by `node_id` with idempotent
column-add migrations and a `(node_id, type)` unique index on agents, matching
the Labels pattern. Thread `req.nodeId` through the /api/agents and
/api/notifications routes. Internal NotificationService and ImageUpdateService
writes resolve the middleware default via `NodeRegistry.getDefaultNodeId()` so
monitor-emitted rows share a bucket with user-facing ones (avoids split-brain
where the UI sees test notifications but not internal alerts).

Frontend: split Security on remote to render only the scanner card and hide
scan policies and CVE suppressions (those remain control-plane-only). Drop the
misleading "Always Local" badge on Developer since retention windows govern
backend jobs, not UI state. Flip the App Store registry to node-scoped.

Docs: add a "What Settings apply per node" table to multi-node, clarify
remote alert setup in alerts-notifications, and note Trivy's per-host install
in vulnerability-scanning.
This commit is contained in:
Anso
2026-04-20 21:04:09 -04:00
committed by GitHub
parent a42cc5bf03
commit 08f57c7141
14 changed files with 172 additions and 95 deletions
@@ -170,12 +170,12 @@ describe('DatabaseService - cleanupOldNotifications', () => {
const oldTimestamp = Date.now() - 60 * 24 * 60 * 60 * 1000; // 60 days ago const oldTimestamp = Date.now() - 60 * 24 * 60 * 60 * 1000; // 60 days ago
const recentTimestamp = Date.now() - 1 * 24 * 60 * 60 * 1000; // 1 day ago const recentTimestamp = Date.now() - 1 * 24 * 60 * 60 * 1000; // 1 day ago
db.addNotificationHistory({ level: 'info', message: 'old notification', timestamp: oldTimestamp }); db.addNotificationHistory(0, { level: 'info', message: 'old notification', timestamp: oldTimestamp });
db.addNotificationHistory({ level: 'info', message: 'recent notification', timestamp: recentTimestamp }); db.addNotificationHistory(0, { level: 'info', message: 'recent notification', timestamp: recentTimestamp });
db.cleanupOldNotifications(30); db.cleanupOldNotifications(30);
const history = db.getNotificationHistory(200); const history = db.getNotificationHistory(0, 200);
const old = history.find((n: any) => n.message === 'old notification'); const old = history.find((n: any) => n.message === 'old notification');
const recent = history.find((n: any) => n.message === 'recent notification'); const recent = history.find((n: any) => n.message === 'recent notification');
expect(old).toBeUndefined(); expect(old).toBeUndefined();
@@ -223,7 +223,7 @@ describe('DatabaseService - notification history cap', () => {
it('auto-prunes to 100 entries when adding notifications', () => { it('auto-prunes to 100 entries when adding notifications', () => {
// Insert 105 notifications // Insert 105 notifications
for (let i = 0; i < 105; i++) { for (let i = 0; i < 105; i++) {
db.addNotificationHistory({ db.addNotificationHistory(0, {
level: 'info', level: 'info',
message: `cap-test-${i}`, message: `cap-test-${i}`,
timestamp: Date.now() + i, timestamp: Date.now() + i,
@@ -231,23 +231,23 @@ describe('DatabaseService - notification history cap', () => {
} }
// The table should have at most 100 rows // The table should have at most 100 rows
const all = db.getNotificationHistory(200); const all = db.getNotificationHistory(0, 200);
expect(all.length).toBeLessThanOrEqual(100); expect(all.length).toBeLessThanOrEqual(100);
}); });
it('keeps the most recent entries after pruning', () => { it('keeps the most recent entries after pruning', () => {
// Clear all first // Clear all first
db.deleteAllNotifications(); db.deleteAllNotifications(0);
for (let i = 0; i < 105; i++) { for (let i = 0; i < 105; i++) {
db.addNotificationHistory({ db.addNotificationHistory(0, {
level: 'info', level: 'info',
message: `order-test-${i}`, message: `order-test-${i}`,
timestamp: Date.now() + i * 10, timestamp: Date.now() + i * 10,
}); });
} }
const all = db.getNotificationHistory(200); const all = db.getNotificationHistory(0, 200);
// The newest entries should survive (ordered DESC by timestamp) // The newest entries should survive (ordered DESC by timestamp)
expect(all[0].message).toContain('order-test-'); expect(all[0].message).toContain('order-test-');
// The oldest entries (0-4) should have been pruned // The oldest entries (0-4) should have been pruned
@@ -81,6 +81,7 @@ vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: { NodeRegistry: {
getInstance: () => ({ getInstance: () => ({
getComposeDir: () => '/tmp/compose', getComposeDir: () => '/tmp/compose',
getDefaultNodeId: () => 1,
}), }),
}, },
})); }));
@@ -436,7 +437,7 @@ services:
await (service as any).checkNode(1, 'local', fakeDb()); await (service as any).checkNode(1, 'local', fakeDb());
expect(mockAddNotificationHistory).toHaveBeenCalledWith(expect.objectContaining({ expect(mockAddNotificationHistory).toHaveBeenCalledWith(1, expect.objectContaining({
level: 'error', level: 'error',
message: expect.stringContaining('webhook timeout'), message: expect.stringContaining('webhook timeout'),
})); }));
@@ -35,6 +35,17 @@ vi.mock('../services/DatabaseService', () => ({
}, },
})); }));
// NodeRegistry is consulted to resolve this instance's default node id so
// internal dispatch writes land on the same key the middleware sets for user
// requests. Mock it to a fixed id so assertions are deterministic.
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getDefaultNodeId: () => 1,
}),
},
}));
// Spy on global fetch for webhook dispatch verification // Spy on global fetch for webhook dispatch verification
const mockFetch = vi.fn().mockResolvedValue({ ok: true }); const mockFetch = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal('fetch', mockFetch); vi.stubGlobal('fetch', mockFetch);
@@ -210,7 +221,7 @@ describe('NotificationService - routing logic', () => {
await svc.dispatchAlert('info', 'Should be logged'); await svc.dispatchAlert('info', 'Should be logged');
expect(mockAddNotificationHistory).toHaveBeenCalledWith({ expect(mockAddNotificationHistory).toHaveBeenCalledWith(1, {
level: 'info', level: 'info',
message: 'Should be logged', message: 'Should be logged',
timestamp: expect.any(Number), timestamp: expect.any(Number),
@@ -225,7 +236,7 @@ describe('NotificationService - routing logic', () => {
await svc.dispatchAlert('warning', 'Restarted', 'my-app', 'my-app-web-1'); await svc.dispatchAlert('warning', 'Restarted', 'my-app', 'my-app-web-1');
expect(mockAddNotificationHistory).toHaveBeenCalledWith({ expect(mockAddNotificationHistory).toHaveBeenCalledWith(1, {
level: 'warning', level: 'warning',
message: 'Restarted', message: 'Restarted',
timestamp: expect.any(Number), timestamp: expect.any(Number),
+12 -6
View File
@@ -5572,7 +5572,8 @@ function validateHttpsUrl(value: unknown): string | null {
app.get('/api/agents', authMiddleware, async (req: Request, res: Response) => { app.get('/api/agents', authMiddleware, async (req: Request, res: Response) => {
try { try {
const agents = DatabaseService.getInstance().getAgents(); const nodeId = req.nodeId ?? 0;
const agents = DatabaseService.getInstance().getAgents(nodeId);
res.json(agents); res.json(agents);
} catch (error) { } catch (error) {
console.error('Failed to fetch agents:', error); console.error('Failed to fetch agents:', error);
@@ -5594,7 +5595,8 @@ app.post('/api/agents', authMiddleware, async (req: Request, res: Response) => {
res.status(400).json({ error: 'enabled must be a boolean' }); res.status(400).json({ error: 'enabled must be a boolean' });
return; return;
} }
DatabaseService.getInstance().upsertAgent({ type, url, enabled }); const nodeId = req.nodeId ?? 0;
DatabaseService.getInstance().upsertAgent(nodeId, { type, url, enabled });
console.log(`[Agents] Agent ${type} updated`); console.log(`[Agents] Agent ${type} updated`);
if (isDebugEnabled()) console.log(`[Agents:diag] Agent ${type} upsert: enabled=${enabled}`); if (isDebugEnabled()) console.log(`[Agents:diag] Agent ${type} upsert: enabled=${enabled}`);
res.json({ success: true }); res.json({ success: true });
@@ -5846,7 +5848,8 @@ app.get('/api/auto-heal/policies/:id/history', authMiddleware, (req: Request, re
app.get('/api/notifications', authMiddleware, async (req: Request, res: Response) => { app.get('/api/notifications', authMiddleware, async (req: Request, res: Response) => {
try { try {
const history = DatabaseService.getInstance().getNotificationHistory(); const nodeId = req.nodeId ?? 0;
const history = DatabaseService.getInstance().getNotificationHistory(nodeId);
res.json(history); res.json(history);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch notifications' }); res.status(500).json({ error: 'Failed to fetch notifications' });
@@ -5855,7 +5858,8 @@ app.get('/api/notifications', authMiddleware, async (req: Request, res: Response
app.post('/api/notifications/read', authMiddleware, async (req: Request, res: Response) => { app.post('/api/notifications/read', authMiddleware, async (req: Request, res: Response) => {
try { try {
DatabaseService.getInstance().markAllNotificationsRead(); const nodeId = req.nodeId ?? 0;
DatabaseService.getInstance().markAllNotificationsRead(nodeId);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to mark notifications read' }); res.status(500).json({ error: 'Failed to mark notifications read' });
@@ -5866,7 +5870,8 @@ app.delete('/api/notifications/:id', authMiddleware, async (req: Request, res: R
try { try {
const id = parseInt(req.params.id as string, 10); const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid notification ID' }); return; } if (isNaN(id)) { res.status(400).json({ error: 'Invalid notification ID' }); return; }
DatabaseService.getInstance().deleteNotification(id); const nodeId = req.nodeId ?? 0;
DatabaseService.getInstance().deleteNotification(nodeId, id);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to delete notification' }); res.status(500).json({ error: 'Failed to delete notification' });
@@ -5875,7 +5880,8 @@ app.delete('/api/notifications/:id', authMiddleware, async (req: Request, res: R
app.delete('/api/notifications', authMiddleware, async (req: Request, res: Response) => { app.delete('/api/notifications', authMiddleware, async (req: Request, res: Response) => {
try { try {
DatabaseService.getInstance().deleteAllNotifications(); const nodeId = req.nodeId ?? 0;
DatabaseService.getInstance().deleteAllNotifications(nodeId);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to clear notifications' }); res.status(500).json({ error: 'Failed to clear notifications' });
+61 -30
View File
@@ -449,6 +449,7 @@ export class DatabaseService {
this.migrateNotificationHistoryContext(); this.migrateNotificationHistoryContext();
this.migrateScanPolicyFleetColumns(); this.migrateScanPolicyFleetColumns();
this.migrateSecretMisconfigColumns(); this.migrateSecretMisconfigColumns();
this.migrateAgentsAndNotificationsNodeId();
} }
public static getInstance(): DatabaseService { public static getInstance(): DatabaseService {
@@ -466,6 +467,7 @@ export class DatabaseService {
this.db.exec(` this.db.exec(`
CREATE TABLE IF NOT EXISTS agents ( CREATE TABLE IF NOT EXISTS agents (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL DEFAULT 0,
type TEXT NOT NULL, type TEXT NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
enabled INTEGER DEFAULT 0 enabled INTEGER DEFAULT 0
@@ -489,6 +491,7 @@ export class DatabaseService {
CREATE TABLE IF NOT EXISTS notification_history ( CREATE TABLE IF NOT EXISTS notification_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL DEFAULT 0,
level TEXT NOT NULL, level TEXT NOT NULL,
message TEXT NOT NULL, message TEXT NOT NULL,
timestamp INTEGER NOT NULL, timestamp INTEGER NOT NULL,
@@ -1115,32 +1118,59 @@ export class DatabaseService {
tryAddColumn('vulnerability_scans', 'scanners_used', "TEXT NOT NULL DEFAULT 'vuln'"); tryAddColumn('vulnerability_scans', 'scanners_used', "TEXT NOT NULL DEFAULT 'vuln'");
} }
private migrateAgentsAndNotificationsNodeId(): void {
const tryAddColumn = (table: string, col: string, def: string) => {
try {
this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run();
} catch {
/* column already present */
}
};
tryAddColumn('agents', 'node_id', 'INTEGER NOT NULL DEFAULT 0');
tryAddColumn('notification_history', 'node_id', 'INTEGER NOT NULL DEFAULT 0');
const tryIndex = (sql: string, label: string) => {
try {
this.db.prepare(sql).run();
} catch (e) {
console.warn(`[DatabaseService] Could not create ${label}:`, (e as Error).message);
}
};
tryIndex(
'CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_node_type ON agents(node_id, type)',
'agents(node_id, type) unique index',
);
tryIndex(
'CREATE INDEX IF NOT EXISTS idx_notif_history_node_timestamp ON notification_history(node_id, timestamp DESC)',
'notification_history(node_id, timestamp) index',
);
}
// --- Agents --- // --- Agents ---
public getAgents(): Agent[] { public getAgents(nodeId: number): Agent[] {
const stmt = this.db.prepare('SELECT * FROM agents'); const stmt = this.db.prepare('SELECT * FROM agents WHERE node_id = ?');
return stmt.all().map((row: any) => ({ return stmt.all(nodeId).map((row: any) => ({
...row, ...row,
enabled: row.enabled === 1 enabled: row.enabled === 1
})); }));
} }
public getEnabledAgents(): Agent[] { public getEnabledAgents(nodeId: number): Agent[] {
const stmt = this.db.prepare('SELECT * FROM agents WHERE enabled = 1'); const stmt = this.db.prepare('SELECT * FROM agents WHERE node_id = ? AND enabled = 1');
return stmt.all().map((row: any) => ({ return stmt.all(nodeId).map((row: any) => ({
...row, ...row,
enabled: row.enabled === 1 enabled: row.enabled === 1
})); }));
} }
public upsertAgent(agent: Agent): void { public upsertAgent(nodeId: number, agent: Agent): void {
const existing = this.db.prepare('SELECT id FROM agents WHERE type = ?').get(agent.type) as any; const existing = this.db.prepare('SELECT id FROM agents WHERE node_id = ? AND type = ?').get(nodeId, agent.type) as any;
if (existing) { if (existing) {
const stmt = this.db.prepare('UPDATE agents SET url = ?, enabled = ? WHERE type = ?'); const stmt = this.db.prepare('UPDATE agents SET url = ?, enabled = ? WHERE node_id = ? AND type = ?');
stmt.run(agent.url, agent.enabled ? 1 : 0, agent.type); stmt.run(agent.url, agent.enabled ? 1 : 0, nodeId, agent.type);
} else { } else {
const stmt = this.db.prepare('INSERT INTO agents (type, url, enabled) VALUES (?, ?, ?)'); const stmt = this.db.prepare('INSERT INTO agents (node_id, type, url, enabled) VALUES (?, ?, ?, ?)');
stmt.run(agent.type, agent.url, agent.enabled ? 1 : 0); stmt.run(nodeId, agent.type, agent.url, agent.enabled ? 1 : 0);
} }
} }
@@ -1370,9 +1400,9 @@ export class DatabaseService {
// --- Notification History --- // --- Notification History ---
public getNotificationHistory(limit = 50): NotificationHistory[] { public getNotificationHistory(nodeId: number, limit = 50): NotificationHistory[] {
const stmt = this.db.prepare('SELECT * FROM notification_history ORDER BY timestamp DESC LIMIT ?'); const stmt = this.db.prepare('SELECT * FROM notification_history WHERE node_id = ? ORDER BY timestamp DESC LIMIT ?');
return stmt.all(limit).map((row: any) => ({ return stmt.all(nodeId, limit).map((row: any) => ({
...row, ...row,
is_read: row.is_read === 1, is_read: row.is_read === 1,
stack_name: row.stack_name ?? undefined, stack_name: row.stack_name ?? undefined,
@@ -1380,11 +1410,12 @@ export class DatabaseService {
})); }));
} }
public addNotificationHistory(notification: Omit<NotificationHistory, 'id' | 'is_read'>): NotificationHistory { public addNotificationHistory(nodeId: number, notification: Omit<NotificationHistory, 'id' | 'is_read'>): NotificationHistory {
const stmt = this.db.prepare( const stmt = this.db.prepare(
'INSERT INTO notification_history (level, message, timestamp, is_read, stack_name, container_name) VALUES (?, ?, ?, 0, ?, ?)' 'INSERT INTO notification_history (node_id, level, message, timestamp, is_read, stack_name, container_name) VALUES (?, ?, ?, ?, 0, ?, ?)'
); );
const result = stmt.run( const result = stmt.run(
nodeId,
notification.level, notification.level,
notification.message, notification.message,
notification.timestamp, notification.timestamp,
@@ -1392,12 +1423,12 @@ export class DatabaseService {
notification.container_name ?? null notification.container_name ?? null
); );
this.db.exec(` this.db.prepare(`
DELETE FROM notification_history DELETE FROM notification_history
WHERE id NOT IN ( WHERE node_id = ? AND id NOT IN (
SELECT id FROM notification_history ORDER BY timestamp DESC LIMIT 100 SELECT id FROM notification_history WHERE node_id = ? ORDER BY timestamp DESC LIMIT 100
) )
`); `).run(nodeId, nodeId);
return { return {
id: result.lastInsertRowid as number, id: result.lastInsertRowid as number,
@@ -1410,19 +1441,19 @@ export class DatabaseService {
}; };
} }
public markAllNotificationsRead(): void { public markAllNotificationsRead(nodeId: number): void {
const stmt = this.db.prepare('UPDATE notification_history SET is_read = 1'); const stmt = this.db.prepare('UPDATE notification_history SET is_read = 1 WHERE node_id = ?');
stmt.run(); stmt.run(nodeId);
} }
public deleteNotification(id: number): void { public deleteNotification(nodeId: number, id: number): void {
const stmt = this.db.prepare('DELETE FROM notification_history WHERE id = ?'); const stmt = this.db.prepare('DELETE FROM notification_history WHERE node_id = ? AND id = ?');
stmt.run(id); stmt.run(nodeId, id);
} }
public deleteAllNotifications(): void { public deleteAllNotifications(nodeId: number): void {
const stmt = this.db.prepare('DELETE FROM notification_history'); const stmt = this.db.prepare('DELETE FROM notification_history WHERE node_id = ?');
stmt.run(); stmt.run(nodeId);
} }
public updateNotificationDispatchError(id: number, error: string): void { public updateNotificationDispatchError(id: number, error: string): void {
+3 -1
View File
@@ -281,8 +281,10 @@ export class ImageUpdateService {
} catch (e) { } catch (e) {
console.error(`[ImageUpdateService] Failed to dispatch update notification for "${stackName}":`, e); console.error(`[ImageUpdateService] Failed to dispatch update notification for "${stackName}":`, e);
// Direct DB write to avoid recursing through dispatchAlert if it is what failed. // Direct DB write to avoid recursing through dispatchAlert if it is what failed.
// Key on the local default: the iterated `nodeId` may be a remote's id in the
// control plane's DB, and the UI never queries that row (it proxies instead).
try { try {
db.addNotificationHistory({ db.addNotificationHistory(NodeRegistry.getInstance().getDefaultNodeId(), {
level: 'error', level: 'error',
message: `[Node: ${nodeName}] Failed to notify about image updates for stack "${stackName}": ${getErrorMessage(e, String(e))}`, message: `[Node: ${nodeName}] Failed to notify about image updates for stack "${stackName}": ${getErrorMessage(e, String(e))}`,
timestamp: Date.now(), timestamp: Date.now(),
+8 -4
View File
@@ -1,4 +1,5 @@
import { DatabaseService, NotificationHistory } from './DatabaseService'; import { DatabaseService, NotificationHistory } from './DatabaseService';
import { NodeRegistry } from './NodeRegistry';
import { isDebugEnabled } from '../utils/debug'; import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors'; import { getErrorMessage } from '../utils/errors';
@@ -45,8 +46,11 @@ export class NotificationService {
stackName?: string, stackName?: string,
containerName?: string, containerName?: string,
) { ) {
// 1. Log to history and get the full inserted record (with id) // Internal writes use the middleware default so they share a row key
const notification = this.dbService.addNotificationHistory({ // with user-initiated requests; otherwise the UI and monitors split
// between different node_id buckets.
const localNodeId = NodeRegistry.getInstance().getDefaultNodeId();
const notification = this.dbService.addNotificationHistory(localNodeId, {
level, level,
message, message,
timestamp: Date.now(), timestamp: Date.now(),
@@ -84,8 +88,8 @@ export class NotificationService {
} }
} }
// 4. Fall back to global agents // 4. Fall back to this instance's agents (keyed by this instance's default node id).
const agents = this.dbService.getEnabledAgents(); const agents = this.dbService.getEnabledAgents(localNodeId);
if (agents.length === 0) { if (agents.length === 0) {
if (isDebugEnabled()) console.log('[Notify:diag] No routes or agents matched; skipping external dispatch'); if (isDebugEnabled()) console.log('[Notify:diag] No routes or agents matched; skipping external dispatch');
return; return;
+4 -2
View File
@@ -135,8 +135,10 @@ The alert panel shows a blue info banner when you are configuring alerts on a re
### Setup checklist for remote alerts ### Setup checklist for remote alerts
1. On the **remote** Sencho instance, go to **Settings > Notifications** and configure at least one channel You can configure a remote node's notification channels directly from the control plane:
2. From your **primary** instance, switch to the remote node
1. From your **primary** instance, switch to the remote node using the node picker
2. Open **Settings > Notifications** and configure the remote's Discord, Slack, or Webhook channels. Values saved here apply only to the selected node.
3. Right-click a stack and select **Alerts** to create rules 3. Right-click a stack and select **Alerts** to create rules
4. The remote instance handles monitoring and notification delivery independently 4. The remote instance handles monitoring and notification delivery independently
+16
View File
@@ -83,6 +83,22 @@ Click the **calendar icon** on any node row to jump directly to the Schedules vi
When a node is deleted, all scheduled tasks and update status data associated with it are automatically cleaned up. When a node is deleted, all scheduled tasks and update status data associated with it are automatically cleaned up.
## What Settings apply per node
When you select a remote node in the node picker, the Settings hub filters to the panels that control that specific instance. Values saved here never cross over to other nodes.
| Panel | Scope | Notes |
|-------|:-----:|-------|
| Appearance | Per browser | Density and theme preferences are stored in your browser, not on the node. |
| System Limits | Per node | Host CPU, RAM, disk, and crash-loop thresholds for the selected node. |
| Notifications | Per node | Discord, Slack, and Webhook channels fire from the node that detects the event. |
| Labels | Per node | Stack and container label palettes. |
| Security | Per node | Trivy install, update, and scanner status. Scan policies and CVE suppressions are managed on the control node and apply fleet-wide. |
| Developer | Per node | Retention windows for metrics and logs, plus Developer Mode. |
| App Store | Per node | Template registry URL for the selected node's catalog. |
Panels that manage control-plane concerns (Account, License, Users, SSO, API Tokens, Registries, Nodes, Routing, Webhooks) are hidden when a remote node is active.
## License enforcement across nodes ## License enforcement across nodes
When you have a paid license (Skipper or Admiral) on your primary instance, all remote nodes automatically inherit that license tier for proxied requests. You do not need to activate a license on each remote node separately. When you have a paid license (Skipper or Admiral) on your primary instance, all remote nodes automatically inherit that license tier for proxied requests. You do not need to activate a license on each remote node separately.
+4
View File
@@ -13,6 +13,10 @@ Sencho integrates with [Trivy](https://trivy.dev) to scan container images for k
The Trivy CLI must be available on the machine running Sencho. Trivy is not bundled with the Sencho Docker image; see [Installing Trivy](/operations/trivy-setup) for mount and installation options. Sencho checks for Trivy on startup and hides scanning UI when the binary is not available. The Trivy CLI must be available on the machine running Sencho. Trivy is not bundled with the Sencho Docker image; see [Installing Trivy](/operations/trivy-setup) for mount and installation options. Sencho checks for Trivy on startup and hides scanning UI when the binary is not available.
<Note>
Trivy is installed independently on each Sencho instance. When you select a remote node, **Settings > Security** shows only the scanner status for that node; install, update, or uninstall Trivy from there to manage the remote's binary. Scan policies and CVE suppressions are managed on the control node and apply fleet-wide at read time.
</Note>
## Tier availability ## Tier availability
| Feature | Community | Skipper | Admiral | | Feature | Community | Skipper | Admiral |
@@ -326,7 +326,6 @@ export function SettingsModal({ isOpen, onClose, initialSection, onLabelsChanged
onSave={saveDeveloperSettings} onSave={saveDeveloperSettings}
isSaving={isSavingDeveloper} isSaving={isSavingDeveloper}
isLoading={isSettingsLoading} isLoading={isSettingsLoading}
isRemote={isRemote}
/> />
); );
case 'nodes': return <NodeManager />; case 'nodes': return <NodeManager />;
@@ -3,10 +3,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { TogglePill } from '@/components/ui/toggle-pill'; import { TogglePill } from '@/components/ui/toggle-pill';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useLicense } from '@/context/LicenseContext'; import { useLicense } from '@/context/LicenseContext';
import { RefreshCw, Database, Info } from 'lucide-react'; import { RefreshCw, Database } from 'lucide-react';
import type { PatchableSettings } from './types'; import type { PatchableSettings } from './types';
interface DeveloperSectionProps { interface DeveloperSectionProps {
@@ -15,7 +13,6 @@ interface DeveloperSectionProps {
onSave: () => Promise<void>; onSave: () => Promise<void>;
isSaving: boolean; isSaving: boolean;
isLoading: boolean; isLoading: boolean;
isRemote: boolean;
} }
function SettingsSkeleton() { function SettingsSkeleton() {
@@ -32,29 +29,11 @@ function SettingsSkeleton() {
); );
} }
export function DeveloperSection({ settings, onSettingChange, onSave, isSaving, isLoading, isRemote }: DeveloperSectionProps) { export function DeveloperSection({ settings, onSettingChange, onSave, isSaving, isLoading }: DeveloperSectionProps) {
const { isPaid, license } = useLicense(); const { isPaid, license } = useLicense();
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{isRemote && (
<div className="flex justify-end">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="text-xs cursor-help">
<Info className="w-3 h-3 mr-1" />
Always Local
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[220px] text-center">
These settings control this Sencho instance's UI behaviour and are never synced to remote nodes.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
{isLoading ? <SettingsSkeleton /> : ( {isLoading ? <SettingsSkeleton /> : (
<> <>
<div className="space-y-6 bg-glass border border-glass-border p-4 rounded-lg"> <div className="space-y-6 bg-glass border border-glass-border p-4 rounded-lg">
@@ -30,6 +30,7 @@ import { PaidGate } from '@/components/PaidGate';
import { ShieldCheck, Plus, Trash2, Pencil, Download, RefreshCw, Loader2, Info } from 'lucide-react'; import { ShieldCheck, Plus, Trash2, Pencil, Download, RefreshCw, Loader2, Info } from 'lucide-react';
import type { FleetRole, ScanPolicy, VulnSeverity } from '@/types/security'; import type { FleetRole, ScanPolicy, VulnSeverity } from '@/types/security';
import { useLicense } from '@/context/LicenseContext'; import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import { useTrivyStatus } from '@/hooks/useTrivyStatus';
import { SuppressionsPanel } from './SuppressionsPanel'; import { SuppressionsPanel } from './SuppressionsPanel';
@@ -85,6 +86,8 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
const { license } = useLicense(); const { license } = useLicense();
const isAdmiral = isPaid && license?.variant === 'admiral'; const isAdmiral = isPaid && license?.variant === 'admiral';
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
const { status: trivy, updateCheck, refresh: refreshTrivy, refreshUpdateCheck } = useTrivyStatus(); const { status: trivy, updateCheck, refresh: refreshTrivy, refreshUpdateCheck } = useTrivyStatus();
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update'>(null); const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update'>(null);
const [uninstallConfirm, setUninstallConfirm] = useState(false); const [uninstallConfirm, setUninstallConfirm] = useState(false);
@@ -100,7 +103,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
setTrivyBusy(op); setTrivyBusy(op);
const toastId = toast.loading(loading); const toastId = toast.loading(loading);
try { try {
const res = await apiFetch(path, { method, localOnly: true }); const res = await apiFetch(path, { method });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error(err?.error || `Trivy ${op} failed`); throw new Error(err?.error || `Trivy ${op} failed`);
@@ -127,7 +130,6 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
try { try {
const res = await apiFetch('/security/trivy-auto-update', { const res = await apiFetch('/security/trivy-auto-update', {
method: 'PUT', method: 'PUT',
localOnly: true,
body: JSON.stringify({ enabled }), body: JSON.stringify({ enabled }),
}); });
if (!res.ok) { if (!res.ok) {
@@ -158,11 +160,17 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
}; };
useEffect(() => { useEffect(() => {
if (isPaid) fetchPolicies(); if (!isPaid) { setLoading(false); return; }
else setLoading(false); if (isRemote) { setPolicies([]); setLoading(false); return; }
}, [isPaid]); fetchPolicies();
}, [isPaid, isRemote]);
useEffect(() => { useEffect(() => {
void refreshTrivy();
}, [activeNode?.id, refreshTrivy]);
useEffect(() => {
if (isRemote) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
@@ -177,7 +185,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, [isRemote]);
const openCreate = () => { const openCreate = () => {
setEditingId(null); setEditingId(null);
@@ -267,7 +275,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{!isReplica && ( {!isRemote && !isReplica && (
<div className="flex justify-end"> <div className="flex justify-end">
<Button size="sm" onClick={openCreate}> <Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" /> <Plus className="w-4 h-4 mr-1.5" />
@@ -276,7 +284,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
</div> </div>
)} )}
{isReplica && ( {!isRemote && isReplica && (
<div <div
role="status" role="status"
aria-live="polite" aria-live="polite"
@@ -367,14 +375,30 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
)} )}
</div> </div>
{loading && ( {isRemote && (
<div
role="status"
aria-live="polite"
className="flex items-start gap-2 rounded-lg border border-card-border bg-muted/30 px-4 py-3"
>
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
<div className="text-sm">
<div className="font-medium">Scanner is per-node</div>
<p className="text-xs text-muted-foreground mt-0.5">
Trivy is installed independently on each Sencho instance. Scan policies and CVE suppressions are managed on the control node.
</p>
</div>
</div>
)}
{!isRemote && loading && (
<div className="space-y-3"> <div className="space-y-3">
<Skeleton className="h-20 w-full rounded-lg" /> <Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-20 w-full rounded-lg" /> <Skeleton className="h-20 w-full rounded-lg" />
</div> </div>
)} )}
{!loading && policies.length === 0 && ( {!isRemote && !loading && policies.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">
<ShieldCheck className="w-10 h-10 text-muted-foreground/50 mb-3" /> <ShieldCheck className="w-10 h-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">No scan policies configured.</p> <p className="text-sm text-muted-foreground">No scan policies configured.</p>
@@ -384,7 +408,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
</div> </div>
)} )}
{!loading && {!isRemote && !loading &&
policies.map((policy) => ( policies.map((policy) => (
<div key={policy.id} className="border border-glass-border rounded-lg p-4 space-y-3"> <div key={policy.id} className="border border-glass-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@@ -436,7 +460,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
</div> </div>
))} ))}
<SuppressionsPanel isReplica={isReplica} /> {!isRemote && <SuppressionsPanel isReplica={isReplica} />}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
+3 -5
View File
@@ -131,7 +131,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
description: 'In-app toasts and browser push for stack, container, and system events.', description: 'In-app toasts and browser push for stack, container, and system events.',
keywords: ['toasts', 'push', 'events', 'alerts', 'inbox'], keywords: ['toasts', 'push', 'events', 'alerts', 'inbox'],
tier: null, tier: null,
scope: 'global', scope: 'node',
}, },
{ {
id: 'notification-routing', id: 'notification-routing',
@@ -170,9 +170,8 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
description: 'Image scanning, suppressions, and posture defaults.', description: 'Image scanning, suppressions, and posture defaults.',
keywords: ['scan', 'cve', 'trivy', 'suppressions', 'hardening'], keywords: ['scan', 'cve', 'trivy', 'suppressions', 'hardening'],
tier: 'skipper', tier: 'skipper',
scope: 'global', scope: 'node',
adminOnly: true, adminOnly: true,
hiddenOnRemote: true,
}, },
{ {
id: 'developer', id: 'developer',
@@ -190,8 +189,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
description: 'Template registry URL and featured-catalog source.', description: 'Template registry URL and featured-catalog source.',
keywords: ['templates', 'registry', 'catalog', 'featured'], keywords: ['templates', 'registry', 'catalog', 'featured'],
tier: null, tier: null,
scope: 'global', scope: 'node',
hiddenOnRemote: true,
}, },
{ {
id: 'support', id: 'support',