mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 07:36:40 +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) => {
|
||||
|
||||
Reference in New Issue
Block a user