mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 07:36:40 +00:00
fix(labels): harden stack labels with nodeId filtering, concurrency guard, and test coverage (#552)
* fix(labels): add nodeId filter, existence check, stale cleanup, concurrency guard, and body validation - Fix getStacksForLabel to filter by node_id (prevents cross-node data leak) - Add getLabel(id, nodeId) for single-label existence check - Add getLabelCount(nodeId) for enforcing per-node label limit (50) - Add cleanupStaleAssignments to remove orphaned assignments for deleted stacks - Add label/assignment cleanup to deleteNode transaction - Add label existence check on bulk action endpoint (returns 404 for missing labels) - Add concurrency guard on bulk actions (returns 429 if already in-flight) - Add requireBody guard on all mutation endpoints - Extract isSqliteUniqueViolation helper to deduplicate constraint checks - Add MAX_LABELS_PER_NODE constant (50) with limit enforcement on create - Add diagnostic logging on all label endpoints (gated behind developer_mode) - Add operational log line for bulk action results * fix(labels): use ScrollArea, show failure details, add loading feedback, deduplicate constants - Replace overflow-y-auto div with ScrollArea in LabelAssignPopover (design system) - Show failed stack names in bulk action error toast - Add loading toast for context menu label toggle - Disable bulk action menu items while a bulk action is running - Disable "New Label" button at 50-label limit with "Limit reached" text - Export LABEL_COLORS and MAX_LABELS_PER_NODE from LabelPill (single source of truth) - Import shared constants in LabelAssignPopover and LabelsSection (remove duplicates) - Add BulkActionResult interface to replace inline type assertion * test(labels): add comprehensive coverage for label CRUD, assignments, and bulk edge cases 42 tests covering: - getLabels: empty, ordered, node isolation - getLabel: found, wrong node, nonexistent - createLabel: returns with ID, duplicate name constraint - getLabelCount: correct count, zero for empty node - updateLabel: name, color, both, not found, wrong node - deleteLabel: removes label, cascades assignments, wrong node no-op - setStackLabels: assign, replace, clear, invalid ID throws - getLabelsForStacks: correct mapping, empty result - getStacksForLabel: correct results, node filter, empty for nonexistent - cleanupStaleAssignments: removes stale, preserves valid, handles empty - deleteNode: cascades labels and assignments - Edge cases: atomicity, cascade across stacks, multi-label assignment * docs(labels): document 50-label limit and bulk action failure details * fix(labels): add missing LabelColor type imports and explicit parameter types * refactor(labels): extract label types and constants to label-types.ts Moves LabelColor, Label, LABEL_COLORS, and MAX_LABELS_PER_NODE out of LabelPill.tsx into a dedicated non-component file. This fixes the react-refresh/only-export-components lint error caused by mixing constant exports with component exports.
This commit is contained in:
@@ -0,0 +1,614 @@
|
||||
/**
|
||||
* Tests for Stack Labels feature: DatabaseService methods and cascade behavior.
|
||||
*
|
||||
* Uses an in-memory SQLite database (via better-sqlite3 directly)
|
||||
* to test actual SQL behavior without touching disk.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'local',
|
||||
status TEXT DEFAULT 'online',
|
||||
is_default INTEGER DEFAULT 0,
|
||||
api_url TEXT,
|
||||
api_token TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stack_labels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
UNIQUE(node_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stack_label_assignments (
|
||||
label_id INTEGER NOT NULL REFERENCES stack_labels(id) ON DELETE CASCADE,
|
||||
stack_name TEXT NOT NULL,
|
||||
node_id INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (label_id, stack_name, node_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_label_assignments_stack
|
||||
ON stack_label_assignments(stack_name, node_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduled_task_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
started_at INTEGER NOT NULL,
|
||||
completed_at INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
output TEXT,
|
||||
error TEXT,
|
||||
triggered_by TEXT DEFAULT 'scheduler'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduled_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL DEFAULT 'stack',
|
||||
target_id TEXT,
|
||||
node_id INTEGER,
|
||||
action TEXT NOT NULL DEFAULT 'update',
|
||||
cron_expression TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_by TEXT NOT NULL DEFAULT 'admin',
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
last_run_at INTEGER,
|
||||
next_run_at INTEGER,
|
||||
last_status TEXT,
|
||||
last_error TEXT,
|
||||
prune_targets TEXT,
|
||||
target_services TEXT,
|
||||
prune_label_filter TEXT,
|
||||
FOREIGN KEY(node_id) REFERENCES nodes(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stack_update_status (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL,
|
||||
stack_name TEXT NOT NULL,
|
||||
has_update INTEGER DEFAULT 0,
|
||||
checked_at INTEGER
|
||||
);
|
||||
`;
|
||||
|
||||
function execStatements(db: Database.Database, sql: string) {
|
||||
const statements = sql.split(';').map(s => s.trim()).filter(Boolean);
|
||||
for (const stmt of statements) {
|
||||
db.prepare(stmt).run();
|
||||
}
|
||||
}
|
||||
|
||||
describe('Stack Labels (in-memory SQLite)', () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
execStatements(db, SCHEMA);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function insertNode(name = 'test-node'): number {
|
||||
return db.prepare(
|
||||
'INSERT INTO nodes (name, type, status, is_default) VALUES (?, ?, ?, ?)'
|
||||
).run(name, 'local', 'online', 0).lastInsertRowid as number;
|
||||
}
|
||||
|
||||
interface Label { id: number; node_id: number; name: string; color: string }
|
||||
|
||||
function createLabel(nodeId: number, name: string, color: string): Label {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO stack_labels (node_id, name, color) VALUES (?, ?, ?)'
|
||||
).run(nodeId, name, color);
|
||||
return { id: result.lastInsertRowid as number, node_id: nodeId, name, color };
|
||||
}
|
||||
|
||||
function getLabel(id: number, nodeId: number): Label | null {
|
||||
return (db.prepare('SELECT * FROM stack_labels WHERE id = ? AND node_id = ?')
|
||||
.get(id, nodeId) as Label) ?? null;
|
||||
}
|
||||
|
||||
function getLabels(nodeId: number): Label[] {
|
||||
return db.prepare('SELECT * FROM stack_labels WHERE node_id = ? ORDER BY name')
|
||||
.all(nodeId) as Label[];
|
||||
}
|
||||
|
||||
function getLabelCount(nodeId: number): number {
|
||||
return (db.prepare('SELECT COUNT(*) as cnt FROM stack_labels WHERE node_id = ?')
|
||||
.get(nodeId) as { cnt: number }).cnt;
|
||||
}
|
||||
|
||||
function updateLabel(id: number, nodeId: number, updates: { name?: string; color?: string }): Label | null {
|
||||
const label = db.prepare('SELECT * FROM stack_labels WHERE id = ? AND node_id = ?').get(id, nodeId) as Label | undefined;
|
||||
if (!label) return null;
|
||||
const name = updates.name ?? label.name;
|
||||
const color = updates.color ?? label.color;
|
||||
db.prepare('UPDATE stack_labels SET name = ?, color = ? WHERE id = ? AND node_id = ?').run(name, color, id, nodeId);
|
||||
return { ...label, name, color };
|
||||
}
|
||||
|
||||
function deleteLabel(id: number, nodeId: number): void {
|
||||
db.prepare('DELETE FROM stack_labels WHERE id = ? AND node_id = ?').run(id, nodeId);
|
||||
}
|
||||
|
||||
function setStackLabels(stackName: string, nodeId: number, labelIds: number[]): void {
|
||||
const txn = db.transaction(() => {
|
||||
if (labelIds.length > 0) {
|
||||
const placeholders = labelIds.map(() => '?').join(',');
|
||||
const validCount = db.prepare(
|
||||
`SELECT COUNT(*) as cnt FROM stack_labels WHERE id IN (${placeholders}) AND node_id = ?`
|
||||
).get(...labelIds, nodeId) as { cnt: number };
|
||||
if (validCount.cnt !== labelIds.length) {
|
||||
throw new Error('One or more label IDs are invalid for this node');
|
||||
}
|
||||
}
|
||||
db.prepare('DELETE FROM stack_label_assignments WHERE stack_name = ? AND node_id = ?').run(stackName, nodeId);
|
||||
const insert = db.prepare('INSERT INTO stack_label_assignments (label_id, stack_name, node_id) VALUES (?, ?, ?)');
|
||||
for (const labelId of labelIds) {
|
||||
insert.run(labelId, stackName, nodeId);
|
||||
}
|
||||
});
|
||||
txn();
|
||||
}
|
||||
|
||||
function getLabelsForStacks(nodeId: number): Record<string, Label[]> {
|
||||
const rows = db.prepare(`
|
||||
SELECT a.stack_name, l.id, l.node_id, l.name, l.color
|
||||
FROM stack_label_assignments a
|
||||
JOIN stack_labels l ON a.label_id = l.id
|
||||
WHERE a.node_id = ?
|
||||
ORDER BY l.name
|
||||
`).all(nodeId) as (Label & { stack_name: string })[];
|
||||
const result: Record<string, Label[]> = {};
|
||||
for (const row of rows) {
|
||||
if (!result[row.stack_name]) result[row.stack_name] = [];
|
||||
result[row.stack_name].push({ id: row.id, node_id: row.node_id, name: row.name, color: row.color });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getStacksForLabel(labelId: number, nodeId: number): string[] {
|
||||
const rows = db.prepare('SELECT stack_name FROM stack_label_assignments WHERE label_id = ? AND node_id = ?')
|
||||
.all(labelId, nodeId) as { stack_name: string }[];
|
||||
return rows.map(r => r.stack_name);
|
||||
}
|
||||
|
||||
function cleanupStaleAssignments(nodeId: number, validStackNames: string[]): number {
|
||||
if (validStackNames.length === 0) {
|
||||
return db.prepare('DELETE FROM stack_label_assignments WHERE node_id = ?').run(nodeId).changes;
|
||||
}
|
||||
const placeholders = validStackNames.map(() => '?').join(',');
|
||||
return db.prepare(
|
||||
`DELETE FROM stack_label_assignments WHERE node_id = ? AND stack_name NOT IN (${placeholders})`
|
||||
).run(nodeId, ...validStackNames).changes;
|
||||
}
|
||||
|
||||
function countRows(table: string): number {
|
||||
return (db.prepare(`SELECT COUNT(*) as c FROM ${table}`).get() as { c: number }).c;
|
||||
}
|
||||
|
||||
// ── getLabels ──────────────────────────────────────────────────────
|
||||
|
||||
describe('getLabels', () => {
|
||||
it('returns empty array for node with no labels', () => {
|
||||
expect(getLabels(0)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns labels ordered by name', () => {
|
||||
createLabel(0, 'Zulu', 'teal');
|
||||
createLabel(0, 'Alpha', 'blue');
|
||||
createLabel(0, 'Mike', 'rose');
|
||||
const labels = getLabels(0);
|
||||
expect(labels.map(l => l.name)).toEqual(['Alpha', 'Mike', 'Zulu']);
|
||||
});
|
||||
|
||||
it('only returns labels for the specified node', () => {
|
||||
createLabel(0, 'local-label', 'teal');
|
||||
createLabel(1, 'remote-label', 'blue');
|
||||
expect(getLabels(0)).toHaveLength(1);
|
||||
expect(getLabels(0)[0].name).toBe('local-label');
|
||||
expect(getLabels(1)).toHaveLength(1);
|
||||
expect(getLabels(1)[0].name).toBe('remote-label');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getLabel ───────────────────────────────────────────────────────
|
||||
|
||||
describe('getLabel', () => {
|
||||
it('returns the label by id and nodeId', () => {
|
||||
const created = createLabel(0, 'test', 'teal');
|
||||
const found = getLabel(created.id, 0);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.name).toBe('test');
|
||||
expect(found!.color).toBe('teal');
|
||||
});
|
||||
|
||||
it('returns null for wrong nodeId', () => {
|
||||
const created = createLabel(0, 'test', 'teal');
|
||||
expect(getLabel(created.id, 999)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for nonexistent id', () => {
|
||||
expect(getLabel(999, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── createLabel ────────────────────────────────────────────────────
|
||||
|
||||
describe('createLabel', () => {
|
||||
it('creates label and returns it with id', () => {
|
||||
const label = createLabel(0, 'production', 'rose');
|
||||
expect(label.id).toBeGreaterThan(0);
|
||||
expect(label.name).toBe('production');
|
||||
expect(label.color).toBe('rose');
|
||||
expect(label.node_id).toBe(0);
|
||||
});
|
||||
|
||||
it('throws on duplicate (node_id, name)', () => {
|
||||
createLabel(0, 'unique-name', 'teal');
|
||||
expect(() => createLabel(0, 'unique-name', 'blue')).toThrow();
|
||||
});
|
||||
|
||||
it('allows same name on different nodes', () => {
|
||||
createLabel(0, 'shared-name', 'teal');
|
||||
const label2 = createLabel(1, 'shared-name', 'blue');
|
||||
expect(label2.id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('accepts names at exactly 30 characters', () => {
|
||||
const longName = 'a'.repeat(30);
|
||||
const label = createLabel(0, longName, 'teal');
|
||||
expect(label.name).toBe(longName);
|
||||
});
|
||||
|
||||
it('accepts names with spaces and hyphens', () => {
|
||||
const label = createLabel(0, 'my cool-label', 'blue');
|
||||
expect(label.name).toBe('my cool-label');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getLabelCount ──────────────────────────────────────────────────
|
||||
|
||||
describe('getLabelCount', () => {
|
||||
it('returns correct count', () => {
|
||||
createLabel(0, 'a', 'teal');
|
||||
createLabel(0, 'b', 'blue');
|
||||
createLabel(0, 'c', 'rose');
|
||||
expect(getLabelCount(0)).toBe(3);
|
||||
});
|
||||
|
||||
it('returns 0 for node with no labels', () => {
|
||||
expect(getLabelCount(42)).toBe(0);
|
||||
});
|
||||
|
||||
it('counts only labels for the specified node', () => {
|
||||
createLabel(0, 'a', 'teal');
|
||||
createLabel(0, 'b', 'blue');
|
||||
createLabel(1, 'c', 'rose');
|
||||
expect(getLabelCount(0)).toBe(2);
|
||||
expect(getLabelCount(1)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateLabel ────────────────────────────────────────────────────
|
||||
|
||||
describe('updateLabel', () => {
|
||||
it('updates name only', () => {
|
||||
const label = createLabel(0, 'old-name', 'teal');
|
||||
const updated = updateLabel(label.id, 0, { name: 'new-name' });
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.name).toBe('new-name');
|
||||
expect(updated!.color).toBe('teal');
|
||||
});
|
||||
|
||||
it('updates color only', () => {
|
||||
const label = createLabel(0, 'test', 'teal');
|
||||
const updated = updateLabel(label.id, 0, { color: 'rose' });
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.color).toBe('rose');
|
||||
expect(updated!.name).toBe('test');
|
||||
});
|
||||
|
||||
it('updates both name and color', () => {
|
||||
const label = createLabel(0, 'old', 'teal');
|
||||
const updated = updateLabel(label.id, 0, { name: 'new', color: 'purple' });
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.name).toBe('new');
|
||||
expect(updated!.color).toBe('purple');
|
||||
});
|
||||
|
||||
it('returns null for nonexistent label', () => {
|
||||
expect(updateLabel(999, 0, { name: 'nope' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for wrong nodeId', () => {
|
||||
const label = createLabel(0, 'test', 'teal');
|
||||
expect(updateLabel(label.id, 999, { name: 'nope' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteLabel ────────────────────────────────────────────────────
|
||||
|
||||
describe('deleteLabel', () => {
|
||||
it('removes the label', () => {
|
||||
const label = createLabel(0, 'doomed', 'teal');
|
||||
deleteLabel(label.id, 0);
|
||||
expect(getLabels(0)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('cascade deletes assignments', () => {
|
||||
const label = createLabel(0, 'test', 'teal');
|
||||
setStackLabels('my-stack', 0, [label.id]);
|
||||
expect(countRows('stack_label_assignments')).toBe(1);
|
||||
|
||||
deleteLabel(label.id, 0);
|
||||
expect(countRows('stack_label_assignments')).toBe(0);
|
||||
});
|
||||
|
||||
it('no-op for wrong nodeId', () => {
|
||||
const label = createLabel(0, 'test', 'teal');
|
||||
deleteLabel(label.id, 999);
|
||||
expect(getLabels(0)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── setStackLabels ─────────────────────────────────────────────────
|
||||
|
||||
describe('setStackLabels', () => {
|
||||
it('assigns labels to a stack', () => {
|
||||
const l1 = createLabel(0, 'a', 'teal');
|
||||
const l2 = createLabel(0, 'b', 'blue');
|
||||
setStackLabels('my-stack', 0, [l1.id, l2.id]);
|
||||
|
||||
const map = getLabelsForStacks(0);
|
||||
expect(map['my-stack']).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('replaces existing assignments', () => {
|
||||
const l1 = createLabel(0, 'a', 'teal');
|
||||
const l2 = createLabel(0, 'b', 'blue');
|
||||
setStackLabels('my-stack', 0, [l1.id, l2.id]);
|
||||
setStackLabels('my-stack', 0, [l1.id]);
|
||||
|
||||
const map = getLabelsForStacks(0);
|
||||
expect(map['my-stack']).toHaveLength(1);
|
||||
expect(map['my-stack'][0].name).toBe('a');
|
||||
});
|
||||
|
||||
it('clears assignments when empty array', () => {
|
||||
const l1 = createLabel(0, 'a', 'teal');
|
||||
setStackLabels('my-stack', 0, [l1.id]);
|
||||
setStackLabels('my-stack', 0, []);
|
||||
|
||||
const map = getLabelsForStacks(0);
|
||||
expect(map['my-stack']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws for invalid label IDs', () => {
|
||||
expect(() => setStackLabels('my-stack', 0, [999])).toThrow('One or more label IDs are invalid');
|
||||
});
|
||||
|
||||
it('throws if any label ID belongs to a different node', () => {
|
||||
const l1 = createLabel(1, 'remote-label', 'teal');
|
||||
expect(() => setStackLabels('my-stack', 0, [l1.id])).toThrow('One or more label IDs are invalid');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getLabelsForStacks ─────────────────────────────────────────────
|
||||
|
||||
describe('getLabelsForStacks', () => {
|
||||
it('returns correct mapping', () => {
|
||||
const l1 = createLabel(0, 'alpha', 'teal');
|
||||
const l2 = createLabel(0, 'beta', 'blue');
|
||||
setStackLabels('stack-a', 0, [l1.id, l2.id]);
|
||||
setStackLabels('stack-b', 0, [l2.id]);
|
||||
|
||||
const map = getLabelsForStacks(0);
|
||||
expect(Object.keys(map)).toHaveLength(2);
|
||||
expect(map['stack-a']).toHaveLength(2);
|
||||
expect(map['stack-b']).toHaveLength(1);
|
||||
// Verify ordering by name
|
||||
expect(map['stack-a'][0].name).toBe('alpha');
|
||||
expect(map['stack-a'][1].name).toBe('beta');
|
||||
});
|
||||
|
||||
it('returns empty object when no assignments', () => {
|
||||
expect(getLabelsForStacks(0)).toEqual({});
|
||||
});
|
||||
|
||||
it('scopes results to the specified node', () => {
|
||||
const l1 = createLabel(0, 'local', 'teal');
|
||||
const l2 = createLabel(1, 'remote', 'blue');
|
||||
setStackLabels('stack-a', 0, [l1.id]);
|
||||
setStackLabels('stack-b', 1, [l2.id]);
|
||||
|
||||
const localMap = getLabelsForStacks(0);
|
||||
expect(Object.keys(localMap)).toEqual(['stack-a']);
|
||||
|
||||
const remoteMap = getLabelsForStacks(1);
|
||||
expect(Object.keys(remoteMap)).toEqual(['stack-b']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getStacksForLabel ──────────────────────────────────────────────
|
||||
|
||||
describe('getStacksForLabel', () => {
|
||||
it('returns stack names for label', () => {
|
||||
const l1 = createLabel(0, 'test', 'teal');
|
||||
setStackLabels('stack-a', 0, [l1.id]);
|
||||
setStackLabels('stack-b', 0, [l1.id]);
|
||||
|
||||
const stacks = getStacksForLabel(l1.id, 0);
|
||||
expect(stacks).toHaveLength(2);
|
||||
expect(stacks).toContain('stack-a');
|
||||
expect(stacks).toContain('stack-b');
|
||||
});
|
||||
|
||||
it('filters by nodeId', () => {
|
||||
const l1 = createLabel(0, 'local', 'teal');
|
||||
const l2 = createLabel(1, 'remote', 'blue');
|
||||
setStackLabels('stack-a', 0, [l1.id]);
|
||||
setStackLabels('stack-b', 1, [l2.id]);
|
||||
|
||||
expect(getStacksForLabel(l1.id, 0)).toEqual(['stack-a']);
|
||||
expect(getStacksForLabel(l1.id, 1)).toEqual([]);
|
||||
expect(getStacksForLabel(l2.id, 1)).toEqual(['stack-b']);
|
||||
});
|
||||
|
||||
it('returns empty for nonexistent label', () => {
|
||||
expect(getStacksForLabel(999, 0)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── cleanupStaleAssignments ────────────────────────────────────────
|
||||
|
||||
describe('cleanupStaleAssignments', () => {
|
||||
it('removes assignments for stacks not in the valid list', () => {
|
||||
const l1 = createLabel(0, 'test', 'teal');
|
||||
setStackLabels('alive-stack', 0, [l1.id]);
|
||||
setStackLabels('dead-stack', 0, [l1.id]);
|
||||
|
||||
const removed = cleanupStaleAssignments(0, ['alive-stack']);
|
||||
expect(removed).toBe(1);
|
||||
|
||||
const map = getLabelsForStacks(0);
|
||||
expect(Object.keys(map)).toEqual(['alive-stack']);
|
||||
});
|
||||
|
||||
it('preserves assignments for valid stacks', () => {
|
||||
const l1 = createLabel(0, 'a', 'teal');
|
||||
const l2 = createLabel(0, 'b', 'blue');
|
||||
setStackLabels('stack-1', 0, [l1.id, l2.id]);
|
||||
setStackLabels('stack-2', 0, [l1.id]);
|
||||
|
||||
const removed = cleanupStaleAssignments(0, ['stack-1', 'stack-2']);
|
||||
expect(removed).toBe(0);
|
||||
expect(countRows('stack_label_assignments')).toBe(3);
|
||||
});
|
||||
|
||||
it('handles empty valid list (deletes all for the node)', () => {
|
||||
const l1 = createLabel(0, 'test', 'teal');
|
||||
setStackLabels('stack-a', 0, [l1.id]);
|
||||
setStackLabels('stack-b', 0, [l1.id]);
|
||||
|
||||
const removed = cleanupStaleAssignments(0, []);
|
||||
expect(removed).toBe(2);
|
||||
expect(countRows('stack_label_assignments')).toBe(0);
|
||||
});
|
||||
|
||||
it('only affects the specified node', () => {
|
||||
const l1 = createLabel(0, 'local', 'teal');
|
||||
const l2 = createLabel(1, 'remote', 'blue');
|
||||
setStackLabels('stack-a', 0, [l1.id]);
|
||||
setStackLabels('stack-b', 1, [l2.id]);
|
||||
|
||||
cleanupStaleAssignments(0, []);
|
||||
expect(getLabelsForStacks(0)).toEqual({});
|
||||
expect(Object.keys(getLabelsForStacks(1))).toEqual(['stack-b']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteNode cascade ─────────────────────────────────────────────
|
||||
|
||||
describe('deleteNode cascade (labels)', () => {
|
||||
it('deletes labels and assignments when node is deleted', () => {
|
||||
const nodeId = insertNode();
|
||||
const l1 = createLabel(nodeId, 'label-a', 'teal');
|
||||
const l2 = createLabel(nodeId, 'label-b', 'blue');
|
||||
setStackLabels('stack-1', nodeId, [l1.id, l2.id]);
|
||||
setStackLabels('stack-2', nodeId, [l1.id]);
|
||||
|
||||
expect(getLabelCount(nodeId)).toBe(2);
|
||||
expect(countRows('stack_label_assignments')).toBe(3);
|
||||
|
||||
// Simulate DatabaseService.deleteNode
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM stack_label_assignments WHERE node_id = ?').run(nodeId);
|
||||
db.prepare('DELETE FROM stack_labels WHERE node_id = ?').run(nodeId);
|
||||
db.prepare('DELETE FROM nodes WHERE id = ?').run(nodeId);
|
||||
})();
|
||||
|
||||
expect(countRows('nodes')).toBe(0);
|
||||
expect(countRows('stack_labels')).toBe(0);
|
||||
expect(countRows('stack_label_assignments')).toBe(0);
|
||||
});
|
||||
|
||||
it('does not affect other nodes labels', () => {
|
||||
const node1 = insertNode('node-1');
|
||||
const node2 = insertNode('node-2');
|
||||
const l1 = createLabel(node1, 'label-a', 'teal');
|
||||
const l2 = createLabel(node2, 'label-b', 'blue');
|
||||
setStackLabels('stack-1', node1, [l1.id]);
|
||||
setStackLabels('stack-2', node2, [l2.id]);
|
||||
|
||||
// Delete node1
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM stack_label_assignments WHERE node_id = ?').run(node1);
|
||||
db.prepare('DELETE FROM stack_labels WHERE node_id = ?').run(node1);
|
||||
db.prepare('DELETE FROM nodes WHERE id = ?').run(node1);
|
||||
})();
|
||||
|
||||
// node2 data untouched
|
||||
expect(getLabelCount(node2)).toBe(1);
|
||||
expect(Object.keys(getLabelsForStacks(node2))).toEqual(['stack-2']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edge cases ─────────────────────────────────────────────────────
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('setStackLabels is atomic (all-or-nothing)', () => {
|
||||
const l1 = createLabel(0, 'valid', 'teal');
|
||||
setStackLabels('my-stack', 0, [l1.id]);
|
||||
|
||||
// Try to set with a mix of valid and invalid IDs
|
||||
expect(() => setStackLabels('my-stack', 0, [l1.id, 999])).toThrow();
|
||||
|
||||
// Original assignment should be unchanged (transaction rolled back)
|
||||
const map = getLabelsForStacks(0);
|
||||
expect(map['my-stack']).toHaveLength(1);
|
||||
expect(map['my-stack'][0].name).toBe('valid');
|
||||
});
|
||||
|
||||
it('cascade delete on label removes all its assignments across stacks', () => {
|
||||
const l1 = createLabel(0, 'shared', 'teal');
|
||||
const l2 = createLabel(0, 'other', 'blue');
|
||||
setStackLabels('stack-a', 0, [l1.id, l2.id]);
|
||||
setStackLabels('stack-b', 0, [l1.id]);
|
||||
setStackLabels('stack-c', 0, [l1.id]);
|
||||
|
||||
expect(countRows('stack_label_assignments')).toBe(4);
|
||||
|
||||
deleteLabel(l1.id, 0);
|
||||
|
||||
// Only l2's assignment on stack-a should remain
|
||||
expect(countRows('stack_label_assignments')).toBe(1);
|
||||
const map = getLabelsForStacks(0);
|
||||
expect(Object.keys(map)).toEqual(['stack-a']);
|
||||
expect(map['stack-a'][0].name).toBe('other');
|
||||
});
|
||||
|
||||
it('multiple labels assigned to the same stack', () => {
|
||||
const l1 = createLabel(0, 'env', 'teal');
|
||||
const l2 = createLabel(0, 'tier', 'blue');
|
||||
const l3 = createLabel(0, 'team', 'rose');
|
||||
setStackLabels('my-stack', 0, [l1.id, l2.id, l3.id]);
|
||||
|
||||
const map = getLabelsForStacks(0);
|
||||
expect(map['my-stack']).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
+100
-26
@@ -82,6 +82,7 @@ const _origEmitWarning = process.emitWarning.bind(process);
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
const VALID_LABEL_COLORS = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'] as const;
|
||||
const MAX_LABELS_PER_NODE = 50;
|
||||
const app = express();
|
||||
const PORT = 3000;
|
||||
|
||||
@@ -1040,6 +1041,18 @@ const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const requireBody = (req: Request, res: Response): boolean => {
|
||||
if (!req.body || typeof req.body !== 'object') {
|
||||
res.status(400).json({ error: 'Request body is required' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
function isSqliteUniqueViolation(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && (error as { code: string }).code === 'SQLITE_CONSTRAINT_UNIQUE';
|
||||
}
|
||||
|
||||
// Tier gate for scheduled tasks: 'update' action requires Skipper+, everything else requires Admiral.
|
||||
const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => {
|
||||
if (action === 'update') return requirePaid(req, res);
|
||||
@@ -3262,6 +3275,7 @@ app.get('/api/labels', authMiddleware, async (req: Request, res: Response): Prom
|
||||
try {
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const labels = DatabaseService.getInstance().getLabels(nodeId);
|
||||
if (isDebugEnabled()) console.debug('[Labels:debug] List labels: nodeId=', nodeId, 'count=', labels.length);
|
||||
res.json(labels);
|
||||
} catch (error) {
|
||||
console.error('[Labels] List error:', error);
|
||||
@@ -3271,6 +3285,7 @@ app.get('/api/labels', authMiddleware, async (req: Request, res: Response): Prom
|
||||
|
||||
app.post('/api/labels', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
try {
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const { name, color } = req.body;
|
||||
@@ -3288,10 +3303,18 @@ app.post('/api/labels', authMiddleware, async (req: Request, res: Response): Pro
|
||||
return;
|
||||
}
|
||||
|
||||
const label = DatabaseService.getInstance().createLabel(nodeId, name.trim(), color);
|
||||
const db = DatabaseService.getInstance();
|
||||
if (db.getLabelCount(nodeId) >= MAX_LABELS_PER_NODE) {
|
||||
res.status(409).json({ error: `Maximum of ${MAX_LABELS_PER_NODE} labels per node reached` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDebugEnabled()) console.debug('[Labels:debug] Create label:', { nodeId, name: name.trim(), color });
|
||||
const label = db.createLabel(nodeId, name.trim(), color);
|
||||
if (isDebugEnabled()) console.debug('[Labels:debug] Created label:', label.id);
|
||||
res.status(201).json(label);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && 'code' in error && (error as { code: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
if (isSqliteUniqueViolation(error)) {
|
||||
res.status(409).json({ error: 'A label with that name already exists' });
|
||||
return;
|
||||
}
|
||||
@@ -3304,7 +3327,25 @@ app.get('/api/labels/assignments', authMiddleware, async (req: Request, res: Res
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const assignments = DatabaseService.getInstance().getLabelsForStacks(nodeId);
|
||||
const db = DatabaseService.getInstance();
|
||||
const assignments = db.getLabelsForStacks(nodeId);
|
||||
|
||||
// Opportunistic cleanup: only scan the filesystem when there are assignments to validate
|
||||
const assignedStacks = Object.keys(assignments);
|
||||
if (assignedStacks.length > 0) {
|
||||
const fsStacks = await FileSystemService.getInstance(nodeId).getStacks();
|
||||
const fsSet = new Set(fsStacks);
|
||||
const staleNames = assignedStacks.filter(name => !fsSet.has(name));
|
||||
if (staleNames.length > 0) {
|
||||
db.cleanupStaleAssignments(nodeId, fsStacks);
|
||||
for (const name of staleNames) {
|
||||
delete assignments[name];
|
||||
}
|
||||
if (isDebugEnabled()) console.debug('[Labels:debug] Cleaned up stale assignments:', staleNames);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDebugEnabled()) console.debug('[Labels:debug] Assignments: nodeId=', nodeId, 'stacks=', Object.keys(assignments).length);
|
||||
res.json(assignments);
|
||||
} catch (error) {
|
||||
console.error('[Labels] Assignments error:', error);
|
||||
@@ -3314,6 +3355,7 @@ app.get('/api/labels/assignments', authMiddleware, async (req: Request, res: Res
|
||||
|
||||
app.put('/api/labels/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'Invalid label ID' }); return; }
|
||||
@@ -3335,6 +3377,7 @@ app.put('/api/labels/:id', authMiddleware, async (req: Request, res: Response):
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDebugEnabled()) console.debug('[Labels:debug] Update label:', { id, nodeId, name: name?.trim(), color });
|
||||
const updated = DatabaseService.getInstance().updateLabel(id, nodeId, {
|
||||
name: name?.trim(),
|
||||
color,
|
||||
@@ -3345,7 +3388,7 @@ app.put('/api/labels/:id', authMiddleware, async (req: Request, res: Response):
|
||||
}
|
||||
res.json(updated);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && 'code' in error && (error as { code: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
if (isSqliteUniqueViolation(error)) {
|
||||
res.status(409).json({ error: 'A label with that name already exists' });
|
||||
return;
|
||||
}
|
||||
@@ -3360,6 +3403,7 @@ app.delete('/api/labels/:id', authMiddleware, async (req: Request, res: Response
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'Invalid label ID' }); return; }
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
if (isDebugEnabled()) console.debug('[Labels:debug] Delete label:', { id, nodeId });
|
||||
DatabaseService.getInstance().deleteLabel(id, nodeId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -3370,6 +3414,7 @@ app.delete('/api/labels/:id', authMiddleware, async (req: Request, res: Response
|
||||
|
||||
app.put('/api/stacks/:stackName/labels', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
@@ -3384,6 +3429,7 @@ app.put('/api/stacks/:stackName/labels', authMiddleware, async (req: Request, re
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDebugEnabled()) console.debug('[Labels:debug] Set stack labels:', { stackName, nodeId, labelIds });
|
||||
DatabaseService.getInstance().setStackLabels(stackName, nodeId, labelIds);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -3392,9 +3438,12 @@ app.put('/api/stacks/:stackName/labels', authMiddleware, async (req: Request, re
|
||||
}
|
||||
});
|
||||
|
||||
const activeBulkActions = new Set<string>();
|
||||
|
||||
app.post('/api/labels/:id/action', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'Invalid label ID' }); return; }
|
||||
@@ -3406,36 +3455,61 @@ app.post('/api/labels/:id/action', authMiddleware, async (req: Request, res: Res
|
||||
}
|
||||
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const stackNames = DatabaseService.getInstance().getStacksForLabel(id);
|
||||
const fsStacks = await FileSystemService.getInstance(nodeId).getStacks();
|
||||
const fsStackNames = new Set(fsStacks);
|
||||
const validStacks = stackNames.filter(name => fsStackNames.has(name));
|
||||
|
||||
const results: { stackName: string; success: boolean; error?: string }[] = [];
|
||||
const label = DatabaseService.getInstance().getLabel(id, nodeId);
|
||||
if (!label) {
|
||||
res.status(404).json({ error: 'Label not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const stackName of validStacks) {
|
||||
try {
|
||||
if (action === 'deploy') {
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, undefined, false);
|
||||
} else {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
if (action === 'stop') {
|
||||
await Promise.all(containers.map(c => dockerController.stopContainer(c.Id)));
|
||||
const lockKey = `bulk:${nodeId}`;
|
||||
if (activeBulkActions.has(lockKey)) {
|
||||
res.status(429).json({ error: 'A bulk action is already running for this node. Please wait.' });
|
||||
return;
|
||||
}
|
||||
activeBulkActions.add(lockKey);
|
||||
|
||||
try {
|
||||
const stackNames = DatabaseService.getInstance().getStacksForLabel(id, nodeId);
|
||||
const fsStacks = await FileSystemService.getInstance(nodeId).getStacks();
|
||||
const fsStackNames = new Set(fsStacks);
|
||||
const validStacks = stackNames.filter(name => fsStackNames.has(name));
|
||||
|
||||
if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action start:', { id, action, nodeId, totalLabeled: stackNames.length, validStacks: validStacks.length });
|
||||
|
||||
const results: { stackName: string; success: boolean; error?: string }[] = [];
|
||||
|
||||
for (const stackName of validStacks) {
|
||||
try {
|
||||
if (action === 'deploy') {
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, undefined, false);
|
||||
} else {
|
||||
await Promise.all(containers.map(c => dockerController.restartContainer(c.Id)));
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
if (action === 'stop') {
|
||||
await Promise.all(containers.map(c => dockerController.stopContainer(c.Id)));
|
||||
} else {
|
||||
await Promise.all(containers.map(c => dockerController.restartContainer(c.Id)));
|
||||
}
|
||||
}
|
||||
results.push({ stackName, success: true });
|
||||
} catch (err: unknown) {
|
||||
results.push({ stackName, success: false, error: (err as Error)?.message || 'Unknown error' });
|
||||
}
|
||||
results.push({ stackName, success: true });
|
||||
} catch (err: unknown) {
|
||||
results.push({ stackName, success: false, error: (err as Error)?.message || 'Unknown error' });
|
||||
}
|
||||
}
|
||||
|
||||
if (results.some(r => r.success)) {
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
const succeeded = results.filter(r => r.success).length;
|
||||
const failed = results.length - succeeded;
|
||||
console.log(`[Labels] Bulk ${action} on label ${id}: ${validStacks.length} stacks (${succeeded} succeeded, ${failed} failed)`);
|
||||
if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action complete:', { id, action, total: results.length, succeeded, failed });
|
||||
|
||||
if (succeeded > 0) {
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
}
|
||||
res.json({ results });
|
||||
} finally {
|
||||
activeBulkActions.delete(lockKey);
|
||||
}
|
||||
res.json({ results });
|
||||
} catch (error) {
|
||||
console.error('[Labels] Bulk action error:', error);
|
||||
res.status(500).json({ error: 'Failed to execute bulk action' });
|
||||
|
||||
@@ -994,6 +994,8 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM scheduled_task_runs WHERE task_id IN (SELECT id FROM scheduled_tasks WHERE node_id = ?)').run(id);
|
||||
this.db.prepare('DELETE FROM scheduled_tasks WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM stack_label_assignments WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM stack_labels WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM nodes WHERE id = ?').run(id);
|
||||
})();
|
||||
}
|
||||
@@ -1550,6 +1552,17 @@ export class DatabaseService {
|
||||
return this.db.prepare('SELECT * FROM stack_labels WHERE node_id = ? ORDER BY name').all(nodeId) as Label[];
|
||||
}
|
||||
|
||||
public getLabel(id: number, nodeId: number): Label | null {
|
||||
return (this.db.prepare('SELECT * FROM stack_labels WHERE id = ? AND node_id = ?')
|
||||
.get(id, nodeId) as Label) ?? null;
|
||||
}
|
||||
|
||||
public getLabelCount(nodeId: number): number {
|
||||
const row = this.db.prepare('SELECT COUNT(*) as cnt FROM stack_labels WHERE node_id = ?')
|
||||
.get(nodeId) as { cnt: number };
|
||||
return row.cnt;
|
||||
}
|
||||
|
||||
public createLabel(nodeId: number, name: string, color: string): Label {
|
||||
const result = this.db.prepare(
|
||||
'INSERT INTO stack_labels (node_id, name, color) VALUES (?, ?, ?)'
|
||||
@@ -1606,8 +1619,21 @@ export class DatabaseService {
|
||||
return result;
|
||||
}
|
||||
|
||||
public getStacksForLabel(labelId: number): string[] {
|
||||
const rows = this.db.prepare('SELECT stack_name FROM stack_label_assignments WHERE label_id = ?').all(labelId) as { stack_name: string }[];
|
||||
public getStacksForLabel(labelId: number, nodeId: number): string[] {
|
||||
const rows = this.db.prepare('SELECT stack_name FROM stack_label_assignments WHERE label_id = ? AND node_id = ?')
|
||||
.all(labelId, nodeId) as { stack_name: string }[];
|
||||
return rows.map(r => r.stack_name);
|
||||
}
|
||||
|
||||
public cleanupStaleAssignments(nodeId: number, validStackNames: string[]): number {
|
||||
if (validStackNames.length === 0) {
|
||||
const result = this.db.prepare('DELETE FROM stack_label_assignments WHERE node_id = ?').run(nodeId);
|
||||
return result.changes;
|
||||
}
|
||||
const placeholders = validStackNames.map(() => '?').join(',');
|
||||
const result = this.db.prepare(
|
||||
`DELETE FROM stack_label_assignments WHERE node_id = ? AND stack_name NOT IN (${placeholders})`
|
||||
).run(nodeId, ...validStackNames);
|
||||
return result.changes;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ Right-click a label pill in the sidebar to access bulk actions:
|
||||
<img src="/images/stack-labels/bulk-actions-menu.png" alt="Right-click context menu on a label pill showing Deploy all, Stop all, and Restart all options" />
|
||||
</Frame>
|
||||
|
||||
A confirmation dialog shows which stacks will be affected before executing.
|
||||
A confirmation dialog shows which stacks will be affected before executing. If any stacks fail during a bulk action, the error toast lists the specific stack names that failed. Only one bulk action can run at a time per node.
|
||||
|
||||
## Managing labels
|
||||
|
||||
@@ -97,4 +97,4 @@ Open **Settings > Labels** to view, edit, and delete your labels.
|
||||
- **Edit**: Click the pencil icon on a label row to change its name or color
|
||||
- **Delete**: Click the trash icon to remove the label. A confirmation dialog explains that the label will be removed from all stacks. This cannot be undone.
|
||||
|
||||
Label names must be unique per node. There are 10 color options: teal, blue, purple, rose, amber, green, orange, pink, cyan, and slate.
|
||||
Label names must be unique per node. You can create up to 50 labels per node. There are 10 color options: teal, blue, purple, rose, amber, green, orange, pink, cyan, and slate.
|
||||
|
||||
@@ -23,7 +23,8 @@ import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { LabelPill, LabelDot, type Label as StackLabel } from './LabelPill';
|
||||
import { LabelPill, LabelDot } from './LabelPill';
|
||||
import { type Label as StackLabel } from './label-types';
|
||||
import { LabelAssignPopover } from './LabelAssignPopover';
|
||||
import { UserProfileDropdown } from './UserProfileDropdown';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
@@ -78,6 +79,12 @@ interface StackStatusInfo {
|
||||
|
||||
type StackAction = 'deploy' | 'stop' | 'restart' | 'update' | 'delete' | 'rollback';
|
||||
|
||||
interface BulkActionResult {
|
||||
stackName: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
@@ -1478,15 +1485,15 @@ export default function EditorLayout() {
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => { setBulkActionLabel(label); setBulkAction('deploy'); setBulkActionOpen(true); }}>
|
||||
<ContextMenuItem disabled={bulkActionRunning} onClick={() => { setBulkActionLabel(label); setBulkAction('deploy'); setBulkActionOpen(true); }}>
|
||||
<Play className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
Deploy all
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => { setBulkActionLabel(label); setBulkAction('stop'); setBulkActionOpen(true); }}>
|
||||
<ContextMenuItem disabled={bulkActionRunning} onClick={() => { setBulkActionLabel(label); setBulkAction('stop'); setBulkActionOpen(true); }}>
|
||||
<Square className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
Stop all
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => { setBulkActionLabel(label); setBulkAction('restart'); setBulkActionOpen(true); }}>
|
||||
<ContextMenuItem disabled={bulkActionRunning} onClick={() => { setBulkActionLabel(label); setBulkAction('restart'); setBulkActionOpen(true); }}>
|
||||
<RotateCw className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
Restart all
|
||||
</ContextMenuItem>
|
||||
@@ -1669,11 +1676,12 @@ export default function EditorLayout() {
|
||||
onClick={async () => {
|
||||
const currentIds = (stackLabelMap[file] || []).map(l => l.id);
|
||||
const newIds = assigned ? currentIds.filter(id => id !== label.id) : [...currentIds, label.id];
|
||||
const loadingId = toast.loading('Updating labels...');
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(file)}/labels`, { method: 'PUT', body: JSON.stringify({ labelIds: newIds }) });
|
||||
if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data?.error || 'Failed to update labels.'); }
|
||||
refreshLabels();
|
||||
} catch (err: unknown) { toast.error((err as Error)?.message || 'Failed to update labels.'); }
|
||||
} catch (err: unknown) { toast.error((err as Error)?.message || 'Failed to update labels.'); } finally { toast.dismiss(loadingId); }
|
||||
}}
|
||||
>
|
||||
<LabelDot color={label.color} />
|
||||
@@ -2361,9 +2369,10 @@ export default function EditorLayout() {
|
||||
throw new Error(data?.error || `Bulk ${bulkAction} failed.`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const failed = data.results?.filter((r: { success: boolean }) => !r.success) || [];
|
||||
const failed = (data.results ?? []).filter((r: BulkActionResult) => !r.success);
|
||||
if (failed.length > 0) {
|
||||
toast.error(`${failed.length} stack(s) failed to ${bulkAction}.`);
|
||||
const failedNames = failed.map((r: BulkActionResult) => r.stackName).join(', ');
|
||||
toast.error(`Failed to ${bulkAction}: ${failedNames}`);
|
||||
} else {
|
||||
toast.success(`All stacks ${bulkAction === 'deploy' ? 'deployed' : bulkAction === 'stop' ? 'stopped' : 'restarted'} successfully.`);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ import { useLicense } from '@/context/LicenseContext';
|
||||
import { PaidGate } from './PaidGate';
|
||||
import FleetSnapshots from './FleetSnapshots';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { LabelDot, type Label as StackLabel } from './LabelPill';
|
||||
import { LabelDot } from './LabelPill';
|
||||
import { type Label as StackLabel } from './label-types';
|
||||
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
|
||||
import { formatVersion } from '@/lib/version';
|
||||
import { CursorProvider, Cursor, CursorFollow, CursorContainer } from '@/components/animate-ui/primitives/animate/cursor';
|
||||
|
||||
@@ -3,11 +3,11 @@ import { Check, Plus } from 'lucide-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { LabelDot, type Label, type LabelColor } from './LabelPill';
|
||||
|
||||
const LABEL_COLORS: LabelColor[] = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'];
|
||||
import { LabelDot } from './LabelPill';
|
||||
import { LABEL_COLORS, MAX_LABELS_PER_NODE, type Label, type LabelColor } from './label-types';
|
||||
|
||||
interface LabelAssignPopoverProps {
|
||||
stackName: string;
|
||||
@@ -89,25 +89,27 @@ export function LabelAssignPopover({ stackName, allLabels, assignedLabelIds, onL
|
||||
align="start"
|
||||
>
|
||||
<div className="text-xs font-medium text-muted-foreground px-2 py-1">Labels</div>
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
{allLabels.map(label => (
|
||||
<button
|
||||
key={label.id}
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
onClick={() => toggleLabel(label.id)}
|
||||
>
|
||||
<LabelDot color={label.color} />
|
||||
<span className="flex-1 text-left font-mono text-[12px] truncate">{label.name}</span>
|
||||
{assignedLabelIds.includes(label.id) && (
|
||||
<Check className="w-3.5 h-3.5 text-success shrink-0" strokeWidth={1.5} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{allLabels.length === 0 && !creating && (
|
||||
<div className="text-xs text-muted-foreground px-2 py-2">No labels yet.</div>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="max-h-[200px]">
|
||||
<div>
|
||||
{allLabels.map(label => (
|
||||
<button
|
||||
key={label.id}
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
onClick={() => toggleLabel(label.id)}
|
||||
>
|
||||
<LabelDot color={label.color} />
|
||||
<span className="flex-1 text-left font-mono text-[12px] truncate">{label.name}</span>
|
||||
{assignedLabelIds.includes(label.id) && (
|
||||
<Check className="w-3.5 h-3.5 text-success shrink-0" strokeWidth={1.5} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{allLabels.length === 0 && !creating && (
|
||||
<div className="text-xs text-muted-foreground px-2 py-2">No labels yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{creating ? (
|
||||
<div className="border-t border-border mt-1 pt-2 px-1 space-y-2">
|
||||
<Input
|
||||
@@ -139,7 +141,7 @@ export function LabelAssignPopover({ stackName, allLabels, assignedLabelIds, onL
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : allLabels.length < MAX_LABELS_PER_NODE ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-accent/50 transition-colors mt-1 border-t border-border pt-2 cursor-pointer"
|
||||
@@ -148,7 +150,7 @@ export function LabelAssignPopover({ stackName, allLabels, assignedLabelIds, onL
|
||||
<Plus className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
Create new label
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { type MouseEvent, type ReactNode } from 'react';
|
||||
|
||||
export type LabelColor = 'teal' | 'blue' | 'purple' | 'rose' | 'amber' | 'green' | 'orange' | 'pink' | 'cyan' | 'slate';
|
||||
|
||||
export interface Label {
|
||||
id: number;
|
||||
node_id: number;
|
||||
name: string;
|
||||
color: LabelColor;
|
||||
}
|
||||
import { type LabelColor, type Label } from './label-types';
|
||||
|
||||
const COLOR_STYLES: Record<LabelColor, { bg: string; text: string; border: string; activeBg: string }> = {
|
||||
teal: { bg: 'bg-[var(--label-teal-bg)]', text: 'text-[var(--label-teal)]', border: 'border-[var(--label-teal)]/30', activeBg: 'bg-[var(--label-teal)]' },
|
||||
@@ -64,4 +56,3 @@ export function LabelDot({ color }: { color: LabelColor }) {
|
||||
);
|
||||
}
|
||||
|
||||
export { COLOR_STYLES };
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export type LabelColor = 'teal' | 'blue' | 'purple' | 'rose' | 'amber' | 'green' | 'orange' | 'pink' | 'cyan' | 'slate';
|
||||
|
||||
export const LABEL_COLORS: LabelColor[] = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'];
|
||||
|
||||
export const MAX_LABELS_PER_NODE = 50;
|
||||
|
||||
export interface Label {
|
||||
id: number;
|
||||
node_id: number;
|
||||
name: string;
|
||||
color: LabelColor;
|
||||
}
|
||||
@@ -23,9 +23,8 @@ import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { PaidGate } from '../PaidGate';
|
||||
import { CapabilityGate } from '../CapabilityGate';
|
||||
import { LabelDot, type Label, type LabelColor } from '../LabelPill';
|
||||
|
||||
const LABEL_COLORS: LabelColor[] = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'];
|
||||
import { LabelDot } from '../LabelPill';
|
||||
import { LABEL_COLORS, MAX_LABELS_PER_NODE, type Label, type LabelColor } from '../label-types';
|
||||
|
||||
export function LabelsSection() {
|
||||
const [labels, setLabels] = useState<Label[]>([]);
|
||||
@@ -131,9 +130,9 @@ export function LabelsSection() {
|
||||
<h2 className="text-lg font-semibold tracking-tight">Stack Labels</h2>
|
||||
<p className="text-sm text-muted-foreground">Organize stacks with colored labels for filtering and bulk actions.</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Button size="sm" onClick={openCreate} disabled={labels.length >= MAX_LABELS_PER_NODE}>
|
||||
<Plus className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
|
||||
New Label
|
||||
{labels.length >= MAX_LABELS_PER_NODE ? 'Limit reached' : 'New Label'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user