mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
feat: RBAC, atomic deployments, and fleet-wide backups (Pro) (#181)
* feat: add RBAC viewer accounts, atomic deployments, and fleet-wide backups (Pro) Introduces three Pro-tier features: - RBAC: Multi-user system with admin/viewer roles, user management UI, automatic migration from single-admin credentials, viewer restrictions across the entire UI (read-only editor, hidden action buttons) - Atomic Deployments: Pre-deploy file backup to .sencho-backup/, automatic rollback on health probe failure, manual rollback button, health probes added to stack updates, webhook-triggered deploys use atomic rollback - Fleet-Wide Backups: Point-in-time snapshots of compose files across all nodes (local + remote), stored centrally in SQLite, per-stack restore with optional redeploy, graceful handling of offline nodes * fix(settings): use correct ProGate prop name in UsersSection * fix(settings): remove unused isPro prop from UsersSection * fix(auth): fetch user info after login and setup so isAdmin is set correctly
This commit is contained in:
@@ -51,6 +51,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* **webhooks:** Custom CI/CD webhooks — create webhooks targeting specific stacks and actions (deploy, restart, stop, start, pull), trigger them from GitHub Actions, GitLab CI, or any HTTP client with HMAC-SHA256 signature authentication (Pro)
|
||||
* **webhooks:** Execution history tracking — last 100 executions per webhook with status, duration, and error details
|
||||
* **webhooks:** Webhook management UI in Settings with create/edit/delete, enable/disable toggle, one-time secret reveal, and copy-to-clipboard for trigger URLs
|
||||
* **rbac:** Role-based access control with admin and viewer roles — viewers get full read-only access to dashboard, stacks, logs, and stats while admins retain full control (Pro)
|
||||
* **rbac:** User management UI in Settings — create, edit, and delete users with username/password/role controls, protected by admin-only access
|
||||
* **rbac:** Automatic migration of existing single-admin credentials to the new multi-user system on first boot
|
||||
* **rbac:** Viewer restrictions across the entire UI — read-only editor, hidden action buttons, disabled host console, and disabled resource management
|
||||
* **atomic:** Atomic deployments with automatic rollback — Sencho backs up compose.yaml and .env before deploying, and auto-restores if health probes detect crashed containers (Pro)
|
||||
* **atomic:** Manual rollback button in the stack action bar — restore the previous deployment with one click when a backup exists (Pro)
|
||||
* **atomic:** Health probes added to stack updates (previously only on deploys) — crashed containers trigger auto-rollback during updates too
|
||||
* **atomic:** Webhook-triggered deploys and updates now use atomic rollback for Pro users
|
||||
* **fleet:** Fleet-wide backups — snapshot all compose files and .env files across every node (local and remote) into a central backup stored in SQLite (Pro)
|
||||
* **fleet:** Snapshot detail view — browse captured files per node and stack with inline preview in a collapsible tree
|
||||
* **fleet:** Per-stack restore from any snapshot — overwrite current compose files with the snapshot version, with optional one-click redeploy (Pro admin)
|
||||
* **fleet:** Graceful handling of offline nodes during snapshot creation — skipped nodes are recorded with reason and displayed as warnings
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ export async function setupTestDb(): Promise<string> {
|
||||
db.updateGlobalSetting('auth_password_hash', passwordHash);
|
||||
db.updateGlobalSetting('auth_jwt_secret', TEST_JWT_SECRET);
|
||||
|
||||
// Also seed the users table (RBAC login reads from here)
|
||||
db.addUser({ username: TEST_USERNAME, password_hash: passwordHash, role: 'admin' });
|
||||
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
|
||||
+604
-24
@@ -195,7 +195,7 @@ app.use(nodeContextMiddleware);
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: { username: string };
|
||||
user?: { username: string; role: 'admin' | 'viewer' };
|
||||
nodeId: number;
|
||||
}
|
||||
}
|
||||
@@ -228,9 +228,9 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) throw new Error('No JWT secret');
|
||||
const decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string };
|
||||
// Accept both user sessions and node proxy tokens
|
||||
req.user = { username: decoded.username || 'node-proxy' };
|
||||
const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string };
|
||||
// Accept both user sessions and node proxy tokens. Default role to 'admin' for backward compat with pre-RBAC tokens.
|
||||
req.user = { username: decoded.username || 'node-proxy', role: (decoded.role as 'admin' | 'viewer') || 'admin' };
|
||||
next();
|
||||
} catch (err) {
|
||||
console.error('[Auth] Token validation failed:', (err as Error).message);
|
||||
@@ -314,8 +314,11 @@ app.post('/api/auth/setup', authRateLimiter, async (req: Request, res: Response)
|
||||
dbSvc.updateGlobalSetting('admin_email', admin_email.trim());
|
||||
}
|
||||
|
||||
// Create admin user in users table
|
||||
dbSvc.addUser({ username, password_hash: passwordHash, role: 'admin' });
|
||||
|
||||
// Issue JWT and log user in
|
||||
const token = jwt.sign({ username }, jwtSecret, { expiresIn: '24h' });
|
||||
const token = jwt.sign({ username, role: 'admin' }, jwtSecret, { expiresIn: '24h' });
|
||||
res.cookie(COOKIE_NAME, token, getCookieOptions(req));
|
||||
res.json({ success: true, message: 'Setup completed successfully' });
|
||||
} catch (error) {
|
||||
@@ -334,16 +337,16 @@ app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response)
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const storedUsername = settings.auth_username;
|
||||
const storedHash = settings.auth_password_hash;
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername(username);
|
||||
|
||||
if (storedUsername && storedHash && username === storedUsername) {
|
||||
const isValid = await bcrypt.compare(password, storedHash);
|
||||
if (user) {
|
||||
const isValid = await bcrypt.compare(password, user.password_hash);
|
||||
if (isValid) {
|
||||
const settings = db.getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) throw new Error('JWT secret missing from DB');
|
||||
const token = jwt.sign({ username }, jwtSecret, { expiresIn: '24h' });
|
||||
const token = jwt.sign({ username: user.username, role: user.role }, jwtSecret, { expiresIn: '24h' });
|
||||
res.cookie(COOKIE_NAME, token, getCookieOptions(req));
|
||||
res.json({ success: true, message: 'Login successful' });
|
||||
return;
|
||||
@@ -357,7 +360,7 @@ app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response)
|
||||
}
|
||||
});
|
||||
|
||||
// Update password endpoint
|
||||
// Update password endpoint — any authenticated user can change their own password
|
||||
app.put('/api/auth/password', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { oldPassword, newPassword } = req.body;
|
||||
@@ -371,21 +374,22 @@ app.put('/api/auth/password', authMiddleware, async (req: Request, res: Response
|
||||
}
|
||||
|
||||
const dbSvc = DatabaseService.getInstance();
|
||||
const settings = dbSvc.getGlobalSettings();
|
||||
const storedHash = settings.auth_password_hash;
|
||||
const user = dbSvc.getUserByUsername(req.user!.username);
|
||||
|
||||
if (!storedHash) {
|
||||
res.status(400).json({ error: 'Auth not configured properly' });
|
||||
if (!user) {
|
||||
res.status(400).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(oldPassword, storedHash);
|
||||
const isValid = await bcrypt.compare(oldPassword, user.password_hash);
|
||||
if (!isValid) {
|
||||
res.status(401).json({ error: 'Invalid old password' });
|
||||
return;
|
||||
}
|
||||
|
||||
const newHash = await bcrypt.hash(newPassword, 10);
|
||||
dbSvc.updateUser(user.id, { password_hash: newHash });
|
||||
// Keep global_settings in sync for backward compat
|
||||
dbSvc.updateGlobalSetting('auth_password_hash', newHash);
|
||||
res.json({ success: true, message: 'Password updated successfully' });
|
||||
} catch (error) {
|
||||
@@ -410,6 +414,7 @@ app.get('/api/auth/check', authMiddleware, (req: Request, res: Response): void =
|
||||
|
||||
// Generate a long-lived node proxy token for Sencho-to-Sencho authentication
|
||||
app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
@@ -445,6 +450,14 @@ const requirePro = (_req: Request, res: Response): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
if (req.user?.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Admin access required.', code: 'ADMIN_REQUIRED' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
app.get('/api/license', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const info = LicenseService.getInstance().getLicenseInfo();
|
||||
@@ -456,6 +469,7 @@ app.get('/api/license', (_req: Request, res: Response): void => {
|
||||
});
|
||||
|
||||
app.post('/api/license/activate', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { license_key } = req.body;
|
||||
if (!license_key || typeof license_key !== 'string') {
|
||||
@@ -475,6 +489,7 @@ app.post('/api/license/activate', async (req: Request, res: Response): Promise<v
|
||||
});
|
||||
|
||||
app.post('/api/license/deactivate', async (_req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(_req, res)) return;
|
||||
try {
|
||||
const result = await LicenseService.getInstance().deactivate();
|
||||
if (result.success) {
|
||||
@@ -751,6 +766,354 @@ async function fetchRemoteNodeOverview(node: Node): Promise<FleetNodeOverview> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fleet Snapshots (Pro) ───
|
||||
|
||||
interface SnapshotNodeData {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
stacks: Array<{
|
||||
stackName: string;
|
||||
files: Array<{ filename: string; content: string }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function captureLocalNodeFiles(node: Node): Promise<SnapshotNodeData> {
|
||||
const fsService = FileSystemService.getInstance(node.id);
|
||||
const stackNames = await fsService.getStacks();
|
||||
const stacks: SnapshotNodeData['stacks'] = [];
|
||||
|
||||
for (const stackName of stackNames) {
|
||||
const files: Array<{ filename: string; content: string }> = [];
|
||||
try {
|
||||
const composeContent = await fsService.getStackContent(stackName);
|
||||
files.push({ filename: 'compose.yaml', content: composeContent });
|
||||
} catch {
|
||||
// Stack has no compose file — skip
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const envContent = await fsService.getEnvContent(stackName);
|
||||
files.push({ filename: '.env', content: envContent });
|
||||
} catch {
|
||||
// No .env file — that's fine
|
||||
}
|
||||
stacks.push({ stackName, files });
|
||||
}
|
||||
|
||||
return { nodeId: node.id, nodeName: node.name, stacks };
|
||||
}
|
||||
|
||||
async function captureRemoteNodeFiles(node: Node): Promise<SnapshotNodeData> {
|
||||
if (!node.api_url || !node.api_token) {
|
||||
throw new Error('Remote node not configured');
|
||||
}
|
||||
|
||||
const baseUrl = node.api_url.replace(/\/$/, '');
|
||||
const headers = { Authorization: `Bearer ${node.api_token}` };
|
||||
|
||||
const stacksRes = await fetch(`${baseUrl}/api/stacks`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!stacksRes.ok) throw new Error('Failed to fetch stacks from remote node');
|
||||
const stackNames = await stacksRes.json() as string[];
|
||||
|
||||
const stacks: SnapshotNodeData['stacks'] = [];
|
||||
|
||||
for (const stackName of stackNames) {
|
||||
const files: Array<{ filename: string; content: string }> = [];
|
||||
try {
|
||||
const composeRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (composeRes.ok) {
|
||||
const content = await composeRes.text();
|
||||
files.push({ filename: 'compose.yaml', content });
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const envRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (envRes.ok) {
|
||||
const content = await envRes.text();
|
||||
files.push({ filename: '.env', content });
|
||||
}
|
||||
} catch {
|
||||
// No .env — skip
|
||||
}
|
||||
if (files.length > 0) {
|
||||
stacks.push({ stackName, files });
|
||||
}
|
||||
}
|
||||
|
||||
return { nodeId: node.id, nodeName: node.name, stacks };
|
||||
}
|
||||
|
||||
// Create fleet snapshot
|
||||
app.post('/api/fleet/snapshots', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
|
||||
try {
|
||||
const { description = '' } = req.body;
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodes = db.getNodes();
|
||||
const username = req.user?.username || 'admin';
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
nodes.map(async (node) => {
|
||||
if (node.type === 'remote') {
|
||||
return captureRemoteNodeFiles(node);
|
||||
}
|
||||
return captureLocalNodeFiles(node);
|
||||
})
|
||||
);
|
||||
|
||||
const capturedNodes: SnapshotNodeData[] = [];
|
||||
const skippedNodes: Array<{ nodeId: number; nodeName: string; reason: string }> = [];
|
||||
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
capturedNodes.push(result.value);
|
||||
} else {
|
||||
console.error(`[Fleet Snapshot] Failed to capture node ${nodes[i].name}:`, result.reason);
|
||||
skippedNodes.push({
|
||||
nodeId: nodes[i].id,
|
||||
nodeName: nodes[i].name,
|
||||
reason: result.reason instanceof Error ? result.reason.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let totalStacks = 0;
|
||||
const allFiles: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string; content: string }> = [];
|
||||
|
||||
for (const nodeData of capturedNodes) {
|
||||
totalStacks += nodeData.stacks.length;
|
||||
for (const stack of nodeData.stacks) {
|
||||
for (const file of stack.files) {
|
||||
allFiles.push({
|
||||
nodeId: nodeData.nodeId,
|
||||
nodeName: nodeData.nodeName,
|
||||
stackName: stack.stackName,
|
||||
filename: file.filename,
|
||||
content: file.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const snapshotId = db.createSnapshot(
|
||||
description,
|
||||
username,
|
||||
capturedNodes.length,
|
||||
totalStacks,
|
||||
JSON.stringify(skippedNodes),
|
||||
);
|
||||
|
||||
if (allFiles.length > 0) {
|
||||
db.insertSnapshotFiles(snapshotId, allFiles);
|
||||
}
|
||||
|
||||
const snapshot = db.getSnapshot(snapshotId);
|
||||
res.status(201).json(snapshot);
|
||||
} catch (error) {
|
||||
console.error('[Fleet Snapshot] Create error:', error);
|
||||
res.status(500).json({ error: 'Failed to create fleet snapshot' });
|
||||
}
|
||||
});
|
||||
|
||||
// List fleet snapshots
|
||||
app.get('/api/fleet/snapshots', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
|
||||
const offset = parseInt(req.query.offset as string, 10) || 0;
|
||||
const db = DatabaseService.getInstance();
|
||||
const snapshots = db.getSnapshots(limit, offset);
|
||||
const total = db.getSnapshotCount();
|
||||
res.json({ snapshots, total });
|
||||
} catch (error) {
|
||||
console.error('[Fleet Snapshot] List error:', error);
|
||||
res.status(500).json({ error: 'Failed to list fleet snapshots' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get snapshot detail
|
||||
app.get('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
const db = DatabaseService.getInstance();
|
||||
const snapshot = db.getSnapshot(id);
|
||||
if (!snapshot) {
|
||||
res.status(404).json({ error: 'Snapshot not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const files = db.getSnapshotFiles(id);
|
||||
|
||||
// Group files by node and stack
|
||||
const nodesMap = new Map<number, { nodeId: number; nodeName: string; stacks: Map<string, Array<{ filename: string; content: string }>> }>();
|
||||
for (const file of files) {
|
||||
if (!nodesMap.has(file.node_id)) {
|
||||
nodesMap.set(file.node_id, { nodeId: file.node_id, nodeName: file.node_name, stacks: new Map() });
|
||||
}
|
||||
const nodeEntry = nodesMap.get(file.node_id)!;
|
||||
if (!nodeEntry.stacks.has(file.stack_name)) {
|
||||
nodeEntry.stacks.set(file.stack_name, []);
|
||||
}
|
||||
nodeEntry.stacks.get(file.stack_name)!.push({ filename: file.filename, content: file.content });
|
||||
}
|
||||
|
||||
const nodes = Array.from(nodesMap.values()).map(n => ({
|
||||
nodeId: n.nodeId,
|
||||
nodeName: n.nodeName,
|
||||
stacks: Array.from(n.stacks.entries()).map(([stackName, stackFiles]) => ({
|
||||
stackName,
|
||||
files: stackFiles,
|
||||
})),
|
||||
}));
|
||||
|
||||
res.json({ ...snapshot, nodes });
|
||||
} catch (error) {
|
||||
console.error('[Fleet Snapshot] Detail error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch snapshot details' });
|
||||
}
|
||||
});
|
||||
|
||||
// Restore a stack from snapshot
|
||||
app.post('/api/fleet/snapshots/:id/restore', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
|
||||
try {
|
||||
const snapshotId = parseInt(req.params.id as string, 10);
|
||||
const { nodeId, stackName, redeploy = false } = req.body;
|
||||
|
||||
if (!nodeId || !stackName) {
|
||||
res.status(400).json({ error: 'nodeId and stackName are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const snapshot = db.getSnapshot(snapshotId);
|
||||
if (!snapshot) {
|
||||
res.status(404).json({ error: 'Snapshot not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const files = db.getSnapshotStackFiles(snapshotId, nodeId, stackName);
|
||||
if (files.length === 0) {
|
||||
res.status(404).json({ error: 'No files found for this stack in the snapshot' });
|
||||
return;
|
||||
}
|
||||
|
||||
const node = db.getNode(nodeId);
|
||||
if (!node) {
|
||||
res.status(404).json({ error: 'Target node no longer exists' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === 'local') {
|
||||
const fsService = FileSystemService.getInstance(node.id);
|
||||
|
||||
// Backup current files before restore
|
||||
try {
|
||||
await fsService.backupStackFiles(stackName);
|
||||
} catch {
|
||||
// Stack may not exist yet — that's ok
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (file.filename === 'compose.yaml') {
|
||||
await fsService.saveStackContent(stackName, file.content);
|
||||
} else if (file.filename === '.env') {
|
||||
await fsService.saveEnvContent(stackName, file.content);
|
||||
}
|
||||
}
|
||||
|
||||
if (redeploy) {
|
||||
const composeService = ComposeService.getInstance(node.id);
|
||||
await composeService.deployStack(stackName);
|
||||
}
|
||||
} else {
|
||||
// Remote node
|
||||
if (!node.api_url || !node.api_token) {
|
||||
res.status(503).json({ error: 'Remote node not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = node.api_url.replace(/\/$/, '');
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${node.api_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
for (const file of files) {
|
||||
if (file.filename === 'compose.yaml') {
|
||||
const putRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({ content: file.content }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!putRes.ok) throw new Error('Failed to restore compose file on remote node');
|
||||
} else if (file.filename === '.env') {
|
||||
const putRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({ content: file.content }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!putRes.ok) throw new Error('Failed to restore env file on remote node');
|
||||
}
|
||||
}
|
||||
|
||||
if (redeploy) {
|
||||
await fetch(`${baseUrl}/api/compose/${encodeURIComponent(stackName)}/up`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: 'Stack restored successfully', redeployed: redeploy });
|
||||
} catch (error) {
|
||||
console.error('[Fleet Snapshot] Restore error:', error);
|
||||
res.status(500).json({ error: 'Failed to restore stack from snapshot' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete snapshot
|
||||
app.delete('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
const db = DatabaseService.getInstance();
|
||||
const snapshot = db.getSnapshot(id);
|
||||
if (!snapshot) {
|
||||
res.status(404).json({ error: 'Snapshot not found' });
|
||||
return;
|
||||
}
|
||||
db.deleteSnapshot(id);
|
||||
res.json({ message: 'Snapshot deleted' });
|
||||
} catch (error) {
|
||||
console.error('[Fleet Snapshot] Delete error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete snapshot' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Webhooks (Pro) ─── CRUD requires auth + Pro, trigger is public with HMAC ───
|
||||
|
||||
// Webhook CRUD (auth + Pro required)
|
||||
@@ -767,6 +1130,7 @@ app.get('/api/webhooks', authMiddleware, async (_req: Request, res: Response): P
|
||||
});
|
||||
|
||||
app.post('/api/webhooks', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
try {
|
||||
const { name, stack_name, action, enabled } = req.body;
|
||||
@@ -795,6 +1159,7 @@ app.post('/api/webhooks', authMiddleware, async (req: Request, res: Response): P
|
||||
});
|
||||
|
||||
app.put('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
@@ -817,6 +1182,7 @@ app.put('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response)
|
||||
});
|
||||
|
||||
app.delete('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
@@ -879,7 +1245,8 @@ app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promi
|
||||
// Execute asynchronously — return 202 immediately
|
||||
res.status(202).json({ message: 'Webhook accepted', action });
|
||||
|
||||
svc.execute(id, action, triggerSource).catch(err => {
|
||||
const atomic = LicenseService.getInstance().getTier() === 'pro';
|
||||
svc.execute(id, action, triggerSource, atomic).catch(err => {
|
||||
console.error(`[Webhooks] Execution error for webhook ${id}:`, err);
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -888,6 +1255,150 @@ app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promi
|
||||
}
|
||||
});
|
||||
|
||||
// --- User Management (local-only, admin + Pro gated for creation) ---
|
||||
|
||||
app.get('/api/users', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const users = DatabaseService.getInstance().getUsers();
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
console.error('[Users] List error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch users' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/users', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
try {
|
||||
const { username, password, role } = req.body;
|
||||
|
||||
if (!username || !password || !role) {
|
||||
res.status(400).json({ error: 'Username, password, and role are required' });
|
||||
return;
|
||||
}
|
||||
if (typeof username !== 'string' || username.length < 3 || !/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
res.status(400).json({ error: 'Username must be at least 3 characters (letters, numbers, underscore, hyphen)' });
|
||||
return;
|
||||
}
|
||||
if (typeof password !== 'string' || password.length < 6) {
|
||||
res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
return;
|
||||
}
|
||||
if (role !== 'admin' && role !== 'viewer') {
|
||||
res.status(400).json({ error: 'Role must be "admin" or "viewer"' });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const existing = db.getUserByUsername(username);
|
||||
if (existing) {
|
||||
res.status(409).json({ error: 'A user with this username already exists' });
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const id = db.addUser({ username, password_hash: passwordHash, role });
|
||||
res.status(201).json({ id, username, role });
|
||||
} catch (error) {
|
||||
console.error('[Users] Create error:', error);
|
||||
res.status(500).json({ error: 'Failed to create user' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/users/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUser(id);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, password, role } = req.body;
|
||||
const updates: Partial<{ username: string; password_hash: string; role: string }> = {};
|
||||
|
||||
if (username !== undefined) {
|
||||
if (typeof username !== 'string' || username.length < 3 || !/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
res.status(400).json({ error: 'Username must be at least 3 characters (letters, numbers, underscore, hyphen)' });
|
||||
return;
|
||||
}
|
||||
const existing = db.getUserByUsername(username);
|
||||
if (existing && existing.id !== id) {
|
||||
res.status(409).json({ error: 'A user with this username already exists' });
|
||||
return;
|
||||
}
|
||||
updates.username = username;
|
||||
}
|
||||
|
||||
if (role !== undefined) {
|
||||
if (role !== 'admin' && role !== 'viewer') {
|
||||
res.status(400).json({ error: 'Role must be "admin" or "viewer"' });
|
||||
return;
|
||||
}
|
||||
// Prevent demoting yourself
|
||||
if (user.username === req.user!.username && role !== user.role) {
|
||||
res.status(400).json({ error: 'Cannot change your own role' });
|
||||
return;
|
||||
}
|
||||
// Prevent removing the last admin
|
||||
if (user.role === 'admin' && role === 'viewer' && db.getAdminCount() <= 1) {
|
||||
res.status(400).json({ error: 'Cannot demote the only admin user' });
|
||||
return;
|
||||
}
|
||||
updates.role = role;
|
||||
}
|
||||
|
||||
if (password !== undefined) {
|
||||
if (typeof password !== 'string' || password.length < 6) {
|
||||
res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
return;
|
||||
}
|
||||
updates.password_hash = await bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
db.updateUser(id, updates);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[Users] Update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update user' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/users/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUser(id);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Cannot delete yourself
|
||||
if (user.username === req.user!.username) {
|
||||
res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Cannot delete the last admin
|
||||
if (user.role === 'admin' && db.getAdminCount() <= 1) {
|
||||
res.status(400).json({ error: 'Cannot delete the only admin user' });
|
||||
return;
|
||||
}
|
||||
|
||||
db.deleteUser(id);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[Users] Delete error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete user' });
|
||||
}
|
||||
});
|
||||
|
||||
// Remote Node HTTP Proxy - single global instance.
|
||||
// Previously, createProxyMiddleware was called inside the request handler on every API
|
||||
// call, spawning a new proxy instance (and http-proxy server) each time. This caused:
|
||||
@@ -1264,6 +1775,7 @@ app.get('/api/stacks/:stackName', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.put('/api/stacks/:stackName', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (stackName.includes('..') || stackName.includes('/') || stackName.includes('\\')) {
|
||||
@@ -1409,6 +1921,7 @@ app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (stackName.includes('..') || stackName.includes('/') || stackName.includes('\\')) {
|
||||
@@ -1442,6 +1955,7 @@ app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.post('/api/stacks', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { stackName } = req.body;
|
||||
if (!stackName || typeof stackName !== 'string') {
|
||||
@@ -1462,6 +1976,7 @@ app.post('/api/stacks', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.delete('/api/stacks/:name', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const stackName = req.params.name as string;
|
||||
try {
|
||||
// Stage 1: Tell Docker to clean up ghost networks/containers
|
||||
@@ -1503,6 +2018,7 @@ app.get('/api/containers/:id/logs', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.post('/api/containers/:id/start', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
@@ -1514,6 +2030,7 @@ app.post('/api/containers/:id/start', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.post('/api/containers/:id/stop', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
@@ -1525,6 +2042,7 @@ app.post('/api/containers/:id/stop', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.post('/api/containers/:id/restart', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
@@ -1537,17 +2055,21 @@ app.post('/api/containers/:id/restart', async (req: Request, res: Response) => {
|
||||
|
||||
// End of legacy container routes
|
||||
app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined);
|
||||
const atomic = LicenseService.getInstance().getTier() === 'pro';
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic);
|
||||
res.json({ message: 'Deployed successfully' });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to deploy stack:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to deploy stack' });
|
||||
const rolledBack = LicenseService.getInstance().getTier() === 'pro';
|
||||
res.status(500).json({ error: error.message || 'Failed to deploy stack', rolledBack });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', terminalWs || undefined);
|
||||
@@ -1558,6 +2080,7 @@ app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
@@ -1576,6 +2099,7 @@ app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) =
|
||||
});
|
||||
|
||||
app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
@@ -1594,6 +2118,7 @@ app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
@@ -1613,13 +2138,49 @@ app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) =>
|
||||
|
||||
// Update stack: pull images and recreate containers
|
||||
app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
// Await update completion
|
||||
await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined);
|
||||
const atomic = LicenseService.getInstance().getTier() === 'pro';
|
||||
await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined, atomic);
|
||||
res.json({ status: 'Update completed' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to update' });
|
||||
const rolledBack = LicenseService.getInstance().getTier() === 'pro';
|
||||
res.status(500).json({ error: 'Failed to update', rolledBack });
|
||||
}
|
||||
});
|
||||
|
||||
// Manual rollback endpoint (Pro + Admin)
|
||||
app.post('/api/stacks/:stackName/rollback', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
const fsSvc = FileSystemService.getInstance(req.nodeId);
|
||||
const backupInfo = await fsSvc.getBackupInfo(stackName);
|
||||
if (!backupInfo.exists) {
|
||||
return res.status(404).json({ error: 'No backup available for this stack.' });
|
||||
}
|
||||
await fsSvc.restoreStackFiles(stackName);
|
||||
// Re-deploy with restored files (non-atomic to avoid loops)
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, false);
|
||||
res.json({ message: 'Stack rolled back successfully.' });
|
||||
} catch (error: any) {
|
||||
console.error('Rollback failed:', error);
|
||||
res.status(500).json({ error: error.message || 'Rollback failed.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Backup info endpoint (read-only)
|
||||
app.get('/api/stacks/:stackName/backup', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
const fsSvc = FileSystemService.getInstance(req.nodeId);
|
||||
const info = await fsSvc.getBackupInfo(stackName);
|
||||
res.json(info);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to get backup info:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get backup info.' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1925,6 +2486,7 @@ app.get('/api/agents', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.post('/api/agents', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const agent = req.body;
|
||||
DatabaseService.getInstance().upsertAgent(agent);
|
||||
@@ -1981,6 +2543,7 @@ app.get('/api/settings', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.post('/api/settings', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { key, value } = req.body;
|
||||
if (!key || typeof key !== 'string' || !ALLOWED_SETTING_KEYS.has(key)) {
|
||||
@@ -2000,6 +2563,7 @@ app.post('/api/settings', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.patch('/api/settings', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const parsed = SettingsPatchSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
@@ -2042,6 +2606,7 @@ const AlertCreateSchema = z.object({
|
||||
});
|
||||
|
||||
app.post('/api/alerts', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const parsed = AlertCreateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: 'Invalid alert data', details: parsed.error.flatten().fieldErrors });
|
||||
@@ -2057,6 +2622,7 @@ app.post('/api/alerts', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.delete('/api/alerts/:id', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
DatabaseService.getInstance().deleteStackAlert(id);
|
||||
@@ -2104,6 +2670,7 @@ app.delete('/api/notifications', authMiddleware, async (req: Request, res: Respo
|
||||
});
|
||||
|
||||
app.post('/api/notifications/test', authMiddleware, async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { type, url } = req.body;
|
||||
await NotificationService.getInstance().testDispatch(type, url);
|
||||
@@ -2119,6 +2686,7 @@ app.post('/api/notifications/test', authMiddleware, async (req: Request, res: Re
|
||||
// to receive a short-lived token. The remote's WS upgrade handler allows 'console_session'
|
||||
// tokens through its isProxyToken guard, keeping the long-lived api_token off interactive paths.
|
||||
app.post('/api/system/console-token', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
@@ -2149,6 +2717,7 @@ app.get('/api/system/orphans', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.post('/api/system/prune/orphans', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { containerIds } = req.body;
|
||||
if (!Array.isArray(containerIds)) {
|
||||
@@ -2164,6 +2733,7 @@ app.post('/api/system/prune/orphans', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.post('/api/system/prune/system', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { target, scope } = req.body as { target: string; scope?: string };
|
||||
if (!['containers', 'images', 'networks', 'volumes'].includes(target)) {
|
||||
@@ -2249,6 +2819,7 @@ app.get('/api/system/networks', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.post('/api/system/images/delete', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { id } = req.body;
|
||||
if (!id) return res.status(400).json({ error: 'ID is required' });
|
||||
@@ -2262,6 +2833,7 @@ app.post('/api/system/images/delete', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.post('/api/system/volumes/delete', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { id } = req.body;
|
||||
if (!id) return res.status(400).json({ error: 'ID is required' });
|
||||
@@ -2275,6 +2847,7 @@ app.post('/api/system/volumes/delete', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.post('/api/system/networks/delete', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { id } = req.body;
|
||||
if (!id) return res.status(400).json({ error: 'ID is required' });
|
||||
@@ -2299,11 +2872,13 @@ app.get('/api/templates', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.post('/api/templates/refresh-cache', authMiddleware, (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
templateService.clearCache();
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post('/api/templates/deploy', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { stackName, template, envVars } = req.body;
|
||||
|
||||
@@ -2336,7 +2911,8 @@ app.post('/api/templates/deploy', async (req: Request, res: Response) => {
|
||||
|
||||
// 4. Deploy the stack with atomic rollback
|
||||
try {
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined);
|
||||
const atomic = LicenseService.getInstance().getTier() === 'pro';
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic);
|
||||
res.json({ success: true, message: 'Template deployed successfully' });
|
||||
} catch (deployError: any) {
|
||||
const rawError = deployError.message || String(deployError);
|
||||
@@ -2387,6 +2963,7 @@ app.get('/api/image-updates', authMiddleware, (_req: Request, res: Response) =>
|
||||
});
|
||||
|
||||
app.post('/api/image-updates/refresh', authMiddleware, (_req: Request, res: Response) => {
|
||||
if (!requireAdmin(_req, res)) return;
|
||||
try {
|
||||
const triggered = ImageUpdateService.getInstance().triggerManualRefresh();
|
||||
if (!triggered) {
|
||||
@@ -2431,6 +3008,7 @@ app.get('/api/nodes/:id', async (req: Request, res: Response) => {
|
||||
|
||||
// Create a new node
|
||||
app.post('/api/nodes', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { name, type, compose_dir, is_default, api_url, api_token } = req.body;
|
||||
|
||||
@@ -2471,6 +3049,7 @@ app.post('/api/nodes', async (req: Request, res: Response) => {
|
||||
|
||||
// Update a node
|
||||
app.put('/api/nodes/:id', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const updates = req.body;
|
||||
@@ -2496,6 +3075,7 @@ app.put('/api/nodes/:id', async (req: Request, res: Response) => {
|
||||
|
||||
// Delete a node
|
||||
app.delete('/api/nodes/:id', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string);
|
||||
DatabaseService.getInstance().deleteNode(id);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import WebSocket from 'ws';
|
||||
import DockerController from './DockerController';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import { LogFormatter } from './LogFormatter';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
|
||||
@@ -77,43 +78,74 @@ export class ComposeService {
|
||||
await this.execute('docker', ['compose', action], stackDir, ws);
|
||||
}
|
||||
|
||||
async deployStack(stackName: string, ws?: WebSocket): Promise<void> {
|
||||
async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const sendOutput = (data: string) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||
};
|
||||
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(this.nodeId);
|
||||
const legacyContainers = await dockerController.getContainersByStack(stackName);
|
||||
if (legacyContainers && legacyContainers.length > 0) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(`=== Cleaning up existing containers for clean deployment ===\n`);
|
||||
await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id));
|
||||
// Atomic: backup files before deploying
|
||||
if (atomic) {
|
||||
try {
|
||||
const fsSvc = FileSystemService.getInstance(this.nodeId);
|
||||
await fsSvc.backupStackFiles(stackName);
|
||||
sendOutput('=== Backup created for atomic deployment ===\n');
|
||||
} catch (e) {
|
||||
console.warn(`Failed to backup stack files for ${stackName}:`, e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
|
||||
}
|
||||
|
||||
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
|
||||
try {
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(this.nodeId);
|
||||
const legacyContainers = await dockerController.getContainersByStack(stackName);
|
||||
if (legacyContainers && legacyContainers.length > 0) {
|
||||
sendOutput(`=== Cleaning up existing containers for clean deployment ===\n`);
|
||||
await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
|
||||
}
|
||||
|
||||
// Post-Deploy Health Probe
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
|
||||
|
||||
const dockerController = DockerController.getInstance(this.nodeId);
|
||||
const containers = await dockerController.getDocker().listContainers({
|
||||
all: true,
|
||||
filters: { label: [`com.docker.compose.project=${stackName}`] }
|
||||
});
|
||||
// Post-Deploy Health Probe
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
for (const containerInfo of containers) {
|
||||
if (containerInfo.State === 'exited') {
|
||||
const container = dockerController.getDocker().getContainer(containerInfo.Id);
|
||||
const inspectData = await container.inspect();
|
||||
const exitCode = inspectData.State.ExitCode;
|
||||
const dockerController = DockerController.getInstance(this.nodeId);
|
||||
const containers = await dockerController.getDocker().listContainers({
|
||||
all: true,
|
||||
filters: { label: [`com.docker.compose.project=${stackName}`] }
|
||||
});
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const logs = await container.logs({ stdout: true, stderr: true, tail: 50 });
|
||||
const logStr = logs.toString('utf-8');
|
||||
throw new Error(`CONTAINER_CRASHED\nExit Code: ${exitCode}\n${logStr}`);
|
||||
for (const containerInfo of containers) {
|
||||
if (containerInfo.State === 'exited') {
|
||||
const container = dockerController.getDocker().getContainer(containerInfo.Id);
|
||||
const inspectData = await container.inspect();
|
||||
const exitCode = inspectData.State.ExitCode;
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const logs = await container.logs({ stdout: true, stderr: true, tail: 50 });
|
||||
const logStr = logs.toString('utf-8');
|
||||
throw new Error(`CONTAINER_CRASHED\nExit Code: ${exitCode}\n${logStr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (deployError) {
|
||||
// Atomic: auto-rollback on failure
|
||||
if (atomic) {
|
||||
sendOutput('\n=== Deployment failed — rolling back to previous version ===\n');
|
||||
try {
|
||||
const fsSvc = FileSystemService.getInstance(this.nodeId);
|
||||
await fsSvc.restoreStackFiles(stackName);
|
||||
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
|
||||
sendOutput('=== Rolled back successfully ===\n');
|
||||
} catch (rollbackError) {
|
||||
console.error(`Rollback failed for ${stackName}:`, rollbackError);
|
||||
sendOutput('=== Rollback failed — manual intervention may be required ===\n');
|
||||
}
|
||||
}
|
||||
throw deployError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,29 +260,81 @@ export class ComposeService {
|
||||
startStream();
|
||||
}
|
||||
|
||||
async updateStack(stackName: string, ws?: WebSocket): Promise<void> {
|
||||
async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const sendOutput = (data: string) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||
};
|
||||
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(this.nodeId);
|
||||
const legacyContainers = await dockerController.getContainersByStack(stackName);
|
||||
if (legacyContainers && legacyContainers.length > 0) {
|
||||
sendOutput(`=== Cleaning up existing containers for clean update ===\n`);
|
||||
await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id));
|
||||
// Atomic: backup files before updating
|
||||
if (atomic) {
|
||||
try {
|
||||
const fsSvc = FileSystemService.getInstance(this.nodeId);
|
||||
await fsSvc.backupStackFiles(stackName);
|
||||
sendOutput('=== Backup created for atomic update ===\n');
|
||||
} catch (e) {
|
||||
console.warn(`Failed to backup stack files for ${stackName}:`, e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
|
||||
}
|
||||
|
||||
sendOutput('=== Pulling latest images ===\n');
|
||||
await this.execute('docker', ['compose', 'pull'], stackDir, ws);
|
||||
try {
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(this.nodeId);
|
||||
const legacyContainers = await dockerController.getContainersByStack(stackName);
|
||||
if (legacyContainers && legacyContainers.length > 0) {
|
||||
sendOutput(`=== Cleaning up existing containers for clean update ===\n`);
|
||||
await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
|
||||
}
|
||||
|
||||
sendOutput('=== Recreating containers ===\n');
|
||||
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
|
||||
sendOutput('=== Stack updated successfully ===\n');
|
||||
sendOutput('=== Pulling latest images ===\n');
|
||||
await this.execute('docker', ['compose', 'pull'], stackDir, ws);
|
||||
|
||||
sendOutput('=== Recreating containers ===\n');
|
||||
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
|
||||
|
||||
// Post-Update Health Probe
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
const dockerController = DockerController.getInstance(this.nodeId);
|
||||
const containers = await dockerController.getDocker().listContainers({
|
||||
all: true,
|
||||
filters: { label: [`com.docker.compose.project=${stackName}`] }
|
||||
});
|
||||
|
||||
for (const containerInfo of containers) {
|
||||
if (containerInfo.State === 'exited') {
|
||||
const container = dockerController.getDocker().getContainer(containerInfo.Id);
|
||||
const inspectData = await container.inspect();
|
||||
const exitCode = inspectData.State.ExitCode;
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const logs = await container.logs({ stdout: true, stderr: true, tail: 50 });
|
||||
const logStr = logs.toString('utf-8');
|
||||
throw new Error(`CONTAINER_CRASHED\nExit Code: ${exitCode}\n${logStr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendOutput('=== Stack updated successfully ===\n');
|
||||
} catch (updateError) {
|
||||
// Atomic: auto-rollback on failure
|
||||
if (atomic) {
|
||||
sendOutput('\n=== Update failed — rolling back to previous version ===\n');
|
||||
try {
|
||||
const fsSvc = FileSystemService.getInstance(this.nodeId);
|
||||
await fsSvc.restoreStackFiles(stackName);
|
||||
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
|
||||
sendOutput('=== Rolled back successfully ===\n');
|
||||
} catch (rollbackError) {
|
||||
console.error(`Rollback failed for ${stackName}:`, rollbackError);
|
||||
sendOutput('=== Rollback failed — manual intervention may be required ===\n');
|
||||
}
|
||||
}
|
||||
throw updateError;
|
||||
}
|
||||
}
|
||||
|
||||
public async downStack(stackName: string): Promise<void> {
|
||||
|
||||
@@ -59,6 +59,15 @@ export interface WebhookExecution {
|
||||
executed_at: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role: 'admin' | 'viewer';
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface NotificationHistory {
|
||||
id?: number;
|
||||
level: 'info' | 'warning' | 'error';
|
||||
@@ -67,6 +76,26 @@ export interface NotificationHistory {
|
||||
is_read: boolean;
|
||||
}
|
||||
|
||||
export interface FleetSnapshot {
|
||||
id: number;
|
||||
description: string;
|
||||
created_by: string;
|
||||
node_count: number;
|
||||
stack_count: number;
|
||||
skipped_nodes: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface FleetSnapshotFile {
|
||||
id: number;
|
||||
snapshot_id: number;
|
||||
node_id: number;
|
||||
node_name: string;
|
||||
stack_name: string;
|
||||
filename: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export class DatabaseService {
|
||||
private static instance: DatabaseService;
|
||||
private db: Database.Database;
|
||||
@@ -83,6 +112,7 @@ export class DatabaseService {
|
||||
|
||||
this.initSchema();
|
||||
this.migrateJsonConfig(dataDir);
|
||||
this.migrateAdminToUsersTable();
|
||||
}
|
||||
|
||||
public static getInstance(): DatabaseService {
|
||||
@@ -188,6 +218,38 @@ export class DatabaseService {
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_executions_webhook ON webhook_executions(webhook_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fleet_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL,
|
||||
node_count INTEGER NOT NULL,
|
||||
stack_count INTEGER NOT NULL,
|
||||
skipped_nodes TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fleet_snapshot_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
node_name TEXT NOT NULL,
|
||||
stack_name TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
FOREIGN KEY(snapshot_id) REFERENCES fleet_snapshots(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshot_files_snapshot ON fleet_snapshot_files(snapshot_id);
|
||||
`);
|
||||
|
||||
// Apply migrations safely (ignore if columns already exist)
|
||||
@@ -231,6 +293,22 @@ export class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private migrateAdminToUsersTable(): void {
|
||||
const userCount = (this.db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number })?.count || 0;
|
||||
if (userCount > 0) return;
|
||||
|
||||
const settings = this.getGlobalSettings();
|
||||
const username = settings.auth_username;
|
||||
const passwordHash = settings.auth_password_hash;
|
||||
if (!username || !passwordHash) return;
|
||||
|
||||
const now = Date.now();
|
||||
this.db.prepare(
|
||||
'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(username, passwordHash, 'admin', now, now);
|
||||
console.log(`Migrated admin user "${username}" to users table.`);
|
||||
}
|
||||
|
||||
private migrateJsonConfig(dataDir: string) {
|
||||
const configPath = path.join(dataDir, 'sencho.json');
|
||||
if (fs.existsSync(configPath)) {
|
||||
@@ -593,4 +671,105 @@ export class DatabaseService {
|
||||
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
|
||||
public getUsers(): Omit<User, 'password_hash'>[] {
|
||||
return this.db.prepare('SELECT id, username, role, created_at, updated_at FROM users ORDER BY created_at ASC').all() as Omit<User, 'password_hash'>[];
|
||||
}
|
||||
|
||||
public getUser(id: number): User | undefined {
|
||||
return this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined;
|
||||
}
|
||||
|
||||
public getUserByUsername(username: string): User | undefined {
|
||||
return this.db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined;
|
||||
}
|
||||
|
||||
public addUser(user: { username: string; password_hash: string; role: 'admin' | 'viewer' }): number {
|
||||
const now = Date.now();
|
||||
const result = this.db.prepare(
|
||||
'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(user.username, user.password_hash, user.role, now, now);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
public updateUser(id: number, updates: Partial<{ username: string; password_hash: string; role: string }>): void {
|
||||
const fields: string[] = [];
|
||||
const values: (string | number)[] = [];
|
||||
|
||||
if (updates.username !== undefined) { fields.push('username = ?'); values.push(updates.username); }
|
||||
if (updates.password_hash !== undefined) { fields.push('password_hash = ?'); values.push(updates.password_hash); }
|
||||
if (updates.role !== undefined) { fields.push('role = ?'); values.push(updates.role); }
|
||||
|
||||
if (fields.length === 0) return;
|
||||
|
||||
fields.push('updated_at = ?');
|
||||
values.push(Date.now());
|
||||
values.push(id);
|
||||
this.db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
}
|
||||
|
||||
public deleteUser(id: number): void {
|
||||
this.db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
public getUserCount(): number {
|
||||
return (this.db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number })?.count || 0;
|
||||
}
|
||||
|
||||
public getAdminCount(): number {
|
||||
return (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number })?.count || 0;
|
||||
}
|
||||
|
||||
// --- Fleet Snapshots ---
|
||||
|
||||
public createSnapshot(description: string, createdBy: string, nodeCount: number, stackCount: number, skippedNodes: string): number {
|
||||
const result = this.db.prepare(
|
||||
'INSERT INTO fleet_snapshots (description, created_by, node_count, stack_count, skipped_nodes, created_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(description, createdBy, nodeCount, stackCount, skippedNodes, Date.now());
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
public insertSnapshotFiles(snapshotId: number, files: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string; content: string }>): void {
|
||||
const insert = this.db.prepare(
|
||||
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const insertMany = this.db.transaction((rows: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string; content: string }>) => {
|
||||
for (const row of rows) {
|
||||
insert.run(snapshotId, row.nodeId, row.nodeName, row.stackName, row.filename, row.content);
|
||||
}
|
||||
});
|
||||
insertMany(files);
|
||||
}
|
||||
|
||||
public getSnapshots(limit = 50, offset = 0): FleetSnapshot[] {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM fleet_snapshots ORDER BY created_at DESC LIMIT ? OFFSET ?'
|
||||
).all(limit, offset) as FleetSnapshot[];
|
||||
}
|
||||
|
||||
public getSnapshot(id: number): FleetSnapshot | undefined {
|
||||
return this.db.prepare('SELECT * FROM fleet_snapshots WHERE id = ?').get(id) as FleetSnapshot | undefined;
|
||||
}
|
||||
|
||||
public getSnapshotFiles(snapshotId: number): FleetSnapshotFile[] {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM fleet_snapshot_files WHERE snapshot_id = ? ORDER BY node_name, stack_name'
|
||||
).all(snapshotId) as FleetSnapshotFile[];
|
||||
}
|
||||
|
||||
public getSnapshotStackFiles(snapshotId: number, nodeId: number, stackName: string): FleetSnapshotFile[] {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM fleet_snapshot_files WHERE snapshot_id = ? AND node_id = ? AND stack_name = ?'
|
||||
).all(snapshotId, nodeId, stackName) as FleetSnapshotFile[];
|
||||
}
|
||||
|
||||
public deleteSnapshot(id: number): void {
|
||||
this.db.prepare('DELETE FROM fleet_snapshots WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
public getSnapshotCount(): number {
|
||||
return (this.db.prepare('SELECT COUNT(*) as count FROM fleet_snapshots').get() as { count: number })?.count || 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,4 +236,70 @@ export class FileSystemService {
|
||||
console.error('Migration error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup stack files (compose.yaml + .env) to .sencho-backup/ within the stack dir.
|
||||
*/
|
||||
async backupStackFiles(stackName: string): Promise<void> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const backupDir = path.join(stackDir, '.sencho-backup');
|
||||
await fsPromises.mkdir(backupDir, { recursive: true });
|
||||
|
||||
// Copy compose file
|
||||
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
||||
for (const file of composeFiles) {
|
||||
const src = path.join(stackDir, file);
|
||||
try {
|
||||
await fsPromises.access(src);
|
||||
await fsPromises.copyFile(src, path.join(backupDir, file));
|
||||
} catch {
|
||||
// File doesn't exist, skip
|
||||
}
|
||||
}
|
||||
|
||||
// Copy .env if it exists
|
||||
const envSrc = path.join(stackDir, '.env');
|
||||
try {
|
||||
await fsPromises.access(envSrc);
|
||||
await fsPromises.copyFile(envSrc, path.join(backupDir, '.env'));
|
||||
} catch {
|
||||
// No .env to backup
|
||||
}
|
||||
|
||||
// Write timestamp marker
|
||||
await fsPromises.writeFile(path.join(backupDir, '.timestamp'), Date.now().toString(), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore stack files from .sencho-backup/ back to the stack dir.
|
||||
*/
|
||||
async restoreStackFiles(stackName: string): Promise<void> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const backupDir = path.join(stackDir, '.sencho-backup');
|
||||
|
||||
const items = await fsPromises.readdir(backupDir);
|
||||
for (const item of items) {
|
||||
if (item === '.timestamp') continue;
|
||||
await fsPromises.copyFile(path.join(backupDir, item), path.join(stackDir, item));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup info for a stack.
|
||||
*/
|
||||
async getBackupInfo(stackName: string): Promise<{ exists: boolean; timestamp: number | null }> {
|
||||
const backupDir = path.join(this.baseDir, stackName, '.sencho-backup');
|
||||
try {
|
||||
await fsPromises.access(backupDir);
|
||||
const tsFile = path.join(backupDir, '.timestamp');
|
||||
try {
|
||||
const ts = await fsPromises.readFile(tsFile, 'utf-8');
|
||||
return { exists: true, timestamp: parseInt(ts, 10) || null };
|
||||
} catch {
|
||||
return { exists: true, timestamp: null };
|
||||
}
|
||||
} catch {
|
||||
return { exists: false, timestamp: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export class WebhookService {
|
||||
);
|
||||
}
|
||||
|
||||
public async execute(webhookId: number, action: string, triggerSource: string | null): Promise<{ success: boolean; error?: string; duration_ms: number }> {
|
||||
public async execute(webhookId: number, action: string, triggerSource: string | null, atomic?: boolean): Promise<{ success: boolean; error?: string; duration_ms: number }> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const webhook = db.getWebhook(webhookId);
|
||||
if (!webhook) throw new Error('Webhook not found');
|
||||
@@ -62,7 +62,7 @@ export class WebhookService {
|
||||
const compose = ComposeService.getInstance(defaultNodeId);
|
||||
switch (action) {
|
||||
case 'deploy':
|
||||
await compose.deployStack(webhook.stack_name);
|
||||
await compose.deployStack(webhook.stack_name, undefined, atomic);
|
||||
break;
|
||||
case 'restart':
|
||||
await compose.runCommand(webhook.stack_name, 'restart');
|
||||
@@ -74,7 +74,7 @@ export class WebhookService {
|
||||
await compose.runCommand(webhook.stack_name, 'start');
|
||||
break;
|
||||
case 'pull':
|
||||
await compose.updateStack(webhook.stack_name);
|
||||
await compose.updateStack(webhook.stack_name, undefined, atomic);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
|
||||
+4
-1
@@ -37,7 +37,10 @@
|
||||
"features/multi-node",
|
||||
"features/fleet-view",
|
||||
"features/alerts-notifications",
|
||||
"features/webhooks"
|
||||
"features/webhooks",
|
||||
"features/rbac",
|
||||
"features/atomic-deployments",
|
||||
"features/fleet-backups"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: Atomic Deployments
|
||||
description: Zero-downtime deployments with automatic rollback for Sencho Pro users.
|
||||
---
|
||||
|
||||
<Note>
|
||||
Atomic Deployments require a Sencho Pro license. Community Edition uses standard deployments without backup or rollback.
|
||||
</Note>
|
||||
|
||||
Sencho Pro wraps every deployment in a safety net. Before applying changes, it backs up your current configuration. If the deployment fails, it automatically rolls back to the previous working state.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Backup** — Before a deploy or update, Sencho copies `compose.yaml` and `.env` files to a `.sencho-backup/` directory inside the stack folder
|
||||
2. **Deploy** — Sencho runs the requested compose operation (up, pull + recreate, etc.)
|
||||
3. **Health probe** — After deployment, Sencho checks whether any containers exited with a non-zero exit code
|
||||
4. **Auto-rollback** — If a crash is detected, Sencho restores the backed-up files and re-deploys automatically
|
||||
|
||||
This entire sequence happens transparently. You see a single deploy action — Sencho handles the safety logic behind the scenes.
|
||||
|
||||
## Manual rollback
|
||||
|
||||
Pro admins can manually roll back to the previous deployment at any time by clicking the **Rollback** button in the stack action bar. The button appears whenever a backup exists, and its tooltip shows the timestamp of the last backup.
|
||||
|
||||
Clicking Rollback restores the backed-up `compose.yaml` and `.env` files, then re-deploys the stack with the restored configuration.
|
||||
|
||||
## Webhook support
|
||||
|
||||
Deployments triggered via webhooks also use atomic rollback. Whether you deploy manually through the UI or automatically from a CI/CD pipeline, the same backup-and-recover flow applies.
|
||||
|
||||
## Community Edition behavior
|
||||
|
||||
Community users continue to use the standard deploy flow — no backup is created and no rollback is available. Upgrading to Pro enables atomic deployments immediately with no configuration required.
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: Fleet-Wide Backups
|
||||
description: Snapshot compose files across all nodes for disaster recovery and auditing.
|
||||
---
|
||||
|
||||
<Note>
|
||||
Fleet-Wide Backups require a Sencho Pro license. The feature is available to Pro admins in the Fleet View.
|
||||
</Note>
|
||||
|
||||
Create point-in-time snapshots of every `compose.yaml` and `.env` file across your entire fleet — local and remote nodes alike. Snapshots are stored centrally in Sencho's database and can be browsed, previewed, and restored at any time.
|
||||
|
||||
## Creating a snapshot
|
||||
|
||||
1. Navigate to **Fleet View** and select the **Snapshots** tab
|
||||
2. Click **Create Snapshot**
|
||||
3. Optionally enter a description (e.g. "Before v2 migration")
|
||||
4. Click **Create** — Sencho captures files from every reachable node
|
||||
|
||||
During creation, Sencho connects to each node in parallel:
|
||||
- **Local nodes** — reads files directly from the compose directory
|
||||
- **Remote nodes** — fetches files via the Distributed API proxy using the node's API token
|
||||
|
||||
If a remote node is offline or unreachable, it is **skipped gracefully**. The snapshot is still created with data from all reachable nodes, and skipped nodes are recorded with the reason for the failure.
|
||||
|
||||
## Browsing snapshots
|
||||
|
||||
The snapshot list shows:
|
||||
- **Date** — when the snapshot was taken
|
||||
- **Description** — your optional label
|
||||
- **Scope** — how many nodes and stacks were captured
|
||||
- **Warnings** — an indicator if any nodes were skipped
|
||||
|
||||
Click **View** to open the detail view, which presents a collapsible tree:
|
||||
|
||||
```
|
||||
Node A (local)
|
||||
├── traefik/
|
||||
│ ├── compose.yaml
|
||||
│ └── .env
|
||||
└── postgres/
|
||||
└── compose.yaml
|
||||
Node B (remote)
|
||||
└── grafana/
|
||||
├── compose.yaml
|
||||
└── .env
|
||||
```
|
||||
|
||||
Expand any file to preview its contents inline.
|
||||
|
||||
## Restoring from a snapshot
|
||||
|
||||
Admins can restore individual stacks from any snapshot:
|
||||
|
||||
1. Open a snapshot's detail view
|
||||
2. Find the stack you want to restore
|
||||
3. Click **Restore**
|
||||
4. Optionally check **Redeploy stack after restore** to immediately apply the restored configuration
|
||||
5. Confirm the action
|
||||
|
||||
Sencho writes the snapshot's files back to the target node:
|
||||
- **Local nodes** — files are written directly, and the current files are backed up first (creating a rollback point via the atomic deployment system)
|
||||
- **Remote nodes** — files are pushed via the Distributed API proxy
|
||||
|
||||
<Warning>
|
||||
Restoring overwrites the current compose and environment files on the target node. If atomic deployments are enabled, the current files are backed up before restoration.
|
||||
</Warning>
|
||||
|
||||
## Deleting snapshots
|
||||
|
||||
Admins can delete snapshots from the list view. Deleting a snapshot permanently removes all captured file data from the database. This action cannot be undone.
|
||||
|
||||
## Access control
|
||||
|
||||
| Action | Admin | Viewer |
|
||||
|--------|-------|--------|
|
||||
| View snapshot list | Yes | Yes |
|
||||
| Browse snapshot contents | Yes | Yes |
|
||||
| Create snapshot | Yes | No |
|
||||
| Restore from snapshot | Yes | No |
|
||||
| Delete snapshot | Yes | No |
|
||||
|
||||
## Storage
|
||||
|
||||
Snapshots are stored in Sencho's SQLite database. Compose files are typically small (under 10 KB each), so even hundreds of snapshots consume minimal disk space. For very large fleets, consider periodically deleting old snapshots to keep the database lean.
|
||||
@@ -19,6 +19,18 @@ Add remote Sencho instances as nodes. All dashboard operations — stack managem
|
||||
|
||||
Monitor your entire infrastructure from a single screen. The fleet dashboard shows all nodes with health metrics, container counts, and resource usage. Pro users unlock fleet health summary cards, container drill-down, search, sorting, filtering, and critical node detection. [Learn more →](/features/fleet-view)
|
||||
|
||||
## RBAC & user management
|
||||
|
||||
Create viewer accounts with read-only access to dashboards, logs, and file contents — while keeping deploy and edit permissions locked to admins. Sencho Pro supports two roles: Admin (full access) and Viewer (read-only). [Learn more →](/features/rbac)
|
||||
|
||||
## Atomic deployments
|
||||
|
||||
Pro users get automatic backup and rollback on every deployment. Before applying changes, Sencho snapshots your compose and environment files. If containers crash after deploy, the previous configuration is restored automatically. [Learn more →](/features/atomic-deployments)
|
||||
|
||||
## Fleet-wide backups
|
||||
|
||||
Create point-in-time snapshots of every compose file and environment file across all nodes. Snapshots are stored centrally and can be browsed by node and stack. Restore individual stacks from any snapshot with optional one-click redeploy — even to remote nodes. Offline nodes are gracefully skipped with visible warnings. [Learn more →](/features/fleet-backups)
|
||||
|
||||
## Real-time logs & stats
|
||||
|
||||
Stream container logs and resource metrics (CPU, memory, network I/O) live in the browser via WebSocket and Server-Sent Events connections. The Home dashboard shows historical CPU and RAM charts over the last 24 hours.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: RBAC & User Management
|
||||
description: Role-based access control for Sencho Pro — create admin and viewer accounts to control who can modify your stacks.
|
||||
---
|
||||
|
||||
<Note>
|
||||
RBAC requires a Sencho Pro license. Community Edition supports a single admin account only.
|
||||
</Note>
|
||||
|
||||
Sencho Pro introduces role-based access control with two distinct roles: **Admin** and **Viewer**. This lets you give team members read-only access to your infrastructure without risking accidental changes.
|
||||
|
||||
## Roles
|
||||
|
||||
| Role | Description |
|
||||
|------|-------------|
|
||||
| **Admin** | Full access to all features — deploy, edit, manage users, configure nodes, and more |
|
||||
| **Viewer** | Read-only access to dashboards, logs, stats, and file contents |
|
||||
|
||||
### What viewers can see
|
||||
|
||||
- Dashboard and home metrics
|
||||
- Stacks list and stack detail view
|
||||
- Compose and `.env` file contents (read-only)
|
||||
- Per-container stats and logs
|
||||
- Fleet view
|
||||
- Resources hub (images, volumes, networks — read-only)
|
||||
- Global logs
|
||||
- Notifications
|
||||
|
||||
### What viewers cannot do
|
||||
|
||||
- Edit compose or environment files
|
||||
- Deploy, restart, stop, or start stacks
|
||||
- Create or delete stacks
|
||||
- Manage users, nodes, webhooks, or alerts
|
||||
- Access the host console
|
||||
- Prune resources
|
||||
- Change settings
|
||||
|
||||
## Managing users
|
||||
|
||||
Admins can manage accounts in **Settings → Users**. From there you can:
|
||||
|
||||
- **Create** a new user with a username, password, and role (Admin or Viewer)
|
||||
- **Edit** an existing user's password or role
|
||||
- **Delete** a user account
|
||||
|
||||
## Migration from single-admin setup
|
||||
|
||||
When you upgrade to Sencho Pro, your existing single-admin credentials are automatically migrated to the new users table. No manual action is required — your login continues to work as before, and your account is assigned the Admin role.
|
||||
|
||||
## License tiers
|
||||
|
||||
| Tier | Admin accounts | Viewer accounts |
|
||||
|------|---------------|-----------------|
|
||||
| **Community** | 1 | 0 |
|
||||
| **Personal Pro** | 1 | 1 |
|
||||
| **Team Pro** | Unlimited | Unlimited |
|
||||
@@ -10,6 +10,7 @@ import { Search, Rocket, Loader2, Info, ExternalLink, Star } from "lucide-react"
|
||||
import { toast } from "sonner";
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
export interface TemplateEnv {
|
||||
name: string;
|
||||
@@ -44,6 +45,7 @@ interface AppStoreViewProps {
|
||||
}
|
||||
|
||||
export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { activeNode } = useNodes();
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -471,9 +473,10 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<Button
|
||||
onClick={handleDeploy}
|
||||
disabled={isDeploying || !stackName.trim()}
|
||||
disabled={isDeploying || !stackName.trim() || !isAdmin}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
title={!isAdmin ? 'Admin access required to deploy' : undefined}
|
||||
>
|
||||
{isDeploying ? (
|
||||
<>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
|
||||
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 } from 'lucide-react';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2 } from 'lucide-react';
|
||||
import { UserProfileDropdown } from './UserProfileDropdown';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
@@ -37,6 +37,8 @@ import { GlobalObservabilityView } from './GlobalObservabilityView';
|
||||
import { FleetView } from './FleetView';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
|
||||
interface ContainerInfo {
|
||||
Id: string;
|
||||
@@ -69,6 +71,8 @@ const formatBytes = (bytes: number) => {
|
||||
};
|
||||
|
||||
export default function EditorLayout() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPro } = useLicense();
|
||||
const { nodes, activeNode, setActiveNode } = useNodes();
|
||||
// Stable ref so notification callbacks always read the latest nodes list
|
||||
// without needing nodes in their dependency arrays (which would cause loops).
|
||||
@@ -103,6 +107,7 @@ export default function EditorLayout() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingAction, setLoadingAction] = useState<string | null>(null);
|
||||
const [isFileLoading, setIsFileLoading] = useState(false);
|
||||
const [backupInfo, setBackupInfo] = useState<{ exists: boolean; timestamp: number | null }>({ exists: false, timestamp: null });
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const saved = localStorage.getItem('sencho-theme') as Theme | null;
|
||||
if (saved === 'light' || saved === 'dark' || saved === 'auto') return saved;
|
||||
@@ -624,6 +629,17 @@ export default function EditorLayout() {
|
||||
console.error('Failed to load containers:', error);
|
||||
setContainers([]);
|
||||
}
|
||||
|
||||
// Load backup info (Pro only)
|
||||
if (isPro) {
|
||||
try {
|
||||
const backupRes = await apiFetch(`/stacks/${filename}/backup`);
|
||||
if (backupRes.ok) setBackupInfo(await backupRes.json());
|
||||
else setBackupInfo({ exists: false, timestamp: null });
|
||||
} catch {
|
||||
setBackupInfo({ exists: false, timestamp: null });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load file:', error);
|
||||
setSelectedFile(null);
|
||||
@@ -678,6 +694,32 @@ export default function EditorLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
const rollbackStack = async () => {
|
||||
if (!selectedFile || loadingAction !== null) return;
|
||||
setLoadingAction('rollback');
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${selectedFile}/rollback`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err?.error || 'Rollback failed');
|
||||
}
|
||||
toast.success('Stack rolled back successfully.');
|
||||
// Reload the editor content
|
||||
const contentRes = await apiFetch(`/stacks/${selectedFile}`);
|
||||
const text = await contentRes.text();
|
||||
setContent(text || '');
|
||||
setOriginalContent(text || '');
|
||||
// Refresh backup info
|
||||
const backupRes = await apiFetch(`/stacks/${selectedFile}/backup`);
|
||||
if (backupRes.ok) setBackupInfo(await backupRes.json());
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'Rollback failed';
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAndDeploy = async (e: React.MouseEvent) => {
|
||||
await saveFile();
|
||||
await deployStack(e);
|
||||
@@ -716,9 +758,17 @@ export default function EditorLayout() {
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
await refreshStacks(true);
|
||||
// Refresh backup info
|
||||
if (isPro) {
|
||||
try {
|
||||
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
||||
if (backupRes.ok) setBackupInfo(await backupRes.json());
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy:', error);
|
||||
toast.error((error as Error).message || 'Failed to deploy stack');
|
||||
const msg = (error as Error).message || 'Failed to deploy stack';
|
||||
toast.error(isPro ? `${msg} — automatically rolled back to previous version.` : msg);
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
}
|
||||
@@ -969,7 +1019,7 @@ export default function EditorLayout() {
|
||||
)}
|
||||
|
||||
{/* Create Stack Button */}
|
||||
<div className="p-4">
|
||||
{isAdmin && <div className="p-4">
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-full rounded-lg">
|
||||
@@ -995,7 +1045,7 @@ export default function EditorLayout() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Search Input & Stack List */}
|
||||
<Command className="bg-transparent flex-1 flex flex-col overflow-hidden">
|
||||
@@ -1119,6 +1169,7 @@ export default function EditorLayout() {
|
||||
Fleet
|
||||
</Button>
|
||||
{/* Console Toggle */}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant={activeView === 'host-console' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
@@ -1128,6 +1179,7 @@ export default function EditorLayout() {
|
||||
<Terminal className="w-4 h-4 mr-2" />
|
||||
Console
|
||||
</Button>
|
||||
)}
|
||||
{/* Resources Toggle */}
|
||||
<Button
|
||||
variant={activeView === 'resources' ? 'default' : 'outline'}
|
||||
@@ -1263,6 +1315,7 @@ export default function EditorLayout() {
|
||||
{/* Stack Name */}
|
||||
<CardTitle className="text-2xl font-bold">{stackName}</CardTitle>
|
||||
{/* Action Bar */}
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isRunning ? (
|
||||
<>
|
||||
@@ -1285,6 +1338,23 @@ export default function EditorLayout() {
|
||||
<CloudDownload className="w-4 h-4 mr-2" />
|
||||
{loadingAction === 'update' ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
{isPro && backupInfo.exists && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={rollbackStack} disabled={loadingAction !== null}>
|
||||
<Undo2 className="w-4 h-4 mr-2" />
|
||||
{loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{backupInfo.timestamp
|
||||
? `Roll back to backup from ${new Date(backupInfo.timestamp).toLocaleString()}`
|
||||
: 'Roll back to previous deployment'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -1300,6 +1370,7 @@ export default function EditorLayout() {
|
||||
{loadingAction === 'delete' ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
@@ -1391,6 +1462,7 @@ export default function EditorLayout() {
|
||||
<TooltipContent>View Live Logs</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{isAdmin && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -1407,6 +1479,7 @@ export default function EditorLayout() {
|
||||
<TooltipContent>Open Bash Terminal</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1464,6 +1537,7 @@ export default function EditorLayout() {
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex gap-2">
|
||||
{!isEditing ? (
|
||||
<Button size="sm" variant="default" className="rounded-lg" onClick={enterEditMode}>
|
||||
@@ -1487,6 +1561,7 @@ export default function EditorLayout() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{activeTab === 'env' && (
|
||||
@@ -1517,7 +1592,7 @@ export default function EditorLayout() {
|
||||
fontSize: 14,
|
||||
padding: { top: 10 },
|
||||
scrollBeyondLastLine: false,
|
||||
readOnly: !isEditing,
|
||||
readOnly: !isEditing || !isAdmin,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Camera, ArrowLeft, Server, Layers, FileText, AlertTriangle, Trash2,
|
||||
Eye, ChevronDown, ChevronRight, Plus, Loader2, RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface FleetSnapshot {
|
||||
id: number;
|
||||
description: string;
|
||||
created_by: string;
|
||||
node_count: number;
|
||||
stack_count: number;
|
||||
skipped_nodes: string; // JSON string
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface SnapshotStackFile {
|
||||
filename: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface SnapshotStack {
|
||||
stackName: string;
|
||||
files: SnapshotStackFile[];
|
||||
}
|
||||
|
||||
interface SnapshotNode {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
stacks: SnapshotStack[];
|
||||
}
|
||||
|
||||
interface FleetSnapshotDetail extends FleetSnapshot {
|
||||
nodes: SnapshotNode[];
|
||||
}
|
||||
|
||||
interface SkippedNode {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
export default function FleetSnapshots() {
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
const [snapshots, setSnapshots] = useState<FleetSnapshot[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [description, setDescription] = useState('');
|
||||
const [selectedSnapshot, setSelectedSnapshot] = useState<FleetSnapshotDetail | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'list' | 'detail'>('list');
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<number>>(new Set());
|
||||
const [expandedStacks, setExpandedStacks] = useState<Set<string>>(new Set());
|
||||
const [previewFiles, setPreviewFiles] = useState<Set<string>>(new Set());
|
||||
const [restoringStack, setRestoringStack] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
|
||||
// --- Data Fetching ---
|
||||
|
||||
const fetchSnapshots = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/fleet/snapshots', { localOnly: true });
|
||||
if (res.ok) {
|
||||
const data: { snapshots: FleetSnapshot[]; total: number } = await res.json();
|
||||
setSnapshots(data.snapshots);
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to load snapshots.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Record<string, unknown> | null;
|
||||
toast.error(err?.message as string || err?.error as string || 'Something went wrong.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSnapshots();
|
||||
}, [fetchSnapshots]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await apiFetch('/fleet/snapshots', {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({ description: description.trim() || undefined }),
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success('Snapshot created successfully.');
|
||||
setShowCreateForm(false);
|
||||
setDescription('');
|
||||
await fetchSnapshots();
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to create snapshot.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Record<string, unknown> | null;
|
||||
toast.error(err?.message as string || err?.error as string || 'Something went wrong.');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetail = async (snapshot: FleetSnapshot) => {
|
||||
setLoadingDetail(true);
|
||||
setViewMode('detail');
|
||||
setExpandedNodes(new Set());
|
||||
setExpandedStacks(new Set());
|
||||
setPreviewFiles(new Set());
|
||||
try {
|
||||
const res = await apiFetch(`/fleet/snapshots/${snapshot.id}`, { localOnly: true });
|
||||
if (res.ok) {
|
||||
const data: FleetSnapshotDetail = await res.json();
|
||||
setSelectedSnapshot(data);
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to load snapshot details.');
|
||||
setViewMode('list');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Record<string, unknown> | null;
|
||||
toast.error(err?.message as string || err?.error as string || 'Something went wrong.');
|
||||
setViewMode('list');
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
setDeletingId(id);
|
||||
try {
|
||||
const res = await apiFetch(`/fleet/snapshots/${id}`, {
|
||||
method: 'DELETE',
|
||||
localOnly: true,
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success('Snapshot deleted.');
|
||||
setSnapshots(prev => prev.filter(s => s.id !== id));
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to delete snapshot.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Record<string, unknown> | null;
|
||||
toast.error(err?.message as string || err?.error as string || 'Something went wrong.');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (nodeId: number, stackName: string, redeploy: boolean) => {
|
||||
if (!selectedSnapshot) return;
|
||||
const key = `${nodeId}:${stackName}`;
|
||||
setRestoringStack(key);
|
||||
try {
|
||||
const res = await apiFetch(`/fleet/snapshots/${selectedSnapshot.id}/restore`, {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({ nodeId, stackName, redeploy }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: { message: string; redeployed: boolean } = await res.json();
|
||||
toast.success(data.redeployed ? 'Stack restored and redeployed.' : 'Stack restored successfully.');
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to restore stack.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Record<string, unknown> | null;
|
||||
toast.error(err?.message as string || err?.error as string || 'Something went wrong.');
|
||||
} finally {
|
||||
setRestoringStack(null);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Toggle helpers ---
|
||||
|
||||
const toggleNode = (nodeId: number) => {
|
||||
setExpandedNodes(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeId)) next.delete(nodeId);
|
||||
else next.add(nodeId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleStack = (key: string) => {
|
||||
setExpandedStacks(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const togglePreview = (key: string) => {
|
||||
setPreviewFiles(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// --- Parse skipped nodes safely ---
|
||||
|
||||
function parseSkippedNodes(raw: string): SkippedNode[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed as SkippedNode[];
|
||||
} catch { /* invalid JSON */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
// --- Detail View ---
|
||||
|
||||
if (viewMode === 'detail') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 -ml-2"
|
||||
onClick={() => { setViewMode('list'); setSelectedSnapshot(null); }}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Snapshots
|
||||
</Button>
|
||||
|
||||
{loadingDetail ? (
|
||||
<div className="rounded-xl border bg-card p-6 space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
) : selectedSnapshot ? (
|
||||
<>
|
||||
{/* Header card */}
|
||||
<div className="rounded-xl border bg-card p-4 space-y-3">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{selectedSnapshot.description || 'Untitled Snapshot'}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Created by {selectedSnapshot.created_by} on{' '}
|
||||
{new Date(selectedSnapshot.created_at).toLocaleString()}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{selectedSnapshot.node_count} node{selectedSnapshot.node_count !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{selectedSnapshot.stack_count} stack{selectedSnapshot.stack_count !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skipped nodes warning */}
|
||||
{(() => {
|
||||
const skipped = parseSkippedNodes(selectedSnapshot.skipped_nodes);
|
||||
if (skipped.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-amber-700 dark:text-amber-400">
|
||||
Some nodes were unreachable during snapshot creation:
|
||||
</span>
|
||||
</div>
|
||||
<ul className="ml-6 space-y-1">
|
||||
{skipped.map(node => (
|
||||
<li key={node.nodeId} className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">{node.nodeName}</span>
|
||||
{' — '}
|
||||
{node.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Node / Stack / File tree */}
|
||||
<div className="space-y-2">
|
||||
{selectedSnapshot.nodes.map(node => {
|
||||
const nodeExpanded = expandedNodes.has(node.nodeId);
|
||||
return (
|
||||
<div key={node.nodeId} className="rounded-xl border bg-card overflow-hidden">
|
||||
{/* Node header */}
|
||||
<button
|
||||
onClick={() => toggleNode(node.nodeId)}
|
||||
className="flex items-center gap-2.5 w-full px-4 py-3 text-left hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{nodeExpanded
|
||||
? <ChevronDown className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
<Server className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium flex-1 truncate">{node.nodeName}</span>
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{node.stacks.length} stack{node.stacks.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</button>
|
||||
|
||||
{/* Stacks */}
|
||||
{nodeExpanded && (
|
||||
<div className="border-t px-2 pb-3">
|
||||
{node.stacks.map(stack => {
|
||||
const stackKey = `${node.nodeId}:${stack.stackName}`;
|
||||
const stackExpanded = expandedStacks.has(stackKey);
|
||||
return (
|
||||
<div key={stackKey}>
|
||||
<button
|
||||
onClick={() => toggleStack(stackKey)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-left rounded-md hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{stackExpanded
|
||||
? <ChevronDown className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
<Layers className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-medium flex-1 truncate">
|
||||
{stack.stackName}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 shrink-0">
|
||||
{stack.files.length} file{stack.files.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</button>
|
||||
|
||||
{/* Files */}
|
||||
{stackExpanded && (
|
||||
<div className="ml-6 space-y-1 mt-1">
|
||||
{stack.files.map(file => {
|
||||
const fileKey = `${stackKey}:${file.filename}`;
|
||||
const showPreview = previewFiles.has(fileKey);
|
||||
return (
|
||||
<div key={fileKey}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md hover:bg-muted/50 transition-colors">
|
||||
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs flex-1 truncate">{file.filename}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => togglePreview(fileKey)}
|
||||
>
|
||||
<Eye className="w-3 h-3 mr-1" />
|
||||
{showPreview ? 'Hide' : 'Preview'}
|
||||
</Button>
|
||||
</div>
|
||||
{showPreview && (
|
||||
<pre className="mx-3 mt-1 mb-2 p-3 bg-zinc-950 text-zinc-200 text-xs font-mono rounded-lg overflow-auto max-h-64 whitespace-pre-wrap break-words">
|
||||
{file.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Restore button (admin only) */}
|
||||
{isAdmin && (
|
||||
<RestoreButton
|
||||
nodeId={node.nodeId}
|
||||
nodeName={node.nodeName}
|
||||
stackName={stack.stackName}
|
||||
restoring={restoringStack === `${node.nodeId}:${stack.stackName}`}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- List View ---
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Camera className="w-5 h-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">Fleet Snapshots</h2>
|
||||
</div>
|
||||
{isAdmin && !showCreateForm && (
|
||||
<Button size="sm" className="gap-1.5" onClick={() => setShowCreateForm(true)}>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Snapshot
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showCreateForm && (
|
||||
<div className="rounded-xl border bg-card p-4 space-y-3">
|
||||
<Input
|
||||
placeholder="Snapshot description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={creating}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={handleCreate} disabled={creating} className="gap-1.5">
|
||||
{creating && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setShowCreateForm(false); setDescription(''); }}
|
||||
disabled={creating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading ? (
|
||||
<div className="rounded-xl border bg-card">
|
||||
<div className="p-4 space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-48 flex-1" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : snapshots.length === 0 ? (
|
||||
/* Empty state */
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Camera className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-sm font-medium mb-1">No snapshots yet</h3>
|
||||
<p className="text-xs text-muted-foreground max-w-sm">
|
||||
Create your first fleet snapshot to back up compose files across all nodes.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Snapshots table */
|
||||
<div className="rounded-xl border bg-card overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Scope</TableHead>
|
||||
<TableHead>Warnings</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{snapshots.map(snapshot => {
|
||||
const skipped = parseSkippedNodes(snapshot.skipped_nodes);
|
||||
const skippedNames = skipped.map(s => s.nodeName).join(', ');
|
||||
return (
|
||||
<TableRow key={snapshot.id}>
|
||||
<TableCell className="text-xs whitespace-nowrap">
|
||||
{new Date(snapshot.created_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm max-w-[300px] truncate">
|
||||
{snapshot.description ? (
|
||||
snapshot.description
|
||||
) : (
|
||||
<span className="italic text-muted-foreground">No description</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{snapshot.node_count} node{snapshot.node_count !== 1 ? 's' : ''}
|
||||
{' · '}
|
||||
{snapshot.stack_count} stack{snapshot.stack_count !== 1 ? 's' : ''}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{skipped.length > 0 ? (
|
||||
<span
|
||||
className="flex items-center gap-1 text-amber-500"
|
||||
title={`Skipped: ${skippedNames}`}
|
||||
>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
<span className="text-xs">{skipped.length}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">None</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => handleViewDetail(snapshot)}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-red-500 hover:bg-red-500/10"
|
||||
disabled={deletingId === snapshot.id}
|
||||
>
|
||||
{deletingId === snapshot.id ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this fleet snapshot. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => handleDelete(snapshot.id)}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Restore Button Sub-Component ---
|
||||
|
||||
function RestoreButton({ nodeId, nodeName, stackName, restoring, onRestore }: {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
stackName: string;
|
||||
restoring: boolean;
|
||||
onRestore: (nodeId: number, stackName: string, redeploy: boolean) => Promise<void>;
|
||||
}) {
|
||||
const [redeploy, setRedeploy] = useState(false);
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs gap-1.5 ml-3 mt-1"
|
||||
disabled={restoring}
|
||||
>
|
||||
{restoring ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
)}
|
||||
Restore
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Restore {stackName} on {nodeName}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will overwrite the current compose files with the snapshot version.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex items-center space-x-2 py-2">
|
||||
<Checkbox
|
||||
id={`redeploy-${nodeId}-${stackName}`}
|
||||
checked={redeploy}
|
||||
onCheckedChange={(checked) => setRedeploy(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`redeploy-${nodeId}-${stackName}`}
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
Redeploy stack after restore
|
||||
</Label>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={restoring}
|
||||
onClick={() => onRestore(nodeId, stackName, redeploy)}
|
||||
>
|
||||
{restoring && <Loader2 className="w-3.5 h-3.5 animate-spin mr-1.5" />}
|
||||
Restore
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Server, Cpu, MemoryStick, HardDrive, RefreshCw, ChevronDown, ChevronRight,
|
||||
Layers, Wifi, WifiOff, Search, ArrowUpDown, AlertTriangle, Box, Activity,
|
||||
Play, Square, RotateCcw, ExternalLink,
|
||||
Play, Square, RotateCcw, ExternalLink, Camera,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -11,9 +11,11 @@ import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { ProGate } from './ProGate';
|
||||
import FleetSnapshots from './FleetSnapshots';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// --- Types ---
|
||||
@@ -567,210 +569,229 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-xl border bg-card p-4 space-y-3">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Skeleton className="h-14 rounded-lg" />
|
||||
<Skeleton className="h-14 rounded-lg" />
|
||||
<Skeleton className="h-14 rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && nodes.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<Server className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-1">No nodes configured</h3>
|
||||
<p className="text-sm text-muted-foreground">Add nodes in Settings to see your fleet here.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fleet Content */}
|
||||
{!loading && nodes.length > 0 && (
|
||||
<>
|
||||
{/* Pro: Fleet Health Summary Cards */}
|
||||
{isPro && onlineNodes.length > 0 && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard
|
||||
icon={Box}
|
||||
label="Containers"
|
||||
value={`${totalContainers}`}
|
||||
sub={`${totalContainersAll} total across fleet`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="Fleet CPU"
|
||||
value={`${avgCpu}%`}
|
||||
sub={worstCpuNode ? `Peak: ${worstCpuNode.name} (${worstCpuNode.systemStats?.cpu.usage}%)` : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
icon={MemoryStick}
|
||||
label="Fleet Memory"
|
||||
value={formatBytes(totalMemUsed)}
|
||||
sub={totalMemTotal > 0 ? `of ${formatBytes(totalMemTotal)} (${((totalMemUsed / totalMemTotal) * 100).toFixed(0)}%)` : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
label="Alerts"
|
||||
value={`${criticalCount}`}
|
||||
sub={criticalCount > 0 ? `${criticalCount} node${criticalCount > 1 ? 's' : ''} above 90% CPU or disk` : 'All nodes healthy'}
|
||||
alert={criticalCount > 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pro: Search, Sort & Filter Toolbar */}
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
{isPro && (
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search nodes or stacks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<Select value={prefs.sortBy} onValueChange={(v) => updatePrefs({ sortBy: v as SortField })}>
|
||||
<SelectTrigger className="w-[150px] h-9">
|
||||
<ArrowUpDown className="w-3.5 h-3.5 mr-1.5 shrink-0" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
<SelectItem value="cpu">CPU Usage</SelectItem>
|
||||
<SelectItem value="memory">Memory Usage</SelectItem>
|
||||
<SelectItem value="containers">Containers</SelectItem>
|
||||
<SelectItem value="status">Status</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0"
|
||||
onClick={() => updatePrefs({ sortDir: prefs.sortDir === 'asc' ? 'desc' : 'asc' })}
|
||||
title={prefs.sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
||||
>
|
||||
<ArrowUpDown className={`w-4 h-4 ${prefs.sortDir === 'desc' ? 'rotate-180' : ''} transition-transform`} />
|
||||
</Button>
|
||||
|
||||
{/* Filter pills */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(['all', 'online', 'offline'] as FilterStatus[]).map(status => (
|
||||
<Button
|
||||
key={status}
|
||||
variant={prefs.filterStatus === status ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => updatePrefs({ filterStatus: status })}
|
||||
>
|
||||
{status === 'all' ? 'All' : status === 'online' ? (
|
||||
<><Play className="w-3 h-3 mr-1" />Online</>
|
||||
) : (
|
||||
<><Square className="w-3 h-3 mr-1" />Offline</>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(['all', 'local', 'remote'] as FilterType[]).map(type => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={prefs.filterType === type ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => updatePrefs({ filterType: type })}
|
||||
>
|
||||
{type === 'all' ? 'All Types' : type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={prefs.filterCritical ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => updatePrefs({ filterCritical: !prefs.filterCritical })}
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||
Critical Only
|
||||
</Button>
|
||||
</div>
|
||||
<TabsTrigger value="snapshots">
|
||||
<Camera className="w-4 h-4 mr-1.5" />Snapshots
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{/* Node Grid */}
|
||||
{processedNodes.length > 0 ? (
|
||||
<TabsContent value="overview">
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{processedNodes.map(node => (
|
||||
<NodeCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
onNavigate={onNavigateToNode}
|
||||
/>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-xl border bg-card p-4 space-y-3">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Skeleton className="h-14 rounded-lg" />
|
||||
<Skeleton className="h-14 rounded-lg" />
|
||||
<Skeleton className="h-14 rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Search className="w-10 h-10 text-muted-foreground/50 mb-3" />
|
||||
<h3 className="text-sm font-medium mb-1">No nodes match your filters</h3>
|
||||
<p className="text-xs text-muted-foreground">Try adjusting your search or filter criteria.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false });
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && nodes.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<Server className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-1">No nodes configured</h3>
|
||||
<p className="text-sm text-muted-foreground">Add nodes in Settings to see your fleet here.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pro auto-refresh indicator */}
|
||||
{isPro && (
|
||||
<p className="text-xs text-muted-foreground text-center mt-6">
|
||||
Auto-refreshing every 30 seconds
|
||||
</p>
|
||||
)}
|
||||
{/* Fleet Content */}
|
||||
{!loading && nodes.length > 0 && (
|
||||
<>
|
||||
{/* Pro: Fleet Health Summary Cards */}
|
||||
{isPro && onlineNodes.length > 0 && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard
|
||||
icon={Box}
|
||||
label="Containers"
|
||||
value={`${totalContainers}`}
|
||||
sub={`${totalContainersAll} total across fleet`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="Fleet CPU"
|
||||
value={`${avgCpu}%`}
|
||||
sub={worstCpuNode ? `Peak: ${worstCpuNode.name} (${worstCpuNode.systemStats?.cpu.usage}%)` : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
icon={MemoryStick}
|
||||
label="Fleet Memory"
|
||||
value={formatBytes(totalMemUsed)}
|
||||
sub={totalMemTotal > 0 ? `of ${formatBytes(totalMemTotal)} (${((totalMemUsed / totalMemTotal) * 100).toFixed(0)}%)` : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
label="Alerts"
|
||||
value={`${criticalCount}`}
|
||||
sub={criticalCount > 0 ? `${criticalCount} node${criticalCount > 1 ? 's' : ''} above 90% CPU or disk` : 'All nodes healthy'}
|
||||
alert={criticalCount > 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Free tier: Pro gate for advanced features */}
|
||||
{!isPro && nodes.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<ProGate featureName="Fleet Management">
|
||||
{/* Preview of what Pro unlocks */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
{/* Pro: Search, Sort & Filter Toolbar */}
|
||||
{isPro && (
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search nodes or stacks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<Select value={prefs.sortBy} onValueChange={(v) => updatePrefs({ sortBy: v as SortField })}>
|
||||
<SelectTrigger className="w-[150px] h-9">
|
||||
<ArrowUpDown className="w-3.5 h-3.5 mr-1.5 shrink-0" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
<SelectItem value="cpu">CPU Usage</SelectItem>
|
||||
<SelectItem value="memory">Memory Usage</SelectItem>
|
||||
<SelectItem value="containers">Containers</SelectItem>
|
||||
<SelectItem value="status">Status</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0"
|
||||
onClick={() => updatePrefs({ sortDir: prefs.sortDir === 'asc' ? 'desc' : 'asc' })}
|
||||
title={prefs.sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
||||
>
|
||||
<ArrowUpDown className={`w-4 h-4 ${prefs.sortDir === 'desc' ? 'rotate-180' : ''} transition-transform`} />
|
||||
</Button>
|
||||
|
||||
{/* Filter pills */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(['all', 'online', 'offline'] as FilterStatus[]).map(status => (
|
||||
<Button
|
||||
key={status}
|
||||
variant={prefs.filterStatus === status ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => updatePrefs({ filterStatus: status })}
|
||||
>
|
||||
{status === 'all' ? 'All' : status === 'online' ? (
|
||||
<><Play className="w-3 h-3 mr-1" />Online</>
|
||||
) : (
|
||||
<><Square className="w-3 h-3 mr-1" />Offline</>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(['all', 'local', 'remote'] as FilterType[]).map(type => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={prefs.filterType === type ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => updatePrefs({ filterType: type })}
|
||||
>
|
||||
{type === 'all' ? 'All Types' : type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={prefs.filterCritical ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => updatePrefs({ filterCritical: !prefs.filterCritical })}
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||
Critical Only
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="h-9 rounded-md border bg-card flex-1 max-w-sm" />
|
||||
<div className="h-9 rounded-md border bg-card w-[150px]" />
|
||||
)}
|
||||
|
||||
{/* Node Grid */}
|
||||
{processedNodes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{processedNodes.map(node => (
|
||||
<NodeCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
onNavigate={onNavigateToNode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ProGate>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Search className="w-10 h-10 text-muted-foreground/50 mb-3" />
|
||||
<h3 className="text-sm font-medium mb-1">No nodes match your filters</h3>
|
||||
<p className="text-xs text-muted-foreground">Try adjusting your search or filter criteria.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false });
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pro auto-refresh indicator */}
|
||||
{isPro && (
|
||||
<p className="text-xs text-muted-foreground text-center mt-6">
|
||||
Auto-refreshing every 30 seconds
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Free tier: Pro gate for advanced features */}
|
||||
{!isPro && nodes.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<ProGate featureName="Fleet Management">
|
||||
{/* Preview of what Pro unlocks */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
</div>
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="h-9 rounded-md border bg-card flex-1 max-w-sm" />
|
||||
<div className="h-9 rounded-md border bg-card w-[150px]" />
|
||||
</div>
|
||||
</ProGate>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{isPro && (
|
||||
<TabsContent value="snapshots">
|
||||
<FleetSnapshots />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { apiFetch } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck } from 'lucide-react';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -305,6 +306,7 @@ function TableSkeleton({ cols, rows = 5 }: { cols: number; rows?: number }) {
|
||||
// ── Main Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ResourcesView() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { activeNode } = useNodes();
|
||||
const [usage, setUsage] = useState<UsageData | null>(null);
|
||||
const [images, setImages] = useState<DockerImage[]>([]);
|
||||
@@ -485,7 +487,7 @@ export default function ResourcesView() {
|
||||
</Card>
|
||||
|
||||
{/* Quick Clean */}
|
||||
<Card className="col-span-1 md:col-span-2 border-border shadow-sm flex flex-col animate-in fade-in-0 slide-in-from-bottom-2 duration-300 delay-75">
|
||||
{isAdmin && <Card className="col-span-1 md:col-span-2 border-border shadow-sm flex flex-col animate-in fade-in-0 slide-in-from-bottom-2 duration-300 delay-75">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground tracking-wide uppercase">
|
||||
Quick Clean
|
||||
@@ -531,7 +533,7 @@ export default function ResourcesView() {
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card>}
|
||||
</div>
|
||||
|
||||
{/* Resource Tabs */}
|
||||
@@ -609,9 +611,9 @@ export default function ResourcesView() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 hover:text-red-500 hover:bg-red-500/10 transition-colors" onClick={() => setConfirmDelete({ type: 'images', id: img.Id, name: img.RepoTags?.[0] })}>
|
||||
{isAdmin && <Button variant="ghost" size="icon" className="h-7 w-7 hover:text-red-500 hover:bg-red-500/10 transition-colors" onClick={() => setConfirmDelete({ type: 'images', id: img.Id, name: img.RepoTags?.[0] })}>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</Button>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -656,9 +658,9 @@ export default function ResourcesView() {
|
||||
<TableCell className="hidden md:table-cell text-xs text-muted-foreground truncate max-w-[300px]">{vol.Mountpoint}</TableCell>
|
||||
<TableCell><ManagedBadge status={vol.managedStatus} managedBy={vol.managedBy} /></TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 hover:text-red-500 hover:bg-red-500/10 transition-colors" onClick={() => setConfirmDelete({ type: 'volumes', id: vol.Name, name: vol.Name })}>
|
||||
{isAdmin && <Button variant="ghost" size="icon" className="h-7 w-7 hover:text-red-500 hover:bg-red-500/10 transition-colors" onClick={() => setConfirmDelete({ type: 'volumes', id: vol.Name, name: vol.Name })}>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</Button>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -705,7 +707,7 @@ export default function ResourcesView() {
|
||||
<TableCell><Badge variant="outline" className="text-[10px] h-5">{net.Scope}</Badge></TableCell>
|
||||
<TableCell><ManagedBadge status={net.managedStatus} managedBy={net.managedBy} /></TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
{isAdmin && <Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:text-red-500 hover:bg-red-500/10 transition-colors disabled:opacity-30"
|
||||
@@ -713,7 +715,7 @@ export default function ResourcesView() {
|
||||
onClick={() => setConfirmDelete({ type: 'networks', id: net.Id, name: net.Name })}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</Button>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -18,10 +18,12 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History } from 'lucide-react';
|
||||
import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History, Users, Pencil } from 'lucide-react';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { NodeManager } from './NodeManager';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { ProBadge } from './ProBadge';
|
||||
import { ProGate } from './ProGate';
|
||||
@@ -46,7 +48,7 @@ interface PatchableSettings {
|
||||
log_retention_days?: string;
|
||||
}
|
||||
|
||||
type SectionId = 'account' | 'license' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'about';
|
||||
type SectionId = 'account' | 'license' | 'users' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'about';
|
||||
|
||||
interface WebhookItem {
|
||||
id: number;
|
||||
@@ -372,8 +374,279 @@ function WebhooksSection({ isPro }: { isPro: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface UserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
role: 'admin' | 'viewer';
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
function UsersSection() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<UserItem | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [formUsername, setFormUsername] = useState('');
|
||||
const [formPassword, setFormPassword] = useState('');
|
||||
const [formConfirmPassword, setFormConfirmPassword] = useState('');
|
||||
const [formRole, setFormRole] = useState<'admin' | 'viewer'>('viewer');
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/users', { localOnly: true });
|
||||
if (res.ok) setUsers(await res.json());
|
||||
} catch { /* ignore */ } finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { fetchUsers(); }, []);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormUsername('');
|
||||
setFormPassword('');
|
||||
setFormConfirmPassword('');
|
||||
setFormRole('viewer');
|
||||
setEditingUser(null);
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formUsername || formUsername.length < 3) {
|
||||
toast.error('Username must be at least 3 characters.');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(formUsername)) {
|
||||
toast.error('Username can only contain letters, numbers, underscores, and hyphens.');
|
||||
return;
|
||||
}
|
||||
if (!editingUser && !formPassword) {
|
||||
toast.error('Password is required for new users.');
|
||||
return;
|
||||
}
|
||||
if (formPassword && formPassword.length < 6) {
|
||||
toast.error('Password must be at least 6 characters.');
|
||||
return;
|
||||
}
|
||||
if (formPassword && formPassword !== formConfirmPassword) {
|
||||
toast.error('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingUser) {
|
||||
const body: Record<string, string> = { username: formUsername, role: formRole };
|
||||
if (formPassword) body.password = formPassword;
|
||||
const res = await apiFetch(`/users/${editingUser.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
localOnly: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
toast.error(err?.error || err?.message || 'Failed to update user.');
|
||||
return;
|
||||
}
|
||||
toast.success('User updated.');
|
||||
} else {
|
||||
const res = await apiFetch('/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: formUsername, password: formPassword, role: formRole }),
|
||||
localOnly: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
toast.error(err?.error || err?.message || 'Failed to create user.');
|
||||
return;
|
||||
}
|
||||
toast.success('User created.');
|
||||
}
|
||||
resetForm();
|
||||
fetchUsers();
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'Something went wrong.';
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (userId: number) => {
|
||||
try {
|
||||
const res = await apiFetch(`/users/${userId}`, { method: 'DELETE', localOnly: true });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
toast.error(err?.error || err?.message || 'Failed to delete user.');
|
||||
return;
|
||||
}
|
||||
toast.success('User deleted.');
|
||||
fetchUsers();
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'Something went wrong.';
|
||||
toast.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (u: UserItem) => {
|
||||
setEditingUser(u);
|
||||
setFormUsername(u.username);
|
||||
setFormRole(u.role);
|
||||
setFormPassword('');
|
||||
setFormConfirmPassword('');
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<ProGate featureName="User management">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between pr-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">User Management</h3>
|
||||
<p className="text-sm text-muted-foreground">Create and manage user accounts with role-based access control.</p>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<Button size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />Add User
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Form */}
|
||||
{showForm && (
|
||||
<div className="space-y-4 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<h4 className="text-sm font-medium">{editingUser ? 'Edit User' : 'New User'}</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Username</Label>
|
||||
<Input
|
||||
value={formUsername}
|
||||
onChange={(e) => setFormUsername(e.target.value)}
|
||||
placeholder="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Role</Label>
|
||||
<Select value={formRole} onValueChange={(v) => setFormRole(v as 'admin' | 'viewer')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="viewer">Viewer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{editingUser ? 'New Password (optional)' : 'Password'}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={formPassword}
|
||||
onChange={(e) => setFormPassword(e.target.value)}
|
||||
placeholder={editingUser ? 'Leave blank to keep' : 'min. 6 characters'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Confirm Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={formConfirmPassword}
|
||||
onChange={(e) => setFormConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? <><RefreshCw className="w-4 h-4 mr-1 animate-spin" />Saving...</> : (editingUser ? 'Update User' : 'Create User')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Table */}
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">No users found.</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/30 border-b border-border">
|
||||
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Username</th>
|
||||
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Role</th>
|
||||
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Created</th>
|
||||
<th className="text-right px-4 py-2.5 font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => {
|
||||
const isSelf = u.username === currentUser?.username;
|
||||
return (
|
||||
<tr key={u.id} className="border-b border-border last:border-0 hover:bg-muted/10">
|
||||
<td className="px-4 py-2.5 font-medium">
|
||||
{u.username}
|
||||
{isSelf && <span className="ml-2 text-xs text-muted-foreground">(you)</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge variant={u.role === 'admin' ? 'default' : 'secondary'} className="text-xs capitalize">
|
||||
{u.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground">
|
||||
{new Date(u.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex gap-1 justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={() => startEdit(u)}>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" disabled={isSelf}>
|
||||
<Trash2 className="w-3.5 h-3.5 text-destructive" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete user "{u.username}"?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. The user will lose access immediately.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleDelete(u.id)}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ProGate>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const { isAdmin } = useAuth();
|
||||
const { license, isPro, activate, deactivate } = useLicense();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const [activeSection, setActiveSection] = useState<SectionId>('account');
|
||||
@@ -383,7 +656,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
|
||||
// When switching to a remote node, reset to a node-scoped section if on a global-only one
|
||||
useEffect(() => {
|
||||
if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) {
|
||||
if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) {
|
||||
setActiveSection('system');
|
||||
}
|
||||
}, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -721,6 +994,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
{!isRemote && (
|
||||
<NavButton section="license" icon={<Crown className="w-4 h-4 mr-2" />} label="License" />
|
||||
)}
|
||||
{!isRemote && isAdmin && (
|
||||
<NavButton section="users" icon={<Users className="w-4 h-4 mr-2" />} label="Users" />
|
||||
)}
|
||||
<NavButton
|
||||
section="system"
|
||||
icon={<Activity className="w-4 h-4 mr-2" />}
|
||||
@@ -1092,6 +1368,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
<WebhooksSection isPro={isPro} />
|
||||
)}
|
||||
|
||||
{activeSection === 'users' && (
|
||||
<UsersSection />
|
||||
)}
|
||||
|
||||
{activeSection === 'developer' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between pr-8">
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Trash2, HelpCircle, AlertTriangle, Info, CheckCircle2, Loader2 } from '
|
||||
import { toast } from 'sonner';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
interface StackAlert {
|
||||
id?: number;
|
||||
@@ -39,6 +40,7 @@ interface AgentStatus {
|
||||
}
|
||||
|
||||
export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
|
||||
@@ -266,7 +268,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
Trigger after {alert.duration_mins}m • Cooldown: {alert.cooldown_mins}m
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
{isAdmin && <Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive shrink-0"
|
||||
@@ -274,7 +276,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</Button>}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -284,7 +286,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
<hr />
|
||||
|
||||
{/* Add New Alert Form */}
|
||||
<div className="space-y-4">
|
||||
{isAdmin && <div className="space-y-4">
|
||||
<h4 className="text-sm font-semibold">Add New Rule</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -419,7 +421,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
'Add Rule'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SheetContent>
|
||||
|
||||
@@ -15,7 +15,7 @@ interface UserProfileDropdownProps {
|
||||
}
|
||||
|
||||
export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserProfileDropdownProps) {
|
||||
const { logout } = useAuth();
|
||||
const { logout, user, isAdmin } = useAuth();
|
||||
const { license, isPro } = useLicense();
|
||||
|
||||
return (
|
||||
@@ -33,8 +33,12 @@ export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserPro
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">admin</p>
|
||||
<p className="text-sm font-medium truncate">{user?.username ?? 'admin'}</p>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium uppercase ${isAdmin ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||
{user?.role ?? 'admin'}
|
||||
</span>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
{isPro ? <ProBadge /> : <span>Community</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,17 @@ import { createContext, useContext, useState, useEffect, type ReactNode } from '
|
||||
|
||||
type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'authenticated';
|
||||
|
||||
interface UserInfo {
|
||||
username: string;
|
||||
role: 'admin' | 'viewer';
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
appStatus: AppStatus;
|
||||
isAuthenticated: boolean;
|
||||
needsSetup: boolean;
|
||||
user: UserInfo | null;
|
||||
isAdmin: boolean;
|
||||
login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
|
||||
logout: () => Promise<void>;
|
||||
completeSetup: () => void;
|
||||
@@ -16,6 +23,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [appStatus, setAppStatus] = useState<AppStatus>('loading');
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
@@ -24,9 +32,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
credentials: 'include',
|
||||
});
|
||||
const statusData = await statusResponse.json();
|
||||
|
||||
|
||||
if (statusData.needsSetup) {
|
||||
setAppStatus('needsSetup');
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,13 +43,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const authResponse = await fetch('/api/auth/check', {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
|
||||
if (authResponse.ok) {
|
||||
const data = await authResponse.json();
|
||||
setUser(data.user ?? null);
|
||||
setAppStatus('authenticated');
|
||||
} else {
|
||||
setUser(null);
|
||||
setAppStatus('notAuthenticated');
|
||||
}
|
||||
} catch {
|
||||
setUser(null);
|
||||
setAppStatus('notAuthenticated');
|
||||
}
|
||||
};
|
||||
@@ -67,6 +80,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
if (response.ok && data.success) {
|
||||
setAppStatus('authenticated');
|
||||
// Fetch user info (role, username) so isAdmin is correct immediately
|
||||
await checkAuth();
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: data.error || 'Login failed' };
|
||||
@@ -85,23 +100,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
setUser(null);
|
||||
setAppStatus('notAuthenticated');
|
||||
}
|
||||
};
|
||||
|
||||
const completeSetup = () => {
|
||||
setAppStatus('authenticated');
|
||||
// Fetch user info so isAdmin is correct after setup
|
||||
checkAuth();
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
appStatus,
|
||||
isAuthenticated: appStatus === 'authenticated',
|
||||
<AuthContext.Provider value={{
|
||||
appStatus,
|
||||
isAuthenticated: appStatus === 'authenticated',
|
||||
needsSetup: appStatus === 'needsSetup',
|
||||
login,
|
||||
logout,
|
||||
user,
|
||||
isAdmin: user?.role === 'admin',
|
||||
login,
|
||||
logout,
|
||||
completeSetup,
|
||||
checkAuth
|
||||
checkAuth
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
|
||||
Reference in New Issue
Block a user