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:
Anso
2026-04-13 12:49:03 -04:00
committed by GitHub
parent faabbea350
commit a695251f38
10 changed files with 805 additions and 77 deletions
+614
View File
@@ -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
View File
@@ -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' });
+28 -2
View File
@@ -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;
}
}
+2 -2
View File
@@ -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.
+16 -7
View File
@@ -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.`);
}
+2 -1
View File
@@ -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';
+26 -24
View File
@@ -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 -10
View File
@@ -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 };
+12
View File
@@ -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>