mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 17:34:23 +00:00
feat(labels): add stack labels for organizing, filtering, and bulk actions (#341)
* feat(labels): add stack_labels schema and DatabaseService CRUD methods
* feat(labels): add label CRUD, assignment, and bulk action API routes
* feat(labels): add oklch label color palette for light and dark themes
* feat(labels): add LabelPill and LabelDot reusable components
* feat(labels): add LabelAssignPopover component for inline label management
* feat(labels): add label pill bar, label dots, and label assignment to sidebar
* feat(labels): add label filtering and label dots to fleet view
* feat(labels): add label-scoped bulk actions (deploy/stop/restart all)
* docs: add Stack Labels feature documentation
* fix(labels): use context menu sub-menu for label assignment and add settings integration
Replace broken Popover-inside-ContextMenu pattern with native Radix
ContextMenuSub for reliable label toggling on right-click. Wrap
ContextMenuSubContent in a Portal to prevent overflow clipping. Add
"Manage labels..." item that opens Settings directly to Labels section.
Fix close button overlap in LabelsSection header. Add LabelsSection
settings component with full CRUD, assignment counts, and ProGate.
Add initialSection prop to SettingsModal for deep-linking. Include
screenshots for documentation.
* docs: update stack labels documentation with screenshots and corrected instructions
* fix(labels): address security and quality issues from code review
- Add NaN validation on parseInt(req.params.id) in label routes
- Scope updateLabel/deleteLabel by nodeId to prevent cross-node IDOR
- Validate labelIds belong to correct node in setStackLabels
- Add requireAdmin check on bulk action endpoint
- Replace error: any with error: unknown and proper narrowing
- Remove unused Label import from index.ts
- Remove unused isPro prop from LabelsSection
- Add strokeWidth={1.5} to Check icons per design system
* chore: update CHANGELOG with stack labels feature
This commit is contained in:
@@ -48,6 +48,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 app = express();
|
||||
const PORT = 3000;
|
||||
|
||||
@@ -2464,6 +2465,190 @@ app.get('/api/containers', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Label Routes (Pro-gated) ---
|
||||
|
||||
app.get('/api/labels', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
try {
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const labels = DatabaseService.getInstance().getLabels(nodeId);
|
||||
res.json(labels);
|
||||
} catch (error) {
|
||||
console.error('[Labels] List error:', error);
|
||||
res.status(500).json({ error: 'Failed to list labels' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/labels', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
try {
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const { name, color } = req.body;
|
||||
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0 || name.length > 30) {
|
||||
res.status(400).json({ error: 'name is required and must be 1-30 characters' });
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9 -]+$/.test(name)) {
|
||||
res.status(400).json({ error: 'name may only contain letters, numbers, spaces, and hyphens' });
|
||||
return;
|
||||
}
|
||||
if (!color || !(VALID_LABEL_COLORS as readonly string[]).includes(color)) {
|
||||
res.status(400).json({ error: `color must be one of: ${VALID_LABEL_COLORS.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const label = DatabaseService.getInstance().createLabel(nodeId, name.trim(), color);
|
||||
res.status(201).json(label);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && 'code' in error && (error as { code: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
res.status(409).json({ error: 'A label with that name already exists' });
|
||||
return;
|
||||
}
|
||||
console.error('[Labels] Create error:', error);
|
||||
res.status(500).json({ error: 'Failed to create label' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/labels/assignments', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
try {
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const assignments = DatabaseService.getInstance().getLabelsForStacks(nodeId);
|
||||
res.json(assignments);
|
||||
} catch (error) {
|
||||
console.error('[Labels] Assignments error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch label assignments' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/labels/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(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; }
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const { name, color } = req.body;
|
||||
|
||||
if (name !== undefined) {
|
||||
if (typeof name !== 'string' || name.trim().length === 0 || name.length > 30) {
|
||||
res.status(400).json({ error: 'name must be 1-30 characters' });
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9 -]+$/.test(name)) {
|
||||
res.status(400).json({ error: 'name may only contain letters, numbers, spaces, and hyphens' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (color !== undefined && !(VALID_LABEL_COLORS as readonly string[]).includes(color)) {
|
||||
res.status(400).json({ error: `color must be one of: ${VALID_LABEL_COLORS.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = DatabaseService.getInstance().updateLabel(id, nodeId, {
|
||||
name: name?.trim(),
|
||||
color,
|
||||
});
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: 'Label not found' });
|
||||
return;
|
||||
}
|
||||
res.json(updated);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && 'code' in error && (error as { code: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
res.status(409).json({ error: 'A label with that name already exists' });
|
||||
return;
|
||||
}
|
||||
console.error('[Labels] Update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update label' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/labels/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(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; }
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
DatabaseService.getInstance().deleteLabel(id, nodeId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[Labels] Delete error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete label' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/stacks/:stackName/labels', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const { labelIds } = req.body;
|
||||
|
||||
if (!Array.isArray(labelIds) || !labelIds.every((id: unknown) => typeof id === 'number')) {
|
||||
res.status(400).json({ error: 'labelIds must be an array of numbers' });
|
||||
return;
|
||||
}
|
||||
|
||||
DatabaseService.getInstance().setStackLabels(stackName, nodeId, labelIds);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[Labels] Set stack labels error:', error);
|
||||
res.status(500).json({ error: 'Failed to set stack labels' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/labels/:id/action', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requireAdmin(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; }
|
||||
const { action } = req.body;
|
||||
const validActions = ['deploy', 'stop', 'restart'];
|
||||
if (!action || !validActions.includes(action)) {
|
||||
res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
|
||||
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 }[] = [];
|
||||
|
||||
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)));
|
||||
} 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' });
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ results });
|
||||
} catch (error) {
|
||||
console.error('[Labels] Bulk action error:', error);
|
||||
res.status(500).json({ error: 'Failed to execute bulk action' });
|
||||
}
|
||||
});
|
||||
|
||||
// Stack Routes - Updated to use stackName (directory name) instead of filename
|
||||
|
||||
app.get('/api/stacks', async (req: Request, res: Response) => {
|
||||
|
||||
@@ -38,6 +38,13 @@ export interface Node {
|
||||
api_token?: string;
|
||||
}
|
||||
|
||||
export interface Label {
|
||||
id: number;
|
||||
node_id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface Webhook {
|
||||
id?: number;
|
||||
name: string;
|
||||
@@ -416,6 +423,24 @@ export class DatabaseService {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_task_runs_task ON scheduled_task_runs(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_next_run ON scheduled_tasks(next_run_at);
|
||||
|
||||
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);
|
||||
`);
|
||||
|
||||
// Apply migrations safely (ignore if columns already exist)
|
||||
@@ -1349,4 +1374,71 @@ export class DatabaseService {
|
||||
const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000);
|
||||
this.db.prepare('DELETE FROM scheduled_task_runs WHERE started_at < ?').run(cutoff);
|
||||
}
|
||||
|
||||
// --- Stack Labels ---
|
||||
|
||||
public getLabels(nodeId: number): Label[] {
|
||||
return this.db.prepare('SELECT * FROM stack_labels WHERE node_id = ? ORDER BY name').all(nodeId) as Label[];
|
||||
}
|
||||
|
||||
public createLabel(nodeId: number, name: string, color: string): Label {
|
||||
const result = this.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 };
|
||||
}
|
||||
|
||||
public updateLabel(id: number, nodeId: number, updates: { name?: string; color?: string }): Label | null {
|
||||
const label = this.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;
|
||||
this.db.prepare('UPDATE stack_labels SET name = ?, color = ? WHERE id = ? AND node_id = ?').run(name, color, id, nodeId);
|
||||
return { ...label, name, color };
|
||||
}
|
||||
|
||||
public deleteLabel(id: number, nodeId: number): void {
|
||||
this.db.prepare('DELETE FROM stack_labels WHERE id = ? AND node_id = ?').run(id, nodeId);
|
||||
}
|
||||
|
||||
public setStackLabels(stackName: string, nodeId: number, labelIds: number[]): void {
|
||||
const txn = this.db.transaction(() => {
|
||||
if (labelIds.length > 0) {
|
||||
const placeholders = labelIds.map(() => '?').join(',');
|
||||
const validCount = this.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');
|
||||
}
|
||||
}
|
||||
this.db.prepare('DELETE FROM stack_label_assignments WHERE stack_name = ? AND node_id = ?').run(stackName, nodeId);
|
||||
const insert = this.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();
|
||||
}
|
||||
|
||||
public getLabelsForStacks(nodeId: number): Record<string, Label[]> {
|
||||
const rows = this.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;
|
||||
}
|
||||
|
||||
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 }[];
|
||||
return rows.map(r => r.stack_name);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user