From a695251f38a21dd12ba535471a9e934928d5672e Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 13 Apr 2026 12:49:03 -0400 Subject: [PATCH] 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. --- backend/src/__tests__/labels.test.ts | 614 ++++++++++++++++++ backend/src/index.ts | 126 +++- backend/src/services/DatabaseService.ts | 30 +- docs/features/stack-labels.mdx | 4 +- frontend/src/components/EditorLayout.tsx | 23 +- frontend/src/components/FleetView.tsx | 3 +- .../src/components/LabelAssignPopover.tsx | 50 +- frontend/src/components/LabelPill.tsx | 11 +- frontend/src/components/label-types.ts | 12 + .../src/components/settings/LabelsSection.tsx | 9 +- 10 files changed, 805 insertions(+), 77 deletions(-) create mode 100644 backend/src/__tests__/labels.test.ts create mode 100644 frontend/src/components/label-types.ts diff --git a/backend/src/__tests__/labels.test.ts b/backend/src/__tests__/labels.test.ts new file mode 100644 index 00000000..28604ae2 --- /dev/null +++ b/backend/src/__tests__/labels.test.ts @@ -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 { + 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 = {}; + 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); + }); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 41e62ce4..33f384eb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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 => { 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 => { 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 => { 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(); + app.post('/api/labels/:id/action', authMiddleware, async (req: Request, res: Response): Promise => { 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' }); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 96c28d23..669cc31f 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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; + } } diff --git a/docs/features/stack-labels.mdx b/docs/features/stack-labels.mdx index e91580fe..4404832b 100644 --- a/docs/features/stack-labels.mdx +++ b/docs/features/stack-labels.mdx @@ -88,7 +88,7 @@ Right-click a label pill in the sidebar to access bulk actions: Right-click context menu on a label pill showing Deploy all, Stop all, and Restart all options -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. diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 0922ed03..9b5baef3 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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() { - { setBulkActionLabel(label); setBulkAction('deploy'); setBulkActionOpen(true); }}> + { setBulkActionLabel(label); setBulkAction('deploy'); setBulkActionOpen(true); }}> Deploy all - { setBulkActionLabel(label); setBulkAction('stop'); setBulkActionOpen(true); }}> + { setBulkActionLabel(label); setBulkAction('stop'); setBulkActionOpen(true); }}> Stop all - { setBulkActionLabel(label); setBulkAction('restart'); setBulkActionOpen(true); }}> + { setBulkActionLabel(label); setBulkAction('restart'); setBulkActionOpen(true); }}> Restart all @@ -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); } }} > @@ -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.`); } diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index b9380a10..44c5f393 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -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'; diff --git a/frontend/src/components/LabelAssignPopover.tsx b/frontend/src/components/LabelAssignPopover.tsx index 78046606..64b98944 100644 --- a/frontend/src/components/LabelAssignPopover.tsx +++ b/frontend/src/components/LabelAssignPopover.tsx @@ -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" >
Labels
-
- {allLabels.map(label => ( - - ))} - {allLabels.length === 0 && !creating && ( -
No labels yet.
- )} -
+ +
+ {allLabels.map(label => ( + + ))} + {allLabels.length === 0 && !creating && ( +
No labels yet.
+ )} +
+
{creating ? (
- ) : ( + ) : allLabels.length < MAX_LABELS_PER_NODE ? ( - )} + ) : null} ); diff --git a/frontend/src/components/LabelPill.tsx b/frontend/src/components/LabelPill.tsx index 8e101de6..70dda378 100644 --- a/frontend/src/components/LabelPill.tsx +++ b/frontend/src/components/LabelPill.tsx @@ -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 = { 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 }; diff --git a/frontend/src/components/label-types.ts b/frontend/src/components/label-types.ts new file mode 100644 index 00000000..42f073e1 --- /dev/null +++ b/frontend/src/components/label-types.ts @@ -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; +} diff --git a/frontend/src/components/settings/LabelsSection.tsx b/frontend/src/components/settings/LabelsSection.tsx index 1ffde8c2..164cb965 100644 --- a/frontend/src/components/settings/LabelsSection.tsx +++ b/frontend/src/components/settings/LabelsSection.tsx @@ -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([]); @@ -131,9 +130,9 @@ export function LabelsSection() {

Stack Labels

Organize stacks with colored labels for filtering and bulk actions.

-