mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
feat(fleet): add Fleet Actions tab for cross-node bulk operations (#963)
* feat(fleet): add Fleet Actions tab for cross-node bulk operations Introduces a new "Actions" sub-tab in Fleet view with two Skipper+ cards that fill gaps in the existing surface: - Stop fleet by label: matches a label name across every node and stops every stack assigned to it, reporting per-node and per-stack results. - Bulk label assign: applies the same label set to many stacks on one node in a single round trip. Other bulk operations stay in their existing homes (sidebar bulk mode, Schedules, NodeUpdatesSheet) to avoid duplicate surfaces. Backend: - POST /api/fleet/labels/fleet-stop (gateway-orchestrated, multi-node) - POST /api/fleet-actions/labels/bulk-assign (per-node, capped at 1000) - Tightens /api/fleet proxy-exempt prefix to /api/fleet/ so /api/fleet-actions/* is routed through the proxy for per-node calls. - Exports activeBulkActions from labels.ts so fleet-stop and label-action share the per-node lock and cannot double-stop the same containers. - Extracts containerActionForStack helper from stacks.ts for reuse. * chore(fleet): rename Actions tab to Fleet Actions and reorder Fleet sub-tabs - Tab label "Actions" -> "Fleet Actions" so the surface is unambiguous alongside Schedules and the sidebar bulk bar. - Reorder Fleet sub-tabs as Overview / Snapshots / Status | Deployments / Traffic / Fleet Actions, with the separator after Status. - Rename "Traffic · Routing" -> "Traffic" and update Sencho Mesh docs to match the shorter label. - Update Fleet Actions docs to the new tab name and placement.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Tests for the Fleet Actions tab endpoints. Covers auth, tier gating, input
|
||||
* validation, and orchestration shape across the two routes.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
authHeader = `Bearer ${token}`;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
function mockTier(tier: 'paid' | 'community') {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
|
||||
}
|
||||
|
||||
describe('Fleet Actions endpoints require authentication', () => {
|
||||
it('POST /api/fleet-actions/labels/bulk-assign returns 401 without auth', async () => {
|
||||
const res = await request(app).post('/api/fleet-actions/labels/bulk-assign').send({ assignments: [] });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('POST /api/fleet/labels/fleet-stop returns 401 without auth', async () => {
|
||||
const res = await request(app).post('/api/fleet/labels/fleet-stop').send({ labelName: 'prod' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fleet Actions tier gating', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('POST /api/fleet/labels/fleet-stop returns 403 on community tier (Skipper+)', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'prod' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/bulk-assign returns 403 on community tier (Skipper+)', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ assignments: [] });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fleet Actions input validation', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('POST /api/fleet/labels/fleet-stop rejects missing labelName', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/labelName/);
|
||||
});
|
||||
|
||||
it('POST /api/fleet/labels/fleet-stop rejects whitespace-only labelName', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: ' ' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/bulk-assign rejects non-array assignments', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ assignments: 'oops' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/assignments must be an array/);
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/bulk-assign rejects oversized payload', async () => {
|
||||
mockTier('paid');
|
||||
const big = Array.from({ length: 1001 }, (_, i) => ({ stackName: `s${i}`, labelIds: [] }));
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ assignments: big });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/may not exceed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fleet Actions orchestration shape', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('POST /api/fleet/labels/fleet-stop with unknown label returns matched:false per node', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'this-label-does-not-exist' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.results)).toBe(true);
|
||||
for (const row of res.body.results) {
|
||||
expect(row.matched).toBe(false);
|
||||
expect(row.stackResults).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/bulk-assign accepts empty assignments and returns empty results', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ assignments: [] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toEqual([]);
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/bulk-assign rejects an entry with bad stack name in-line', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ assignments: [{ stackName: 'has spaces!', labelIds: [1] }] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results[0]).toMatchObject({ success: false, error: 'Invalid stack name' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user