feat: open security basics, manual fleet ops, and basic fleet management to Community (#930)

Realign tier guards to the user-stated philosophy: Community covers
deploy/monitor at scale plus security basics, Skipper adds automation
and advanced fleet management, Admiral keeps enterprise control.

Community now includes:
- Trivy install / uninstall / update from the Settings Hub (admin role)
- CVE suppressions CRUD (admin role; replicates fleet-wide)
- Manual image scan with vuln, secret, and misconfig results
- Stack-config scan, scan comparison
- Manual fleet snapshots: create, list, view, restore, delete
- Per-node Sencho self-update (Check Updates + per-node Update)
- Fleet Overview search, sort, filters, node-card expand, auto-refresh

Stays paid:
- Scan policies with block_on_deploy enforcement (Skipper+)
- SBOM (SPDX, CycloneDX), SARIF export (Skipper+)
- Bulk Update All across the fleet (Skipper+)
- Scheduled snapshot create (now Skipper, was Admiral)
- Trivy auto-update toggle, fleet-wide policy push (Admiral)

The Settings -> Security tab is unhidden by setting the registry tier to
null. The SecuritySection no longer early-returns a PaidGate; the policy
list, Add Policy button, and policy dialogs are wrapped in {isPaid && }.
The Fleet view drops isPaid gates on the Snapshots tab, Check Updates
button, per-node update handlers, OverviewToolbar grid controls, the
NodeCard expand affordance, and the auto-refresh notice. The
NodeUpdatesSheet receives a canBulkUpdate prop and gates the Update All
button on it. useFleetUpdateStatus and useFleetPolling drop their isPaid
guards so polling runs for Community; useFleetOverview drops the isPaid
wrap on the filter and sort path.

Backend route guards are flipped per the matrix above. The scheduler
tick and requireScheduledTaskTier add 'snapshot' to the Skipper+ branch.
Backend test assertions are inverted for the now-Community endpoints
and a positive Skipper-snapshot-task test is added.

Documentation across features/, api-reference/, and operations/ is
updated to reflect the new tier mapping.
This commit is contained in:
Anso
2026-05-05 12:54:26 -04:00
committed by GitHub
parent f1a592372b
commit ecf4dd5d52
28 changed files with 234 additions and 280 deletions
+31 -37
View File
@@ -157,34 +157,7 @@ describe('GET /api/fleet/overview', () => {
describe('Fleet tier gating', () => { describe('Fleet tier gating', () => {
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
it('GET /api/fleet/update-status returns 403 on free tier', async () => { it('POST /api/fleet/update-all returns 403 on community tier (bulk update is Skipper+)', async () => {
mockTier('community');
const res = await request(app)
.get('/api/fleet/update-status')
.set('Authorization', authHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('GET /api/fleet/snapshots returns 403 on free tier', async () => {
mockTier('community');
const res = await request(app)
.get('/api/fleet/snapshots')
.set('Authorization', authHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('POST /api/fleet/nodes/1/update returns 403 on free tier', async () => {
mockTier('community');
const res = await request(app)
.post('/api/fleet/nodes/1/update')
.set('Authorization', authHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('POST /api/fleet/update-all returns 403 on free tier', async () => {
mockTier('community'); mockTier('community');
const res = await request(app) const res = await request(app)
.post('/api/fleet/update-all') .post('/api/fleet/update-all')
@@ -193,22 +166,44 @@ describe('Fleet tier gating', () => {
expect(res.body.code).toBe('PAID_REQUIRED'); expect(res.body.code).toBe('PAID_REQUIRED');
}); });
it('DELETE /api/fleet/nodes/1/update-status returns 403 on free tier', async () => { it('GET /api/fleet/update-status is accessible on community tier', async () => {
mockTier('community');
const res = await request(app)
.get('/api/fleet/update-status')
.set('Authorization', authHeader);
expect(res.body.code).not.toBe('PAID_REQUIRED');
});
it('GET /api/fleet/snapshots is accessible on community tier', async () => {
mockTier('community');
const res = await request(app)
.get('/api/fleet/snapshots')
.set('Authorization', authHeader);
expect(res.body.code).not.toBe('PAID_REQUIRED');
});
it('POST /api/fleet/nodes/1/update is accessible on community tier', async () => {
mockTier('community');
const res = await request(app)
.post('/api/fleet/nodes/1/update')
.set('Authorization', authHeader);
expect(res.body.code).not.toBe('PAID_REQUIRED');
});
it('DELETE /api/fleet/nodes/1/update-status is accessible on community tier', async () => {
mockTier('community'); mockTier('community');
const res = await request(app) const res = await request(app)
.delete('/api/fleet/nodes/1/update-status') .delete('/api/fleet/nodes/1/update-status')
.set('Authorization', authHeader); .set('Authorization', authHeader);
expect(res.status).toBe(403); expect(res.body.code).not.toBe('PAID_REQUIRED');
expect(res.body.code).toBe('PAID_REQUIRED');
}); });
it('DELETE /api/fleet/update-status returns 403 on free tier', async () => { it('DELETE /api/fleet/update-status is accessible on community tier', async () => {
mockTier('community'); mockTier('community');
const res = await request(app) const res = await request(app)
.delete('/api/fleet/update-status') .delete('/api/fleet/update-status')
.set('Authorization', authHeader); .set('Authorization', authHeader);
expect(res.status).toBe(403); expect(res.body.code).not.toBe('PAID_REQUIRED');
expect(res.body.code).toBe('PAID_REQUIRED');
}); });
}); });
@@ -384,14 +379,13 @@ describe('Fleet snapshot restore', () => {
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
it('POST /api/fleet/snapshots/:id/restore returns 403 on free tier', async () => { it('POST /api/fleet/snapshots/:id/restore is accessible on community tier', async () => {
mockTier('community'); mockTier('community');
const res = await request(app) const res = await request(app)
.post(`/api/fleet/snapshots/${snapshotId}/restore`) .post(`/api/fleet/snapshots/${snapshotId}/restore`)
.set('Authorization', authHeader) .set('Authorization', authHeader)
.send({ nodeId: 1, stackName: 'test' }); .send({ nodeId: 1, stackName: 'test' });
expect(res.status).toBe(403); expect(res.body.code).not.toBe('PAID_REQUIRED');
expect(res.body.code).toBe('PAID_REQUIRED');
}); });
it('returns 400 with missing nodeId/stackName', async () => { it('returns 400 with missing nodeId/stackName', async () => {
+2 -3
View File
@@ -116,7 +116,7 @@ beforeEach(() => {
}); });
describe('GET /api/security/compare', () => { describe('GET /api/security/compare', () => {
it('returns 403 for community tier', async () => { it('is accessible on community tier', async () => {
tierSpy.mockReturnValue('community'); tierSpy.mockReturnValue('community');
const a = seedScan(); const a = seedScan();
const b = seedScan({ scannedAt: Date.now() + 1000 }); const b = seedScan({ scannedAt: Date.now() + 1000 });
@@ -125,8 +125,7 @@ describe('GET /api/security/compare', () => {
.get(`/api/security/compare?scanId1=${a}&scanId2=${b}`) .get(`/api/security/compare?scanId1=${a}&scanId2=${b}`)
.set('Authorization', `Bearer ${adminToken()}`); .set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(403); expect(res.body.code).not.toBe('PAID_REQUIRED');
expect(res.body.code).toBe('PAID_REQUIRED');
}); });
it('returns 400 for non-finite scanId params', async () => { it('returns 400 for non-finite scanId params', async () => {
@@ -281,7 +281,7 @@ describe('SchedulerService - license gating', () => {
expect(mockCreateScheduledTaskRun).toHaveBeenCalled(); expect(mockCreateScheduledTaskRun).toHaveBeenCalled();
}); });
it('skips non-update tasks for non-admiral pro', async () => { it('skips non-update/scan/snapshot tasks for non-admiral pro', async () => {
mockGetTier.mockReturnValue('paid'); mockGetTier.mockReturnValue('paid');
mockGetVariant.mockReturnValue('individual'); mockGetVariant.mockReturnValue('individual');
mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'restart' })]); mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'restart' })]);
@@ -292,6 +292,18 @@ describe('SchedulerService - license gating', () => {
expect(mockCreateScheduledTaskRun).not.toHaveBeenCalled(); expect(mockCreateScheduledTaskRun).not.toHaveBeenCalled();
}); });
it('allows snapshot tasks for non-admiral pro (Skipper)', async () => {
mockGetTier.mockReturnValue('paid');
mockGetVariant.mockReturnValue('individual');
mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'snapshot', target_type: 'fleet' })]);
const svc = SchedulerService.getInstance();
await (svc as any).tick();
await new Promise(r => setTimeout(r, 50));
expect(mockCreateScheduledTaskRun).toHaveBeenCalled();
});
it('allows all actions for admiral (pro + team)', async () => { it('allows all actions for admiral (pro + team)', async () => {
mockGetTier.mockReturnValue('paid'); mockGetTier.mockReturnValue('paid');
mockGetVariant.mockReturnValue('admiral'); mockGetVariant.mockReturnValue('admiral');
@@ -56,11 +56,11 @@ describe('GET /api/security/suppressions', () => {
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
it('requires paid tier', async () => { it('is accessible on community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const res = await request(app).get('/api/security/suppressions').set('Authorization', adminAuthHeader); const res = await request(app).get('/api/security/suppressions').set('Authorization', adminAuthHeader);
expect(res.status).toBe(403); expect(res.status).toBe(200);
expect(res.body.code).toBe('PAID_REQUIRED'); expect(res.body.code).not.toBe('PAID_REQUIRED');
}); });
it('returns an empty list when no suppressions exist', async () => { it('returns an empty list when no suppressions exist', async () => {
@@ -123,14 +123,14 @@ describe('POST /api/security/suppressions', () => {
expect(res.body.code).toBe('ADMIN_REQUIRED'); expect(res.body.code).toBe('ADMIN_REQUIRED');
}); });
it('rejects community tier with 403', async () => { it('is accessible on community tier (admin still required)', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const res = await request(app) const res = await request(app)
.post('/api/security/suppressions') .post('/api/security/suppressions')
.set('Authorization', adminAuthHeader) .set('Authorization', adminAuthHeader)
.send(validBody); .send(validBody);
expect(res.status).toBe(403); expect(res.status).toBe(201);
expect(res.body.code).toBe('PAID_REQUIRED'); expect(res.body.code).not.toBe('PAID_REQUIRED');
}); });
it('rejects writes on replicas with 403', async () => { it('rejects writes on replicas with 403', async () => {
+2 -2
View File
@@ -59,9 +59,9 @@ export const requireNodeProxy = (req: Request, res: Response): boolean => {
return true; return true;
}; };
/** Tier gate for scheduled tasks: `update` and `scan` require Skipper+, everything else requires Admiral. */ /** Tier gate for scheduled tasks: `update`, `scan`, and `snapshot` require Skipper+, everything else requires Admiral. */
export const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => { export const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => {
if (action === 'update' || action === 'scan') return requirePaid(req, res); if (action === 'update' || action === 'scan' || action === 'snapshot') return requirePaid(req, res);
return requireAdmiral(req, res); return requireAdmiral(req, res);
}; };
+1 -12
View File
@@ -476,7 +476,6 @@ fleetRouter.get('/node/:nodeId/stacks/:stackName/containers', authMiddleware, as
}); });
fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Response): Promise<void> => { fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try { try {
const db = DatabaseService.getInstance(); const db = DatabaseService.getInstance();
const nodes = db.getNodes(); const nodes = db.getNodes();
@@ -625,7 +624,6 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
}); });
fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, res: Response): Promise<void> => { fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
try { try {
const nodeId = parseIntParam(req, res, 'nodeId', 'node ID'); const nodeId = parseIntParam(req, res, 'nodeId', 'node ID');
@@ -779,7 +777,6 @@ fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Respon
}); });
fleetRouter.delete('/nodes/:nodeId/update-status', authMiddleware, async (req: Request, res: Response): Promise<void> => { fleetRouter.delete('/nodes/:nodeId/update-status', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try { try {
const nodeId = parseIntParam(req, res, 'nodeId', 'node ID'); const nodeId = parseIntParam(req, res, 'nodeId', 'node ID');
if (nodeId === null) return; if (nodeId === null) return;
@@ -797,7 +794,6 @@ fleetRouter.delete('/nodes/:nodeId/update-status', authMiddleware, async (req: R
}); });
fleetRouter.delete('/update-status', authMiddleware, async (req: Request, res: Response): Promise<void> => { fleetRouter.delete('/update-status', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
// Pre-fetch fresh latest version so the next GET has up-to-date data. // Pre-fetch fresh latest version so the next GET has up-to-date data.
if (req.query.recheck === 'true') { if (req.query.recheck === 'true') {
await getLatestVersion(true); await getLatestVersion(true);
@@ -810,11 +806,10 @@ fleetRouter.delete('/update-status', authMiddleware, async (req: Request, res: R
res.status(204).send(); res.status(204).send();
}); });
// ─── Fleet Snapshots (Skipper+) ─── // ─── Fleet Snapshots (manual: Community; scheduled: Skipper+) ───
fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => { fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try { try {
const { description = '' } = req.body; const { description = '' } = req.body;
@@ -909,8 +904,6 @@ fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Respons
}); });
fleetRouter.get('/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => { fleetRouter.get('/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try { try {
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100); const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
const offset = parseInt(req.query.offset as string, 10) || 0; const offset = parseInt(req.query.offset as string, 10) || 0;
@@ -926,8 +919,6 @@ fleetRouter.get('/snapshots', authMiddleware, async (req: Request, res: Response
}); });
fleetRouter.get('/snapshots/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => { fleetRouter.get('/snapshots/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
try { try {
const id = parseIntParam(req, res, 'id', 'snapshot ID'); const id = parseIntParam(req, res, 'id', 'snapshot ID');
if (id === null) return; if (id === null) return;
@@ -972,7 +963,6 @@ fleetRouter.get('/snapshots/:id', authMiddleware, async (req: Request, res: Resp
fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request, res: Response): Promise<void> => { fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try { try {
const snapshotId = parseIntParam(req, res, 'id', 'snapshot ID'); const snapshotId = parseIntParam(req, res, 'id', 'snapshot ID');
@@ -1085,7 +1075,6 @@ fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request,
fleetRouter.delete('/snapshots/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => { fleetRouter.delete('/snapshots/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try { try {
const id = parseIntParam(req, res, 'id', 'snapshot ID'); const id = parseIntParam(req, res, 'id', 'snapshot ID');
+3 -13
View File
@@ -67,7 +67,7 @@ securityRouter.get('/trivy-status', authMiddleware, (_req: Request, res: Respons
}); });
securityRouter.post('/trivy-install', trivyInstallLimiter, authMiddleware, async (req: Request, res: Response): Promise<void> => { securityRouter.post('/trivy-install', trivyInstallLimiter, authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return; if (!requireAdmin(req, res)) return;
const svc = TrivyService.getInstance(); const svc = TrivyService.getInstance();
if (svc.getSource() === 'host') { if (svc.getSource() === 'host') {
res.status(409).json({ error: 'Trivy is already installed on the host PATH. Remove the host binary before managing it from Sencho.' }); res.status(409).json({ error: 'Trivy is already installed on the host PATH. Remove the host binary before managing it from Sencho.' });
@@ -89,7 +89,7 @@ securityRouter.post('/trivy-install', trivyInstallLimiter, authMiddleware, async
}); });
securityRouter.delete('/trivy-install', authMiddleware, async (req: Request, res: Response): Promise<void> => { securityRouter.delete('/trivy-install', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return; if (!requireAdmin(req, res)) return;
const svc = TrivyService.getInstance(); const svc = TrivyService.getInstance();
if (svc.getSource() !== 'managed') { if (svc.getSource() !== 'managed') {
res.status(409).json({ error: 'No managed Trivy install to remove' }); res.status(409).json({ error: 'No managed Trivy install to remove' });
@@ -107,7 +107,6 @@ securityRouter.delete('/trivy-install', authMiddleware, async (req: Request, res
}); });
securityRouter.get('/trivy-update-check', authMiddleware, async (req: Request, res: Response): Promise<void> => { securityRouter.get('/trivy-update-check', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
const svc = TrivyService.getInstance(); const svc = TrivyService.getInstance();
if (svc.getSource() !== 'managed') { if (svc.getSource() !== 'managed') {
res.status(409).json({ error: 'Update checks only apply to managed installs' }); res.status(409).json({ error: 'Update checks only apply to managed installs' });
@@ -124,7 +123,7 @@ securityRouter.get('/trivy-update-check', authMiddleware, async (req: Request, r
}); });
securityRouter.post('/trivy-update', trivyInstallLimiter, authMiddleware, async (req: Request, res: Response): Promise<void> => { securityRouter.post('/trivy-update', trivyInstallLimiter, authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return; if (!requireAdmin(req, res)) return;
const svc = TrivyService.getInstance(); const svc = TrivyService.getInstance();
if (svc.getSource() !== 'managed') { if (svc.getSource() !== 'managed') {
res.status(409).json({ error: 'Update only applies to managed installs' }); res.status(409).json({ error: 'Update only applies to managed installs' });
@@ -178,7 +177,6 @@ securityRouter.post('/scan', authMiddleware, (req: Request, res: Response): void
res.status(400).json({ error: 'scanners must be an array of "vuln" or "secret"' }); res.status(400).json({ error: 'scanners must be an array of "vuln" or "secret"' });
return; return;
} }
if (scanners?.includes('secret') && !requirePaid(req, res)) return;
const nodeId = req.nodeId; const nodeId = req.nodeId;
if (svc.isScanning(nodeId, imageRef)) { if (svc.isScanning(nodeId, imageRef)) {
res.status(409).json({ error: 'Already scanning this image' }); res.status(409).json({ error: 'Already scanning this image' });
@@ -194,7 +192,6 @@ securityRouter.post('/scan', authMiddleware, (req: Request, res: Response): void
securityRouter.post('/scan/stack', authMiddleware, async (req: Request, res: Response): Promise<void> => { securityRouter.post('/scan/stack', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
const svc = TrivyService.getInstance(); const svc = TrivyService.getInstance();
if (!svc.isTrivyAvailable()) { if (!svc.isTrivyAvailable()) {
res.status(503).json({ error: 'Trivy is not available on this host' }); return; res.status(503).json({ error: 'Trivy is not available on this host' }); return;
@@ -288,7 +285,6 @@ securityRouter.get(
'/scans/:scanId/secrets', '/scans/:scanId/secrets',
authMiddleware, authMiddleware,
(req: Request, res: Response): void => { (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const scanId = Number(req.params.scanId); const scanId = Number(req.params.scanId);
if (!Number.isFinite(scanId)) { if (!Number.isFinite(scanId)) {
res.status(400).json({ error: 'Invalid scan id' }); return; res.status(400).json({ error: 'Invalid scan id' }); return;
@@ -314,7 +310,6 @@ securityRouter.get(
'/scans/:scanId/misconfigs', '/scans/:scanId/misconfigs',
authMiddleware, authMiddleware,
(req: Request, res: Response): void => { (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const scanId = Number(req.params.scanId); const scanId = Number(req.params.scanId);
if (!Number.isFinite(scanId)) { if (!Number.isFinite(scanId)) {
res.status(400).json({ error: 'Invalid scan id' }); return; res.status(400).json({ error: 'Invalid scan id' }); return;
@@ -495,7 +490,6 @@ securityRouter.delete('/policies/:id', authMiddleware, (req: Request, res: Respo
}); });
securityRouter.get('/suppressions', authMiddleware, (req: Request, res: Response): void => { securityRouter.get('/suppressions', authMiddleware, (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const now = Date.now(); const now = Date.now();
const rows = DatabaseService.getInstance().getCveSuppressions().map((s) => ({ const rows = DatabaseService.getInstance().getCveSuppressions().map((s) => ({
...s, ...s,
@@ -506,7 +500,6 @@ securityRouter.get('/suppressions', authMiddleware, (req: Request, res: Response
securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Response): void => { securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (blockIfReplica(res, 'CVE suppressions')) return; if (blockIfReplica(res, 'CVE suppressions')) return;
const body = req.body ?? {}; const body = req.body ?? {};
const cveId = typeof body.cve_id === 'string' ? body.cve_id.trim() : ''; const cveId = typeof body.cve_id === 'string' ? body.cve_id.trim() : '';
@@ -559,7 +552,6 @@ securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Respons
securityRouter.put('/suppressions/:id', authMiddleware, (req: Request, res: Response): void => { securityRouter.put('/suppressions/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (blockIfReplica(res, 'CVE suppressions')) return; if (blockIfReplica(res, 'CVE suppressions')) return;
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
@@ -597,7 +589,6 @@ securityRouter.put('/suppressions/:id', authMiddleware, (req: Request, res: Resp
securityRouter.delete('/suppressions/:id', authMiddleware, (req: Request, res: Response): void => { securityRouter.delete('/suppressions/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (blockIfReplica(res, 'CVE suppressions')) return; if (blockIfReplica(res, 'CVE suppressions')) return;
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
@@ -609,7 +600,6 @@ securityRouter.delete('/suppressions/:id', authMiddleware, (req: Request, res: R
}); });
securityRouter.get('/compare', authMiddleware, (req: Request, res: Response): void => { securityRouter.get('/compare', authMiddleware, (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const scanId1 = Number(req.query.scanId1); const scanId1 = Number(req.query.scanId1);
const scanId2 = Number(req.query.scanId2); const scanId2 = Number(req.query.scanId2);
if (!Number.isFinite(scanId1) || !Number.isFinite(scanId2)) { if (!Number.isFinite(scanId1) || !Number.isFinite(scanId2)) {
+1 -1
View File
@@ -209,7 +209,7 @@ export class SchedulerService {
db.deleteOldScans(90 * 24 * 60 * 60 * 1000); db.deleteOldScans(90 * 24 * 60 * 60 * 1000);
for (const task of dueTasks) { for (const task of dueTasks) {
if (!isAdmiral && task.action !== 'update' && task.action !== 'scan') { if (!isAdmiral && task.action !== 'update' && task.action !== 'scan' && task.action !== 'snapshot') {
if (isDebugEnabled()) console.log(`[SchedulerService] Task ${task.id} skipped: action "${task.action}" requires Admiral tier`); if (isDebugEnabled()) console.log(`[SchedulerService] Task ${task.id} skipped: action "${task.action}" requires Admiral tier`);
continue; continue;
} }
+6 -6
View File
@@ -5,7 +5,7 @@ description: Automate scan policies, CVE suppressions, and vulnerability scans f
The Security API lets you manage scan policies, CVE suppressions, and trigger vulnerability scans from CI pipelines and automation scripts. Every endpoint in this reference is intended for external automation; internal frontend-only endpoints (finding listings, SARIF downloads) are not documented here. The Security API lets you manage scan policies, CVE suppressions, and trigger vulnerability scans from CI pipelines and automation scripts. Every endpoint in this reference is intended for external automation; internal frontend-only endpoints (finding listings, SARIF downloads) are not documented here.
All endpoints require [Bearer token authentication](/api-reference/overview#authentication) and most are gated to Skipper or Admiral. See the per-endpoint **License** row for details. All endpoints require [Bearer token authentication](/api-reference/overview#authentication). Manual scans, secret and misconfiguration results, scan comparison, and CVE suppressions are available on every tier. Scan policies (with `block_on_deploy` enforcement), SBOM, and SARIF stay on Skipper or Admiral. See the per-endpoint **License** row for details.
## Scan policies ## Scan policies
@@ -120,7 +120,7 @@ Suppressions let you mark individual CVEs as acknowledged so scan reads, compari
**`GET /api/security/suppressions`** **`GET /api/security/suppressions`**
**License:** Skipper or Admiral **License:** Community
Response rows include an `active` boolean computed from the `expires_at` timestamp. Response rows include an `active` boolean computed from the `expires_at` timestamp.
@@ -145,7 +145,7 @@ Response rows include an `active` boolean computed from the `expires_at` timesta
**`POST /api/security/suppressions`** **`POST /api/security/suppressions`**
**License:** Skipper or Admiral · **Role:** Admin **License:** Community · **Role:** Admin
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|:--------:|-------------| |-------|------|:--------:|-------------|
@@ -176,7 +176,7 @@ curl -X POST https://your-sencho-instance:1852/api/security/suppressions \
**`DELETE /api/security/suppressions/{id}`** **`DELETE /api/security/suppressions/{id}`**
**License:** Skipper or Admiral · **Role:** Admin **License:** Community · **Role:** Admin
```bash ```bash
curl -X DELETE https://your-sencho-instance:1852/api/security/suppressions/3 \ curl -X DELETE https://your-sencho-instance:1852/api/security/suppressions/3 \
@@ -189,7 +189,7 @@ curl -X DELETE https://your-sencho-instance:1852/api/security/suppressions/3 \
**`POST /api/security/scan`** **`POST /api/security/scan`**
**License:** Community for vulnerability-only scans. Skipper or Admiral when `scanners` includes `secret`. · **Role:** Admin **License:** Community · **Role:** Admin
Accepts an image reference and starts an asynchronous scan. The response returns immediately with a `scanId` that you can poll. Accepts an image reference and starts an asynchronous scan. The response returns immediately with a `scanId` that you can poll.
@@ -198,7 +198,7 @@ Accepts an image reference and starts an asynchronous scan. The response returns
| `imageRef` | string | yes | Image reference Trivy will scan (must match Sencho's validator; `/`, `:`, `@`, alphanumerics, `-`, `_`, `.`). | | `imageRef` | string | yes | Image reference Trivy will scan (must match Sencho's validator; `/`, `:`, `@`, alphanumerics, `-`, `_`, `.`). |
| `stackName` | string | no | Associates the scan with a stack for display purposes. | | `stackName` | string | no | Associates the scan with a stack for display purposes. |
| `force` | boolean | no | Default `false`. When `true`, ignores the 24-hour digest cache and runs Trivy again. | | `force` | boolean | no | Default `false`. When `true`, ignores the 24-hour digest cache and runs Trivy again. |
| `scanners` | `["vuln"]` or `["vuln","secret"]` | no | Omit for vuln-only. `secret` requires Skipper or Admiral. | | `scanners` | `["vuln"]` or `["vuln","secret"]` | no | Omit for vuln-only. Pass `["vuln","secret"]` to include secret detection. |
```bash ```bash
curl -X POST https://your-sencho-instance:1852/api/security/scan \ curl -X POST https://your-sencho-instance:1852/api/security/scan \
+1 -1
View File
@@ -6,7 +6,7 @@ description: "Accept known-benign vulnerabilities fleet-wide so your scan result
Not every CVE that Trivy reports requires a response. Some are false positives on your base image, some have been accepted by your security review, and some are waiting on an upstream patch. CVE suppressions let you annotate these findings once so they stop competing for attention in every scan, comparison, and alert. Not every CVE that Trivy reports requires a response. Some are false positives on your base image, some have been accepted by your security review, and some are waiting on an upstream patch. CVE suppressions let you annotate these findings once so they stop competing for attention in every scan, comparison, and alert.
<Note> <Note>
CVE suppressions require a **Skipper** or **Admiral** license. CVE suppressions are available on every tier. Suppressions written on a control node replicate to its replicas at fleet scope.
</Note> </Note>
## What suppressions do ## What suppressions do
+1 -1
View File
@@ -4,7 +4,7 @@ description: Snapshot compose files across all nodes for disaster recovery and a
--- ---
<Note> <Note>
Fleet-Wide Backups require a Skipper or Admiral license. The feature is available to admins in the Fleet View. Manual fleet snapshots are available on every tier (admin role required). Scheduled fleet snapshots are a Skipper or Admiral feature.
</Note> </Note>
Create point-in-time snapshots of every `compose.yaml` and `.env` file across your entire fleet, local and remote nodes alike. Snapshots are stored centrally in Sencho's database and can be browsed, previewed, and restored at any time. Create point-in-time snapshots of every `compose.yaml` and `.env` file across your entire fleet, local and remote nodes alike. Snapshots are stored centrally in Sencho's database and can be browsed, previewed, and restored at any time.
+3 -3
View File
@@ -84,10 +84,10 @@ Click the **Refresh** button in the top-right to re-fetch data from all nodes. T
--- ---
## Paid features ## Fleet operations
<Note> <Note>
The features below require a Skipper or Admiral license. Community users see an upgrade prompt in place of these controls. The toolbar (search, sort, filters), stack drill-down, auto-refresh, and per-node update flow are available on every tier. The bulk **Update All** action inside the Node Updates modal is a Skipper or Admiral feature.
</Note> </Note>
### Auto-refresh ### Auto-refresh
@@ -163,7 +163,7 @@ The modal shows:
- **Filter** search box to find specific nodes - **Filter** search box to find specific nodes
- **Node table** with columns: Node name, Type, Current version, Latest version, and Status (either an "Up to date" badge or an "Update" button) - **Node table** with columns: Node name, Type, Current version, Latest version, and Status (either an "Up to date" badge or an "Update" button)
- **Recheck** button to refresh the latest version from GitHub and re-scan for available updates - **Recheck** button to refresh the latest version from GitHub and re-scan for available updates
- **Update All** button to trigger updates on all remote nodes that have a pending update - **Update All** button to trigger updates on all remote nodes that have a pending update (Skipper or Admiral)
When you click **Update** on a remote node, Sencho sends the update command to the remote instance. The remote pulls the latest Docker image, then spawns a short-lived helper container that performs the compose recreate. The node restarts with the new version, and the status badge transitions from "Updating" to "Updated" once the gateway detects the version change. The "Updated" badge remains visible for 60 seconds before the node returns to "Up to date". When you click **Update** on a remote node, Sencho sends the update command to the remote instance. The remote pulls the latest Docker image, then spawns a short-lived helper container that performs the compose recreate. The node restarts with the new version, and the status badge transitions from "Updating" to "Updated" once the gateway detects the version change. The "Updated" badge remains visible for 60 seconds before the node returns to "Up to date".
+10 -4
View File
@@ -23,14 +23,19 @@ Lifetime pricing is an early-adopter offer available for a limited time only.
**Community** includes: **Community** includes:
- Unlimited nodes, compose editor, global logs, app store, alerts, and more - Unlimited nodes, compose editor, global logs, app store, alerts, and more
- Fleet View with search, sort, filters, node-card expand, and topology
- Manual fleet snapshots (create, browse, restore, delete)
- Per-node Sencho updates and the Check Updates view
- Vulnerability scanning: install/update/uninstall Trivy, on-demand scans (vulnerabilities, secrets, misconfigurations), scan comparison, and CVE suppressions
- Two-factor authentication (TOTP) - Two-factor authentication (TOTP)
- Custom OIDC single sign-on (works with Authelia, Keycloak, Authentik, Zitadel, Pocket ID, or any spec-compliant OIDC identity provider) - Custom OIDC single sign-on (works with Authelia, Keycloak, Authentik, Zitadel, Pocket ID, or any spec-compliant OIDC identity provider)
**Skipper** includes everything in Community, plus: **Skipper** includes everything in Community, plus:
- Fleet View with drill-down
- Webhooks and stack labels - Webhooks and stack labels
- Atomic deployments and fleet-wide backups - Atomic deployments
- Auto-update policies - Bulk **Update All** across the fleet, scheduled scans, scheduled updates, and scheduled fleet snapshots
- Scan policies with `block_on_deploy` enforcement, SBOM (SPDX, CycloneDX), and SARIF export
- Auto-update policies for stack images
- One-click Google, GitHub, and Okta SSO presets - One-click Google, GitHub, and Okta SSO presets
**Admiral** includes everything in Skipper, plus: **Admiral** includes everything in Skipper, plus:
@@ -40,7 +45,8 @@ Lifetime pricing is an early-adopter offer available for a limited time only.
- Audit log and host console - Audit log and host console
- API tokens and private registries - API tokens and private registries
- Notification routing - Notification routing
- Scheduled operations - Auto-update of the managed Trivy binary
- All other scheduled operations (restart, prune, etc.)
<Tip> <Tip>
**SSO is available on every tier.** Community users can integrate any OIDC-compliant identity provider through the Custom OIDC option. Paid tiers add turnkey presets (Google, GitHub, Okta) and LDAP / Active Directory. **SSO is available on every tier.** Community users can integrate any OIDC-compliant identity provider through the Custom OIDC option. Paid tiers add turnkey presets (Google, GitHub, Okta) and LDAP / Active Directory.
+4 -4
View File
@@ -57,7 +57,7 @@ Monitor your entire infrastructure from a single screen. The fleet dashboard sho
## Remote updates ## Remote updates
Check for outdated nodes and trigger over-the-air updates from the Fleet View. When the gateway is running a newer version than a remote node, a one-click update pulls the latest image and recreates the container automatically. [Learn more →](/features/remote-updates) Check for outdated nodes and trigger over-the-air updates from the Fleet View. When the gateway is running a newer version than a remote node, a one-click update pulls the latest image and recreates the container automatically. Per-node updates and the Check Updates view are available on every tier; the bulk **Update All** action is Skipper or Admiral. [Learn more →](/features/remote-updates)
## Alerts & notifications ## Alerts & notifications
@@ -81,7 +81,7 @@ Define schedules for Sencho to automatically check your container images for upd
## Scheduled operations ## Scheduled operations
Automate recurring maintenance tasks like stack restarts, fleet snapshots, and system prunes on a cron schedule. Every execution is logged with full history so you always know what ran and when. Admiral only. [Learn more →](/features/scheduled-operations) Automate recurring maintenance tasks like stack restarts, fleet snapshots, and system prunes on a cron schedule. Every execution is logged with full history so you always know what ran and when. Scheduled scans, updates, and snapshots are available on Skipper and Admiral; other scheduled actions remain Admiral only. [Learn more →](/features/scheduled-operations)
## RBAC & user management ## RBAC & user management
@@ -97,7 +97,7 @@ Skipper and Admiral users get automatic backup and rollback on every deployment.
## Fleet-wide backups ## Fleet-wide backups
Create point-in-time snapshots of every compose file and environment file across all nodes. Snapshots are stored centrally and can be browsed by node and stack. Restore individual stacks from any snapshot with optional one-click redeploy, even to remote nodes. [Learn more →](/features/fleet-backups) Create point-in-time snapshots of every compose file and environment file across all nodes. Snapshots are stored centrally and can be browsed by node and stack. Restore individual stacks from any snapshot with optional one-click redeploy, even to remote nodes. Manual snapshots are available on every tier; scheduled fleet snapshots are Skipper or Admiral. [Learn more →](/features/fleet-backups)
## Private registries ## Private registries
@@ -105,7 +105,7 @@ Store credentials for private Docker registries: Docker Hub organizations, GHCR,
## Vulnerability scanning ## Vulnerability scanning
Scan container images for known CVEs with [Trivy](https://trivy.dev). On-demand scanning and severity badges are available on every tier; scheduled scans, scan policies that gate deploys, SBOM generation, and scan history are available on Skipper and Admiral. [Learn more →](/features/vulnerability-scanning) Scan container images for known CVEs with [Trivy](https://trivy.dev). Manual scanning, secret and misconfiguration detection, scan comparison, and CVE suppressions are available on every tier; scheduled scans, scan policies that gate deploys, SBOM generation, and SARIF export are available on Skipper and Admiral. Auto-update of the managed Trivy binary is Admiral. [Learn more →](/features/vulnerability-scanning)
## Audit log ## Audit log
+1 -1
View File
@@ -6,7 +6,7 @@ description: Check for outdated nodes and trigger over-the-air Sencho updates fr
Sencho can update remote nodes directly from the dashboard. When a node is running an older version than the latest available release, a one-click update pulls the latest image and recreates the container automatically. This includes the local (gateway) node itself. Sencho can update remote nodes directly from the dashboard. When a node is running an older version than the latest available release, a one-click update pulls the latest image and recreates the container automatically. This includes the local (gateway) node itself.
<Note> <Note>
Remote updates require a **Skipper** or **Admiral** license. Per-node remote updates and the Check Updates view are available on every tier (admin role required). The bulk **Update All** action is a Skipper or Admiral feature.
</Note> </Note>
## Prerequisites ## Prerequisites
+4 -4
View File
@@ -4,7 +4,7 @@ description: Automate recurring Docker operations like stack restarts, lifecycle
--- ---
<Note> <Note>
Scheduled Operations requires a Sencho **Admiral** license. Skipper users see only the **Auto-update Stack** action; Admiral users see every action. Scheduled Operations is available to admins on Skipper and Admiral. Skipper unlocks **Auto-update Stack**, **Vulnerability Scan**, and **Fleet Snapshot**. All other actions (Restart Stack, System Prune, Backup Stack Files, Stop / Take Down / Start Stack) remain Admiral.
</Note> </Note>
## Overview ## Overview
@@ -36,7 +36,7 @@ Toggle to **All tasks** from the header to see every schedule in a table, regard
|--------|--------|-------------| |--------|--------|-------------|
| **Restart Stack** | A specific stack (or specific services within it) on a specific node | Restarts all or selected containers in the stack | | **Restart Stack** | A specific stack (or specific services within it) on a specific node | Restarts all or selected containers in the stack |
| **Auto-update Stack** | A specific stack on a specific node | Checks each image for updates and recreates the stack if any image has a newer version. See [Auto-Update Readiness](/features/auto-update-policies) for the companion board. Available on Skipper and Admiral. | | **Auto-update Stack** | A specific stack on a specific node | Checks each image for updates and recreates the stack if any image has a newer version. See [Auto-Update Readiness](/features/auto-update-policies) for the companion board. Available on Skipper and Admiral. |
| **Fleet Snapshot** | All nodes | Creates a fleet-wide backup of all compose files and `.env` files | | **Fleet Snapshot** | All nodes | Creates a fleet-wide backup of all compose files and `.env` files. Available on Skipper and Admiral. |
| **System Prune** | The default node | Prunes selected resources, optionally filtered by Docker label | | **System Prune** | The default node | Prunes selected resources, optionally filtered by Docker label |
| **Vulnerability Scan** | All images on a specific node | Runs Trivy against every image on the target node and records the results. Requires Trivy to be installed, see [Installing Trivy](/operations/trivy-setup). Available on Skipper and Admiral. | | **Vulnerability Scan** | All images on a specific node | Runs Trivy against every image on the target node and records the results. Requires Trivy to be installed, see [Installing Trivy](/operations/trivy-setup). Available on Skipper and Admiral. |
| **Backup Stack Files** | A specific stack on a specific node | Backs up the stack's compose file and `.env` to `<DATA_DIR>/backups/<stackName>/`. The most recent backup per stack is kept; each run overwrites the previous one. | | **Backup Stack Files** | A specific stack on a specific node | Backs up the stack's compose file and `.env` to `<DATA_DIR>/backups/<stackName>/`. The most recent backup per stack is kept; each run overwrites the previous one. |
@@ -46,7 +46,7 @@ Toggle to **All tasks** from the header to see every schedule in a table, regard
## Creating a Scheduled Task ## Creating a Scheduled Task
1. Navigate to the **Schedules** tab in the top navigation bar (visible to Admiral admins). 1. Navigate to the **Schedules** tab in the top navigation bar (visible to Skipper and Admiral admins).
2. Click **New Schedule**. 2. Click **New Schedule**.
3. Fill in the form: 3. Fill in the form:
- **Name**: A descriptive label (e.g. "Nightly staging restart"). - **Name**: A descriptive label (e.g. "Nightly staging restart").
@@ -212,7 +212,7 @@ Execution history is retained for 30 days.
The Scheduler Service runs in the background and checks for due tasks every 60 seconds. When a task's next run time has passed: The Scheduler Service runs in the background and checks for due tasks every 60 seconds. When a task's next run time has passed:
1. The scheduler verifies your Admiral license is active. 1. The scheduler verifies your license tier matches the action (Skipper for update, scan, snapshot; Admiral for everything else).
2. It executes the configured action using the same internal services that power the UI buttons (restart, snapshot, prune). 2. It executes the configured action using the same internal services that power the UI buttons (restart, snapshot, prune).
3. Results are logged to the execution history. 3. Results are logged to the execution history.
4. On failure, an alert is dispatched via your configured notification channels. 4. On failure, an alert is dispatched via your configured notification channels.
+9 -6
View File
@@ -3,7 +3,7 @@ title: "Vulnerability Scanning"
description: "Scan container images for known CVEs, surface severity badges in the Resources Hub, and alert on policy violations." description: "Scan container images for known CVEs, surface severity badges in the Resources Hub, and alert on policy violations."
--- ---
Sencho integrates with [Trivy](https://trivy.dev) to scan container images for known vulnerabilities (CVEs), surface severity badges next to your images, and alert when a scan result exceeds a configured threshold. On-demand scanning is available on every tier; automation, policies, and SBOM generation are Skipper and Admiral. Sencho integrates with [Trivy](https://trivy.dev) to scan container images for known vulnerabilities (CVEs), surface severity badges next to your images, and alert when a scan result exceeds a configured threshold. Manual scanning, secret and misconfiguration detection, scan comparison, and CVE suppressions are all available on every tier. Skipper and Admiral add automation, policy enforcement, and compliance exports.
<Frame> <Frame>
<img src="/images/vulnerability-scanning/resources-badges.png" alt="Resources Hub showing vulnerability severity badges next to image tags" /> <img src="/images/vulnerability-scanning/resources-badges.png" alt="Resources Hub showing vulnerability severity badges next to image tags" />
@@ -21,17 +21,20 @@ The Trivy CLI must be available on the machine running Sencho. Trivy is not bund
| Feature | Community | Skipper | Admiral | | Feature | Community | Skipper | Admiral |
|---------|:---------:|:-------:|:-------:| |---------|:---------:|:-------:|:-------:|
| On-demand image scanning | ✓ | ✓ | ✓ | | Install / update / uninstall Trivy from Settings | ✓ | ✓ | ✓ |
| On-demand image scanning (vulnerabilities) | ✓ | ✓ | ✓ |
| Severity badges in the Resources Hub | ✓ | ✓ | ✓ | | Severity badges in the Resources Hub | ✓ | ✓ | ✓ |
| Scan results drawer with vulnerability table | ✓ | ✓ | ✓ | | Scan results drawer with vulnerability table | ✓ | ✓ | ✓ |
| Post-deploy automated scanning | ✓ | ✓ | ✓ | | Post-deploy automated scanning | ✓ | ✓ | ✓ |
| Secret detection in image filesystems | ✓ | ✓ | ✓ |
| Compose file misconfiguration scanning | ✓ | ✓ | ✓ |
| Scan history and comparison | ✓ | ✓ | ✓ |
| CVE suppressions | ✓ | ✓ | ✓ |
| Scheduled fleet scans (all images on a node) | | ✓ | ✓ | | Scheduled fleet scans (all images on a node) | | ✓ | ✓ |
| Scan policies (warning and critical alerts) | | ✓ | ✓ | | Scan policies with `block_on_deploy` enforcement | | ✓ | ✓ |
| SBOM generation (SPDX, CycloneDX) | | ✓ | ✓ | | SBOM generation (SPDX, CycloneDX) | | ✓ | ✓ |
| Scan history and comparison | | ✓ | ✓ |
| Secret detection in image filesystems | | ✓ | ✓ |
| Compose file misconfiguration scanning | | ✓ | ✓ |
| SARIF export (code scanning integration) | | ✓ | ✓ | | SARIF export (code scanning integration) | | ✓ | ✓ |
| Auto-update of the managed Trivy binary | | | ✓ |
## On-demand scanning ## On-demand scanning
+3 -5
View File
@@ -44,7 +44,7 @@ When a newer Trivy release is available, Settings → Security shows an **Update
To update automatically instead, toggle **Auto-update Trivy** on. Sencho checks for new releases once a day and installs them in the background. You'll get an in-app notification each time a new version is installed, or when an update is available and auto-update is off. To update automatically instead, toggle **Auto-update Trivy** on. Sencho checks for new releases once a day and installs them in the background. You'll get an in-app notification each time a new version is installed, or when an update is available and auto-update is off.
The install, update, and uninstall buttons are Admiral-only. Skipper and Community instances see the scanner status, but install actions require an Admiral license. The install, update, and uninstall buttons are available to admins on every tier. The **Auto-update Trivy** toggle is Admiral only.
### Removing the managed install ### Removing the managed install
@@ -212,11 +212,9 @@ This means the host binary is not ABI-compatible with the Sencho image. Use the
The first scan after a Trivy install downloads the vulnerability database. Expect 10 to 30 seconds of additional latency. Subsequent scans are near-instant once the cache is warm and `TRIVY_CACHE_DIR` is persisted. The first scan after a Trivy install downloads the vulnerability database. Expect 10 to 30 seconds of additional latency. Subsequent scans are near-instant once the cache is warm and `TRIVY_CACHE_DIR` is persisted.
### Install button is disabled ### Install button is hidden
The install, update, and uninstall buttons require an Admiral license. If you hold a Skipper or Community license, the card shows current status only; use Option 2 or 3 to add Trivy manually. The install button is hidden when a host-installed Trivy is already detected on `PATH`. Remove the host binary (or drop the bind mount) to switch to the managed install. The button also requires the admin role; viewer accounts see the scanner status only.
The install button is also hidden when a host-installed Trivy is already detected on `PATH`. Remove the host binary (or drop the bind mount) to switch to the managed install.
### Private registry images fail to scan ### Private registry images fail to scan
+22 -29
View File
@@ -34,11 +34,10 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
const experimental = useExperimental(); const experimental = useExperimental();
const { prefs, updatePrefs } = useFleetPreferences(); const { prefs, updatePrefs } = useFleetPreferences();
const updateStatus = useFleetUpdateStatus({ isPaid }); const updateStatus = useFleetUpdateStatus();
const overview = useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses: updateStatus.updateStatuses }); const overview = useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses: updateStatus.updateStatuses });
useFleetPolling({ useFleetPolling({
isPaid,
fetchOverview: overview.fetchOverview, fetchOverview: overview.fetchOverview,
fetchUpdateStatus: updateStatus.fetchUpdateStatus, fetchUpdateStatus: updateStatus.fetchUpdateStatus,
updateStatuses: updateStatus.updateStatuses, updateStatuses: updateStatus.updateStatuses,
@@ -69,13 +68,11 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<TabsHighlightItem value="overview"> <TabsHighlightItem value="overview">
<TabsTrigger value="overview">Overview</TabsTrigger> <TabsTrigger value="overview">Overview</TabsTrigger>
</TabsHighlightItem> </TabsHighlightItem>
{isPaid && ( <TabsHighlightItem value="snapshots">
<TabsHighlightItem value="snapshots"> <TabsTrigger value="snapshots">
<TabsTrigger value="snapshots"> <Camera className="w-4 h-4 mr-1.5" />Snapshots
<Camera className="w-4 h-4 mr-1.5" />Snapshots </TabsTrigger>
</TabsTrigger> </TabsHighlightItem>
</TabsHighlightItem>
)}
{isAdmiral && experimental && ( {isAdmiral && experimental && (
<TabsHighlightItem value="routing"> <TabsHighlightItem value="routing">
<TabsTrigger value="routing"> <TabsTrigger value="routing">
@@ -114,17 +111,15 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
</TabsHighlight> </TabsHighlight>
</TabsList> </TabsList>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isPaid && ( <Button
<Button variant="outline"
variant="outline" size="sm"
size="sm" onClick={updateStatus.checkUpdates}
onClick={updateStatus.checkUpdates} className="gap-2"
className="gap-2" >
> <Search className="w-4 h-4" />
<Search className="w-4 h-4" /> Check Updates
Check Updates </Button>
</Button>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -155,22 +150,19 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
labelFilters={overview.labelFilters} labelFilters={overview.labelFilters}
onLabelFiltersChange={overview.setLabelFilters} onLabelFiltersChange={overview.setLabelFilters}
onClearFilters={overview.clearFilters} onClearFilters={overview.clearFilters}
isPaid={isPaid}
fleetStackLabelMap={overview.fleetStackLabelMap} fleetStackLabelMap={overview.fleetStackLabelMap}
updateStatusMap={overview.updateStatusMap} updateStatusMap={overview.updateStatusMap}
onNavigateToNode={onNavigateToNode} onNavigateToNode={onNavigateToNode}
onUpdate={isPaid ? updateStatus.triggerNodeUpdate : undefined} onUpdate={updateStatus.triggerNodeUpdate}
updatingNodeId={updateStatus.updatingNodeId} updatingNodeId={updateStatus.updatingNodeId}
onRetryUpdate={isPaid ? updateStatus.retryNodeUpdate : undefined} onRetryUpdate={updateStatus.retryNodeUpdate}
onDismissUpdate={isPaid ? updateStatus.dismissNodeUpdate : undefined} onDismissUpdate={updateStatus.dismissNodeUpdate}
/> />
</TabsContent> </TabsContent>
{isPaid && ( <TabsContent value="snapshots">
<TabsContent value="snapshots"> <FleetSnapshots />
<FleetSnapshots /> </TabsContent>
</TabsContent>
)}
{isAdmiral && experimental && ( {isAdmiral && experimental && (
<TabsContent value="routing"> <TabsContent value="routing">
<AdmiralGate> <AdmiralGate>
@@ -233,6 +225,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
retryNodeUpdate={updateStatus.retryNodeUpdate} retryNodeUpdate={updateStatus.retryNodeUpdate}
dismissNodeUpdate={updateStatus.dismissNodeUpdate} dismissNodeUpdate={updateStatus.dismissNodeUpdate}
triggerUpdateAll={updateStatus.triggerUpdateAll} triggerUpdateAll={updateStatus.triggerUpdateAll}
canBulkUpdate={isPaid}
/> />
<LocalUpdateConfirmDialog <LocalUpdateConfirmDialog
@@ -8,7 +8,6 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { formatBytes } from '@/lib/utils'; import { formatBytes } from '@/lib/utils';
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import { useLicense } from '@/context/LicenseContext';
import { toast } from '@/components/ui/toast-store'; import { toast } from '@/components/ui/toast-store';
import { formatVersion } from '@/lib/version'; import { formatVersion } from '@/lib/version';
import { UpdateStatusBadge } from './UpdateStatusBadge'; import { UpdateStatusBadge } from './UpdateStatusBadge';
@@ -46,7 +45,6 @@ function UsageBar({ percent, color }: { percent: number; color: string }) {
// --- Main Export --- // --- Main Export ---
export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate }: NodeCardProps) { export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate }: NodeCardProps) {
const { isPaid } = useLicense();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [stacks, setStacks] = useState<string[] | null>(node.stacks); const [stacks, setStacks] = useState<string[] | null>(node.stacks);
const [loadingStacks, setLoadingStacks] = useState(false); const [loadingStacks, setLoadingStacks] = useState(false);
@@ -60,7 +58,6 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
const diskPercent = getNodeDisk(node); const diskPercent = getNodeDisk(node);
const handleExpand = async () => { const handleExpand = async () => {
if (!isPaid) return;
const next = !expanded; const next = !expanded;
setExpanded(next); setExpanded(next);
@@ -222,8 +219,8 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
)} )}
</div> </div>
{/* Paid: Expandable Stack List with Container Drill-Down */} {/* Expandable Stack List with Container Drill-Down */}
{isOnline && isPaid && ( {isOnline && (
<div className="border-t"> <div className="border-t">
<button <button
onClick={handleExpand} onClick={handleExpand}
@@ -24,11 +24,13 @@ interface NodeUpdatesSheetProps {
retryNodeUpdate: (nodeId: number) => void; retryNodeUpdate: (nodeId: number) => void;
dismissNodeUpdate: (nodeId: number) => void; dismissNodeUpdate: (nodeId: number) => void;
triggerUpdateAll: () => Promise<void>; triggerUpdateAll: () => Promise<void>;
canBulkUpdate: boolean;
} }
export function NodeUpdatesSheet({ export function NodeUpdatesSheet({
open, onOpenChange, checkingUpdates, updateStatuses, updatingNodeId, open, onOpenChange, checkingUpdates, updateStatuses, updatingNodeId,
fetchUpdateStatus, triggerNodeUpdate, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll, fetchUpdateStatus, triggerNodeUpdate, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll,
canBulkUpdate,
}: NodeUpdatesSheetProps) { }: NodeUpdatesSheetProps) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [recheckingUpdates, setRecheckingUpdates] = useState(false); const [recheckingUpdates, setRecheckingUpdates] = useState(false);
@@ -215,7 +217,7 @@ export function NodeUpdatesSheet({
<RefreshCw className={`w-3 h-3 mr-1.5 ${recheckingUpdates ? 'animate-spin' : ''}`} strokeWidth={1.5} /> <RefreshCw className={`w-3 h-3 mr-1.5 ${recheckingUpdates ? 'animate-spin' : ''}`} strokeWidth={1.5} />
Recheck Recheck
</Button> </Button>
{updatableRemoteCount > 0 && ( {canBulkUpdate && updatableRemoteCount > 0 && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -24,7 +24,6 @@ interface OverviewTabProps {
labelFilters: Set<string>; labelFilters: Set<string>;
onLabelFiltersChange: (filters: Set<string>) => void; onLabelFiltersChange: (filters: Set<string>) => void;
onClearFilters: () => void; onClearFilters: () => void;
isPaid: boolean;
fleetStackLabelMap: Record<number, Record<string, StackLabel[]>>; fleetStackLabelMap: Record<number, Record<string, StackLabel[]>>;
updateStatusMap: Map<number, NodeUpdateStatus>; updateStatusMap: Map<number, NodeUpdateStatus>;
onNavigateToNode: (nodeId: number, stackName: string) => void; onNavigateToNode: (nodeId: number, stackName: string) => void;
@@ -50,7 +49,6 @@ export function OverviewTab({
labelFilters, labelFilters,
onLabelFiltersChange, onLabelFiltersChange,
onClearFilters, onClearFilters,
isPaid,
fleetStackLabelMap, fleetStackLabelMap,
updateStatusMap, updateStatusMap,
onNavigateToNode, onNavigateToNode,
@@ -90,7 +88,6 @@ export function OverviewTab({
{!loading && nodes.length > 0 && ( {!loading && nodes.length > 0 && (
<> <>
<OverviewToolbar <OverviewToolbar
isPaid={isPaid}
viewMode={viewMode} viewMode={viewMode}
onViewModeChange={onViewModeChange} onViewModeChange={onViewModeChange}
searchQuery={searchQuery} searchQuery={searchQuery}
@@ -144,11 +141,9 @@ export function OverviewTab({
</div> </div>
)} )}
{isPaid && ( <p className="text-xs text-muted-foreground text-center mt-6">
<p className="text-xs text-muted-foreground text-center mt-6"> Auto-refreshing every 30 seconds
Auto-refreshing every 30 seconds </p>
</p>
)}
</> </>
)} )}
</> </>
@@ -39,7 +39,6 @@ function renderPaletteOption(option: { label: string; color?: string }) {
} }
interface OverviewToolbarProps { interface OverviewToolbarProps {
isPaid: boolean;
viewMode: ViewMode; viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void; onViewModeChange: (mode: ViewMode) => void;
searchQuery: string; searchQuery: string;
@@ -53,7 +52,6 @@ interface OverviewToolbarProps {
} }
export function OverviewToolbar({ export function OverviewToolbar({
isPaid,
viewMode, viewMode,
onViewModeChange, onViewModeChange,
searchQuery, searchQuery,
@@ -65,7 +63,7 @@ export function OverviewToolbar({
onLabelFiltersChange, onLabelFiltersChange,
onClearFilters, onClearFilters,
}: OverviewToolbarProps) { }: OverviewToolbarProps) {
const showPaidControls = isPaid && viewMode === 'grid'; const showGridControls = viewMode === 'grid';
const activeFilterCount = const activeFilterCount =
(prefs.filterStatus !== 'all' ? 1 : 0) + (prefs.filterStatus !== 'all' ? 1 : 0) +
(prefs.filterType !== 'all' ? 1 : 0) + (prefs.filterType !== 'all' ? 1 : 0) +
@@ -79,7 +77,7 @@ export function OverviewToolbar({
return ( return (
<div className="flex flex-wrap items-center gap-2 mb-4"> <div className="flex flex-wrap items-center gap-2 mb-4">
{showPaidControls && ( {showGridControls && (
<> <>
<div className="relative flex-1 min-w-[200px] max-w-sm"> <div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
@@ -103,48 +103,46 @@ export function useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses }:
); );
} }
if (isPaid) { if (prefs.filterStatus === 'online') filtered = filtered.filter(n => n.status === 'online');
if (prefs.filterStatus === 'online') filtered = filtered.filter(n => n.status === 'online'); if (prefs.filterStatus === 'offline') filtered = filtered.filter(n => n.status !== 'online');
if (prefs.filterStatus === 'offline') filtered = filtered.filter(n => n.status !== 'online'); if (prefs.filterType === 'local') filtered = filtered.filter(n => n.type === 'local');
if (prefs.filterType === 'local') filtered = filtered.filter(n => n.type === 'local'); if (prefs.filterType === 'remote') filtered = filtered.filter(n => n.type !== 'local');
if (prefs.filterType === 'remote') filtered = filtered.filter(n => n.type !== 'local'); if (prefs.filterCritical) filtered = filtered.filter(isCritical);
if (prefs.filterCritical) filtered = filtered.filter(isCritical);
if (labelFilters.size > 0) { if (labelFilters.size > 0) {
filtered = filtered.filter(n => { filtered = filtered.filter(n => {
const nodeStackLabels = fleetStackLabelMap[n.id] ?? {}; const nodeStackLabels = fleetStackLabelMap[n.id] ?? {};
return n.stacks?.some(s => { return n.stacks?.some(s => {
const sLabels = nodeStackLabels[s] ?? []; const sLabels = nodeStackLabels[s] ?? [];
return sLabels.some(l => labelFilters.has(labelPaletteKey(l.name, l.color))); return sLabels.some(l => labelFilters.has(labelPaletteKey(l.name, l.color)));
});
}); });
}
filtered.sort((a, b) => {
let cmp = 0;
switch (prefs.sortBy) {
case 'name':
cmp = a.name.localeCompare(b.name);
break;
case 'cpu':
cmp = getNodeCpu(b) - getNodeCpu(a);
break;
case 'memory':
cmp = getNodeMem(b) - getNodeMem(a);
break;
case 'containers':
cmp = (b.stats?.active ?? 0) - (a.stats?.active ?? 0);
break;
case 'status':
cmp = (a.status === 'online' ? 0 : 1) - (b.status === 'online' ? 0 : 1);
break;
}
return prefs.sortDir === 'desc' ? -cmp : cmp;
}); });
} }
filtered.sort((a, b) => {
let cmp = 0;
switch (prefs.sortBy) {
case 'name':
cmp = a.name.localeCompare(b.name);
break;
case 'cpu':
cmp = getNodeCpu(b) - getNodeCpu(a);
break;
case 'memory':
cmp = getNodeMem(b) - getNodeMem(a);
break;
case 'containers':
cmp = (b.stats?.active ?? 0) - (a.stats?.active ?? 0);
break;
case 'status':
cmp = (a.status === 'online' ? 0 : 1) - (b.status === 'online' ? 0 : 1);
break;
}
return prefs.sortDir === 'desc' ? -cmp : cmp;
});
return filtered; return filtered;
}, [nodes, searchQuery, isPaid, prefs, labelFilters, fleetStackLabelMap]); }, [nodes, searchQuery, prefs, labelFilters, fleetStackLabelMap]);
const localNode = useMemo( const localNode = useMemo(
() => processedNodes.find(n => n.type === 'local') ?? null, () => processedNodes.find(n => n.type === 'local') ?? null,
@@ -2,14 +2,12 @@ import { useEffect, useRef } from 'react';
import type { NodeUpdateStatus } from '../types'; import type { NodeUpdateStatus } from '../types';
interface UseFleetPollingOptions { interface UseFleetPollingOptions {
isPaid: boolean;
fetchOverview: () => Promise<void> | void; fetchOverview: () => Promise<void> | void;
fetchUpdateStatus: () => Promise<void> | void; fetchUpdateStatus: () => Promise<void> | void;
updateStatuses: NodeUpdateStatus[]; updateStatuses: NodeUpdateStatus[];
} }
export function useFleetPolling({ export function useFleetPolling({
isPaid,
fetchOverview, fetchOverview,
fetchUpdateStatus, fetchUpdateStatus,
updateStatuses, updateStatuses,
@@ -19,13 +17,12 @@ export function useFleetPolling({
fetchUpdateStatus(); fetchUpdateStatus();
}, [fetchOverview, fetchUpdateStatus]); }, [fetchOverview, fetchUpdateStatus]);
// Paid tier: auto-refresh every 30s // Auto-refresh every 30s for overview, every 2 min for update status.
useEffect(() => { useEffect(() => {
if (!isPaid) return;
const overviewInterval = setInterval(fetchOverview, 30000); const overviewInterval = setInterval(fetchOverview, 30000);
const updateInterval = setInterval(fetchUpdateStatus, 120000); const updateInterval = setInterval(fetchUpdateStatus, 120000);
return () => { clearInterval(overviewInterval); clearInterval(updateInterval); }; return () => { clearInterval(overviewInterval); clearInterval(updateInterval); };
}, [isPaid, fetchOverview, fetchUpdateStatus]); }, [fetchOverview, fetchUpdateStatus]);
// Fast poll (5s) when any node is actively updating. Uses ref to avoid interval thrashing. // Fast poll (5s) when any node is actively updating. Uses ref to avoid interval thrashing.
const hasUpdatingRef = useRef(false); const hasUpdatingRef = useRef(false);
@@ -3,11 +3,7 @@ import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store'; import { toast } from '@/components/ui/toast-store';
import type { NodeUpdateStatus } from '../types'; import type { NodeUpdateStatus } from '../types';
interface UseFleetUpdateStatusOptions { export function useFleetUpdateStatus() {
isPaid: boolean;
}
export function useFleetUpdateStatus({ isPaid }: UseFleetUpdateStatusOptions) {
const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]); const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]);
const [updatingNodeId, setUpdatingNodeId] = useState<number | null>(null); const [updatingNodeId, setUpdatingNodeId] = useState<number | null>(null);
const [reconnecting, setReconnecting] = useState(false); const [reconnecting, setReconnecting] = useState(false);
@@ -22,7 +18,6 @@ export function useFleetUpdateStatus({ isPaid }: UseFleetUpdateStatusOptions) {
updateStatusesRef.current = updateStatuses; updateStatusesRef.current = updateStatuses;
const fetchUpdateStatus = useCallback(async () => { const fetchUpdateStatus = useCallback(async () => {
if (!isPaid) return;
try { try {
const res = await apiFetch('/fleet/update-status', { localOnly: true }); const res = await apiFetch('/fleet/update-status', { localOnly: true });
if (res.ok) { if (res.ok) {
@@ -33,7 +28,7 @@ export function useFleetUpdateStatus({ isPaid }: UseFleetUpdateStatusOptions) {
); );
} }
} catch { /* non-critical */ } } catch { /* non-critical */ }
}, [isPaid]); }, []);
const triggerNodeUpdate = useCallback(async (nodeId: number) => { const triggerNodeUpdate = useCallback(async (nodeId: number) => {
const status = updateStatusesRef.current.find(s => s.nodeId === nodeId); const status = updateStatusesRef.current.find(s => s.nodeId === nodeId);
@@ -26,7 +26,6 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { toast } from '@/components/ui/toast-store'; import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
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 { SettingsCallout } from './SettingsCallout'; import { SettingsCallout } from './SettingsCallout';
import { SettingsPrimaryButton } from './SettingsActions'; import { SettingsPrimaryButton } from './SettingsActions';
@@ -267,31 +266,18 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
loading loading
? null ? null
: [ : [
{ label: 'POLICIES', value: `${policies.length}` }, ...(isPaid ? [{ label: 'POLICIES', value: `${policies.length}` }] : []),
{ {
label: 'TRIVY', label: 'TRIVY',
value: trivy.source === 'none' ? 'missing' : trivy.source, value: trivy.source === 'none' ? 'missing' : trivy.source,
tone: trivy.source === 'none' ? 'warn' : 'value', tone: trivy.source === 'none' ? 'warn' : 'value' as const,
}, },
], ],
); );
if (!isPaid) {
return (
<div className="space-y-6">
<PaidGate>
<div className="space-y-3">
<div className="h-16 rounded-lg border bg-card" />
<div className="h-16 rounded-lg border bg-card" />
</div>
</PaidGate>
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{!isRemote && !isReplica && ( {isPaid && !isRemote && !isReplica && (
<div className="flex justify-end"> <div className="flex justify-end">
<SettingsPrimaryButton size="sm" onClick={openCreate}> <SettingsPrimaryButton size="sm" onClick={openCreate}>
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
@@ -330,41 +316,39 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
</Badge> </Badge>
)} )}
</div> </div>
{isAdmiral && ( <div className="flex items-center gap-2 shrink-0">
<div className="flex items-center gap-2 shrink-0"> {trivy.source === 'none' && (
{trivy.source === 'none' && ( <SettingsPrimaryButton size="sm" onClick={handleInstallTrivy} disabled={trivyBusy !== null}>
<SettingsPrimaryButton size="sm" onClick={handleInstallTrivy} disabled={trivyBusy !== null}> {trivyBusy === 'install' ? (
{trivyBusy === 'install' ? ( <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} /> ) : (
) : ( <Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} /> )}
)} Install Trivy
Install Trivy </SettingsPrimaryButton>
</SettingsPrimaryButton> )}
)} {trivy.source === 'managed' && updateCheck?.updateAvailable && (
{trivy.source === 'managed' && updateCheck?.updateAvailable && ( <Button size="sm" variant="outline" onClick={handleUpdateTrivy} disabled={trivyBusy !== null}>
<Button size="sm" variant="outline" onClick={handleUpdateTrivy} disabled={trivyBusy !== null}> {trivyBusy === 'update' ? (
{trivyBusy === 'update' ? ( <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} /> ) : (
) : ( <RefreshCw className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
<RefreshCw className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} /> )}
)} Update
Update </Button>
</Button> )}
)} {trivy.source === 'managed' && (
{trivy.source === 'managed' && ( <Button
<Button size="sm"
size="sm" variant="ghost"
variant="ghost" className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground" onClick={() => setUninstallConfirm(true)}
onClick={() => setUninstallConfirm(true)} disabled={trivyBusy !== null}
disabled={trivyBusy !== null} >
> Uninstall
Uninstall </Button>
</Button> )}
)} </div>
</div>
)}
</div> </div>
{trivy.source === 'managed' && trivy.version && ( {trivy.source === 'managed' && trivy.version && (
@@ -414,7 +398,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
</div> </div>
)} )}
{!isRemote && !loading && policies.length === 0 && ( {isPaid && !isRemote && !loading && policies.length === 0 && (
<SettingsCallout <SettingsCallout
icon={<ShieldCheck className="h-4 w-4" />} icon={<ShieldCheck className="h-4 w-4" />}
title="No scan policies configured" title="No scan policies configured"
@@ -422,7 +406,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
/> />
)} )}
{!isRemote && !loading && {isPaid && !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">
@@ -476,7 +460,9 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
{!isRemote && <SuppressionsPanel isReplica={isReplica} />} {!isRemote && <SuppressionsPanel isReplica={isReplica} />}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> {isPaid && (
<>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{editingId ? 'Edit Policy' : 'New Policy'}</DialogTitle> <DialogTitle>{editingId ? 'Edit Policy' : 'New Policy'}</DialogTitle>
@@ -548,25 +534,27 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<AlertDialog open={deleteId != null} onOpenChange={(open) => !open && setDeleteId(null)}> <AlertDialog open={deleteId != null} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete scan policy?</AlertDialogTitle> <AlertDialogTitle>Delete scan policy?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This removes the policy immediately. Existing scans are not affected. This removes the policy immediately. Existing scans are not affected.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleDelete} onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
Delete Delete
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</>
)}
<AlertDialog open={uninstallConfirm} onOpenChange={setUninstallConfirm}> <AlertDialog open={uninstallConfirm} onOpenChange={setUninstallConfirm}>
<AlertDialogContent> <AlertDialogContent>
+1 -1
View File
@@ -180,7 +180,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
label: 'Security', label: 'Security',
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: null,
scope: 'node', scope: 'node',
adminOnly: true, adminOnly: true,
}, },