dockscope 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +51 -0
- package/dist/docker/client.d.ts +18 -0
- package/dist/docker/client.js +250 -0
- package/dist/docker/compose.d.ts +25 -0
- package/dist/docker/compose.js +111 -0
- package/dist/docker/links.d.ts +10 -0
- package/dist/docker/links.js +100 -0
- package/dist/docker/logs.d.ts +5 -0
- package/dist/docker/logs.js +56 -0
- package/dist/docker/metrics.d.ts +3 -0
- package/dist/docker/metrics.js +42 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +132 -0
- package/dist/server/routes.d.ts +7 -0
- package/dist/server/routes.js +108 -0
- package/dist/types.d.ts +93 -0
- package/dist/types.js +1 -0
- package/dist/web/assets/index-BVIqjCgJ.css +1 -0
- package/dist/web/assets/index-D3YDY15x.js +4834 -0
- package/dist/web/index.html +19 -0
- package/package.json +78 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { parseComposeFile } from './compose.js';
|
|
3
|
+
const COMPOSE_FILES = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
|
|
4
|
+
/** Extract depends_on links from container labels (runtime) */
|
|
5
|
+
export function extractDependsOnFromLabels(containers, nodes, containerProject) {
|
|
6
|
+
const links = [];
|
|
7
|
+
const seen = new Set();
|
|
8
|
+
for (const container of containers) {
|
|
9
|
+
const depsLabel = container.Labels['com.docker.compose.depends_on'];
|
|
10
|
+
if (!depsLabel)
|
|
11
|
+
continue;
|
|
12
|
+
const project = container.Labels['com.docker.compose.project'] || '';
|
|
13
|
+
const sourceId = container.Id.substring(0, 12);
|
|
14
|
+
const sourceNode = nodes.find((n) => n.id === sourceId);
|
|
15
|
+
if (!sourceNode)
|
|
16
|
+
continue;
|
|
17
|
+
for (const entry of depsLabel.split(',')) {
|
|
18
|
+
const depName = entry.split(':')[0]?.trim();
|
|
19
|
+
if (!depName)
|
|
20
|
+
continue;
|
|
21
|
+
const targetNode = nodes.find((n) => {
|
|
22
|
+
const tp = containerProject.get(n.id) || '';
|
|
23
|
+
return (tp === project &&
|
|
24
|
+
(n.name === depName ||
|
|
25
|
+
n.name === `${project}/${depName}` ||
|
|
26
|
+
n.name.endsWith(`/${depName}`)));
|
|
27
|
+
});
|
|
28
|
+
if (targetNode) {
|
|
29
|
+
const key = `${sourceNode.id}->${targetNode.id}`;
|
|
30
|
+
if (!seen.has(key)) {
|
|
31
|
+
seen.add(key);
|
|
32
|
+
links.push({ source: sourceNode.id, target: targetNode.id, type: 'depends_on' });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { links, seen };
|
|
38
|
+
}
|
|
39
|
+
/** Extract depends_on links from compose file */
|
|
40
|
+
export async function extractDependsOnFromFile(composeFile, nodes, containerProject, seen) {
|
|
41
|
+
const links = [];
|
|
42
|
+
const filesToTry = composeFile ? [composeFile] : COMPOSE_FILES;
|
|
43
|
+
for (const file of filesToTry) {
|
|
44
|
+
if (!existsSync(file))
|
|
45
|
+
continue;
|
|
46
|
+
try {
|
|
47
|
+
const compose = await parseComposeFile(file);
|
|
48
|
+
for (const service of compose.services) {
|
|
49
|
+
const node = nodes.find((n) => n.name === service.name || n.name.endsWith(`/${service.name}`));
|
|
50
|
+
if (!node)
|
|
51
|
+
continue;
|
|
52
|
+
const nodeProject = containerProject.get(node.id) || '';
|
|
53
|
+
for (const dep of service.dependsOn) {
|
|
54
|
+
const target = nodes.find((n) => {
|
|
55
|
+
const tp = containerProject.get(n.id) || '';
|
|
56
|
+
return (tp === nodeProject &&
|
|
57
|
+
(n.name === dep || n.name === `${nodeProject}/${dep}` || n.name.endsWith(`/${dep}`)));
|
|
58
|
+
});
|
|
59
|
+
if (target) {
|
|
60
|
+
const key = `${node.id}->${target.id}`;
|
|
61
|
+
if (!seen.has(key)) {
|
|
62
|
+
seen.add(key);
|
|
63
|
+
links.push({ source: node.id, target: target.id, type: 'depends_on' });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return links;
|
|
75
|
+
}
|
|
76
|
+
/** Extract network-based links (containers sharing non-default networks) */
|
|
77
|
+
export function extractNetworkLinks(networkMap) {
|
|
78
|
+
const links = [];
|
|
79
|
+
const defaultNetworks = new Set(['bridge', 'host', 'none']);
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
for (const [network, containerIds] of networkMap) {
|
|
82
|
+
if (defaultNetworks.has(network))
|
|
83
|
+
continue;
|
|
84
|
+
for (let i = 0; i < containerIds.length; i++) {
|
|
85
|
+
for (let j = i + 1; j < containerIds.length; j++) {
|
|
86
|
+
const key = `${containerIds[i]}<>${containerIds[j]}:${network}`;
|
|
87
|
+
if (seen.has(key))
|
|
88
|
+
continue;
|
|
89
|
+
seen.add(key);
|
|
90
|
+
links.push({
|
|
91
|
+
source: containerIds[i],
|
|
92
|
+
target: containerIds[j],
|
|
93
|
+
type: 'network',
|
|
94
|
+
label: network,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return links;
|
|
100
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import Dockerode from 'dockerode';
|
|
2
|
+
/** Demux Docker log buffer (handles both multiplexed and TTY formats) */
|
|
3
|
+
export declare function demuxLogBuffer(buffer: Buffer): string;
|
|
4
|
+
export declare function getContainerLogs(docker: Dockerode, containerId: string, tail?: number): Promise<string>;
|
|
5
|
+
export declare function streamContainerLogs(docker: Dockerode, containerId: string, onData: (text: string) => void, onError?: (err: Error) => void): () => void;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const DEMUX_HEADER_SIZE = 8;
|
|
2
|
+
/** Demux Docker log buffer (handles both multiplexed and TTY formats) */
|
|
3
|
+
export function demuxLogBuffer(buffer) {
|
|
4
|
+
if (buffer.length >= DEMUX_HEADER_SIZE && buffer[0] !== undefined && buffer[0] <= 2) {
|
|
5
|
+
const lines = [];
|
|
6
|
+
let offset = 0;
|
|
7
|
+
while (offset + DEMUX_HEADER_SIZE <= buffer.length) {
|
|
8
|
+
const size = buffer.readUInt32BE(offset + 4);
|
|
9
|
+
offset += DEMUX_HEADER_SIZE;
|
|
10
|
+
if (offset + size > buffer.length)
|
|
11
|
+
break;
|
|
12
|
+
lines.push(buffer.subarray(offset, offset + size).toString('utf-8'));
|
|
13
|
+
offset += size;
|
|
14
|
+
}
|
|
15
|
+
return lines.join('');
|
|
16
|
+
}
|
|
17
|
+
return buffer.toString('utf-8');
|
|
18
|
+
}
|
|
19
|
+
export async function getContainerLogs(docker, containerId, tail = 200) {
|
|
20
|
+
const container = docker.getContainer(containerId);
|
|
21
|
+
const logBuffer = await container.logs({
|
|
22
|
+
stdout: true,
|
|
23
|
+
stderr: true,
|
|
24
|
+
tail,
|
|
25
|
+
follow: false,
|
|
26
|
+
timestamps: true,
|
|
27
|
+
});
|
|
28
|
+
const buf = Buffer.isBuffer(logBuffer) ? logBuffer : Buffer.from(logBuffer);
|
|
29
|
+
return demuxLogBuffer(buf);
|
|
30
|
+
}
|
|
31
|
+
export function streamContainerLogs(docker, containerId, onData, onError) {
|
|
32
|
+
let destroyed = false;
|
|
33
|
+
let logStream = null;
|
|
34
|
+
const container = docker.getContainer(containerId);
|
|
35
|
+
container.logs({ stdout: true, stderr: true, tail: 100, follow: true, timestamps: true }, (err, stream) => {
|
|
36
|
+
if (err || !stream) {
|
|
37
|
+
onError?.(err || new Error('Failed to get log stream'));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (destroyed) {
|
|
41
|
+
stream.destroy?.();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
logStream = stream;
|
|
45
|
+
stream.on('data', (chunk) => {
|
|
46
|
+
const text = demuxLogBuffer(chunk);
|
|
47
|
+
if (text)
|
|
48
|
+
onData(text);
|
|
49
|
+
});
|
|
50
|
+
stream.on('error', (e) => onError?.(e));
|
|
51
|
+
});
|
|
52
|
+
return () => {
|
|
53
|
+
destroyed = true;
|
|
54
|
+
logStream?.destroy?.();
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const prevNetStats = new Map();
|
|
2
|
+
export async function getContainerStats(docker, containerId) {
|
|
3
|
+
const container = docker.getContainer(containerId);
|
|
4
|
+
const stats = await container.stats({ stream: false });
|
|
5
|
+
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
|
6
|
+
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
|
|
7
|
+
const numCpus = stats.cpu_stats.online_cpus || 1;
|
|
8
|
+
const cpu = systemDelta > 0 ? (cpuDelta / systemDelta) * numCpus * 100 : 0;
|
|
9
|
+
const memUsage = stats.memory_stats.usage - (stats.memory_stats.stats?.cache || 0);
|
|
10
|
+
const memLimit = stats.memory_stats.limit || 1;
|
|
11
|
+
let networkRx = 0;
|
|
12
|
+
let networkTx = 0;
|
|
13
|
+
if (stats.networks) {
|
|
14
|
+
for (const iface of Object.values(stats.networks)) {
|
|
15
|
+
networkRx += iface.rx_bytes || 0;
|
|
16
|
+
networkTx += iface.tx_bytes || 0;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const shortId = containerId.substring(0, 12);
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
const prev = prevNetStats.get(shortId);
|
|
22
|
+
let networkRxRate = 0;
|
|
23
|
+
let networkTxRate = 0;
|
|
24
|
+
if (prev) {
|
|
25
|
+
const elapsed = (now - prev.time) / 1000;
|
|
26
|
+
if (elapsed > 0) {
|
|
27
|
+
networkRxRate = Math.max(0, (networkRx - prev.rx) / elapsed);
|
|
28
|
+
networkTxRate = Math.max(0, (networkTx - prev.tx) / elapsed);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
prevNetStats.set(shortId, { rx: networkRx, tx: networkTx, time: now });
|
|
32
|
+
return {
|
|
33
|
+
id: shortId,
|
|
34
|
+
cpu: Math.round(cpu * 100) / 100,
|
|
35
|
+
memory: memUsage,
|
|
36
|
+
memoryLimit: memLimit,
|
|
37
|
+
networkRx,
|
|
38
|
+
networkTx,
|
|
39
|
+
networkRxRate: Math.round(networkRxRate),
|
|
40
|
+
networkTxRate: Math.round(networkTxRate),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
|
+
import cors from 'cors';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { buildGraph, checkConnection, getContainerStats, streamContainerLogs, watchEvents, } from '../docker/client.js';
|
|
8
|
+
import { setupRoutes } from './routes.js';
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
export async function startServer(opts) {
|
|
11
|
+
const app = express();
|
|
12
|
+
app.use(cors());
|
|
13
|
+
app.use(express.json());
|
|
14
|
+
const server = createServer(app);
|
|
15
|
+
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
16
|
+
const connected = await checkConnection();
|
|
17
|
+
if (!connected) {
|
|
18
|
+
console.error('Cannot connect to Docker daemon. Is Docker running?');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
// Metric history storage (shared with routes)
|
|
22
|
+
const metricHistory = new Map();
|
|
23
|
+
// REST routes
|
|
24
|
+
setupRoutes(app, opts, metricHistory);
|
|
25
|
+
// Serve built frontend in production
|
|
26
|
+
const webDir = path.resolve(__dirname, '../web');
|
|
27
|
+
app.use(express.static(webDir));
|
|
28
|
+
app.get('*', (_req, res) => {
|
|
29
|
+
res.sendFile(path.join(webDir, 'index.html'));
|
|
30
|
+
});
|
|
31
|
+
// --- WebSocket ---
|
|
32
|
+
const broadcast = (msg) => {
|
|
33
|
+
const data = JSON.stringify(msg);
|
|
34
|
+
wss.clients.forEach((client) => {
|
|
35
|
+
if (client.readyState === WebSocket.OPEN)
|
|
36
|
+
client.send(data);
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
let cachedGraph = { nodes: [], links: [] };
|
|
40
|
+
const refreshGraph = async () => {
|
|
41
|
+
try {
|
|
42
|
+
cachedGraph = await buildGraph(opts.composeFile);
|
|
43
|
+
broadcast({ type: 'graph', data: cachedGraph });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
/* Docker may be temporarily unavailable */
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const refreshStats = async () => {
|
|
50
|
+
for (const node of cachedGraph.nodes) {
|
|
51
|
+
if (node.status !== 'running')
|
|
52
|
+
continue;
|
|
53
|
+
try {
|
|
54
|
+
const stats = await getContainerStats(node.containerId);
|
|
55
|
+
broadcast({ type: 'stats', data: stats });
|
|
56
|
+
const shortId = node.containerId.substring(0, 12);
|
|
57
|
+
if (!metricHistory.has(shortId))
|
|
58
|
+
metricHistory.set(shortId, []);
|
|
59
|
+
const history = metricHistory.get(shortId);
|
|
60
|
+
history.push({ cpu: stats.cpu, memory: stats.memory, time: Date.now() });
|
|
61
|
+
if (history.length > 100)
|
|
62
|
+
history.splice(0, history.length - 100);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* Container may have stopped */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const stopWatching = watchEvents((event) => {
|
|
70
|
+
broadcast({ type: 'event', data: event });
|
|
71
|
+
if (['start', 'stop', 'die', 'destroy', 'create', 'pause', 'unpause'].includes(event.action)) {
|
|
72
|
+
refreshGraph();
|
|
73
|
+
}
|
|
74
|
+
}, (err) => console.error('Docker event stream error:', err.message));
|
|
75
|
+
await refreshGraph();
|
|
76
|
+
const statsInterval = setInterval(refreshStats, 3000);
|
|
77
|
+
const graphInterval = setInterval(refreshGraph, 10000);
|
|
78
|
+
// Per-client log streams
|
|
79
|
+
const clientLogStreams = new Map();
|
|
80
|
+
wss.on('connection', (ws) => {
|
|
81
|
+
ws.send(JSON.stringify({ type: 'graph', data: cachedGraph }));
|
|
82
|
+
ws.on('message', (raw) => {
|
|
83
|
+
try {
|
|
84
|
+
const msg = JSON.parse(raw.toString());
|
|
85
|
+
if (msg.type === 'subscribe_logs' && msg.data?.containerId) {
|
|
86
|
+
clientLogStreams.get(ws)?.();
|
|
87
|
+
const stop = streamContainerLogs(msg.data.containerId, (text) => {
|
|
88
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
89
|
+
ws.send(JSON.stringify({
|
|
90
|
+
type: 'log_chunk',
|
|
91
|
+
data: { containerId: msg.data.containerId, text },
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
}, (err) => {
|
|
95
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
96
|
+
ws.send(JSON.stringify({
|
|
97
|
+
type: 'error',
|
|
98
|
+
data: { message: `Log stream error: ${err.message}` },
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
clientLogStreams.set(ws, stop);
|
|
103
|
+
}
|
|
104
|
+
if (msg.type === 'unsubscribe_logs') {
|
|
105
|
+
clientLogStreams.get(ws)?.();
|
|
106
|
+
clientLogStreams.delete(ws);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* ignore */
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
ws.on('close', () => {
|
|
114
|
+
clientLogStreams.get(ws)?.();
|
|
115
|
+
clientLogStreams.delete(ws);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
const shutdown = () => {
|
|
119
|
+
console.log('\nShutting down DockScope...');
|
|
120
|
+
clearInterval(statsInterval);
|
|
121
|
+
clearInterval(graphInterval);
|
|
122
|
+
stopWatching();
|
|
123
|
+
wss.close();
|
|
124
|
+
server.close();
|
|
125
|
+
process.exit(0);
|
|
126
|
+
};
|
|
127
|
+
process.on('SIGINT', shutdown);
|
|
128
|
+
process.on('SIGTERM', shutdown);
|
|
129
|
+
return new Promise((resolve) => {
|
|
130
|
+
server.listen(opts.port, () => resolve());
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { buildGraph, checkConnection, composeAction, containerAction, getContainerLogs, getContainerStats, getSystemInfo, inspectContainer, listComposeProjects, } from '../docker/client.js';
|
|
2
|
+
export function setupRoutes(app, opts, metricHistory) {
|
|
3
|
+
app.get('/api/graph', async (_req, res) => {
|
|
4
|
+
try {
|
|
5
|
+
const graph = await buildGraph(opts.composeFile);
|
|
6
|
+
res.json(graph);
|
|
7
|
+
}
|
|
8
|
+
catch (err) {
|
|
9
|
+
res.status(500).json({ error: err.message });
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
app.get('/api/containers/:id/logs', async (req, res) => {
|
|
13
|
+
try {
|
|
14
|
+
const tail = parseInt(req.query.tail) || 200;
|
|
15
|
+
const logs = await getContainerLogs(req.params.id, tail);
|
|
16
|
+
res.json({ logs });
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
res.status(500).json({ error: err.message });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
app.get('/api/containers/:id/stats', async (req, res) => {
|
|
23
|
+
try {
|
|
24
|
+
const stats = await getContainerStats(req.params.id);
|
|
25
|
+
res.json(stats);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
res.status(500).json({ error: err.message });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
app.get('/api/health', async (_req, res) => {
|
|
32
|
+
const dockerOk = await checkConnection();
|
|
33
|
+
res.json({ status: dockerOk ? 'ok' : 'docker_unavailable' });
|
|
34
|
+
});
|
|
35
|
+
app.post('/api/containers/:id/restart', async (req, res) => {
|
|
36
|
+
try {
|
|
37
|
+
await containerAction(req.params.id, 'restart');
|
|
38
|
+
res.json({ ok: true });
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
res.status(500).json({ error: err.message });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
app.post('/api/containers/:id/stop', async (req, res) => {
|
|
45
|
+
try {
|
|
46
|
+
await containerAction(req.params.id, 'stop');
|
|
47
|
+
res.json({ ok: true });
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
res.status(500).json({ error: err.message });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
app.post('/api/containers/:id/start', async (req, res) => {
|
|
54
|
+
try {
|
|
55
|
+
await containerAction(req.params.id, 'start');
|
|
56
|
+
res.json({ ok: true });
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
res.status(500).json({ error: err.message });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
app.get('/api/containers/:id/inspect', async (req, res) => {
|
|
63
|
+
try {
|
|
64
|
+
const info = await inspectContainer(req.params.id);
|
|
65
|
+
res.json(info);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
res.status(500).json({ error: err.message });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
app.get('/api/containers/:id/history', (req, res) => {
|
|
72
|
+
const history = metricHistory.get(req.params.id.substring(0, 12)) || [];
|
|
73
|
+
res.json(history);
|
|
74
|
+
});
|
|
75
|
+
app.get('/api/system', async (_req, res) => {
|
|
76
|
+
try {
|
|
77
|
+
const info = await getSystemInfo();
|
|
78
|
+
res.json(info);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
res.status(500).json({ error: err.message });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
// Compose project management
|
|
85
|
+
app.get('/api/projects', async (_req, res) => {
|
|
86
|
+
try {
|
|
87
|
+
const projects = await listComposeProjects();
|
|
88
|
+
res.json(projects);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
res.status(500).json({ error: err.message });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
app.post('/api/projects/:name/:action', async (req, res) => {
|
|
95
|
+
const { name, action } = req.params;
|
|
96
|
+
if (!['up', 'down', 'stop', 'start', 'restart'].includes(action)) {
|
|
97
|
+
res.status(400).json({ error: `Invalid action: ${action}` });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const result = await composeAction(name, action);
|
|
102
|
+
res.json({ ok: true, message: result });
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
res.status(500).json({ error: err.message });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface ServiceNode {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
project: string;
|
|
5
|
+
containerId: string;
|
|
6
|
+
image: string;
|
|
7
|
+
status: 'running' | 'exited' | 'paused' | 'restarting' | 'dead' | 'created' | 'removing';
|
|
8
|
+
health: 'healthy' | 'unhealthy' | 'starting' | 'none';
|
|
9
|
+
ports: string[];
|
|
10
|
+
networks: string[];
|
|
11
|
+
cpu: number;
|
|
12
|
+
memory: number;
|
|
13
|
+
memoryLimit: number;
|
|
14
|
+
networkRx: number;
|
|
15
|
+
networkTx: number;
|
|
16
|
+
networkRxRate: number;
|
|
17
|
+
networkTxRate: number;
|
|
18
|
+
}
|
|
19
|
+
export interface ServiceLink {
|
|
20
|
+
source: string;
|
|
21
|
+
target: string;
|
|
22
|
+
type: 'depends_on' | 'network';
|
|
23
|
+
label?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface GraphData {
|
|
26
|
+
nodes: ServiceNode[];
|
|
27
|
+
links: ServiceLink[];
|
|
28
|
+
}
|
|
29
|
+
export interface DockerEvent {
|
|
30
|
+
id: string;
|
|
31
|
+
type: string;
|
|
32
|
+
action: string;
|
|
33
|
+
actor: string;
|
|
34
|
+
time: number;
|
|
35
|
+
message: string;
|
|
36
|
+
}
|
|
37
|
+
export interface ContainerStats {
|
|
38
|
+
id: string;
|
|
39
|
+
cpu: number;
|
|
40
|
+
memory: number;
|
|
41
|
+
memoryLimit: number;
|
|
42
|
+
networkRx: number;
|
|
43
|
+
networkTx: number;
|
|
44
|
+
networkRxRate: number;
|
|
45
|
+
networkTxRate: number;
|
|
46
|
+
}
|
|
47
|
+
export interface ContainerInspect {
|
|
48
|
+
id: string;
|
|
49
|
+
env: string[];
|
|
50
|
+
labels: Record<string, string>;
|
|
51
|
+
mounts: {
|
|
52
|
+
type: string;
|
|
53
|
+
source: string;
|
|
54
|
+
destination: string;
|
|
55
|
+
mode: string;
|
|
56
|
+
}[];
|
|
57
|
+
restartPolicy: string;
|
|
58
|
+
entrypoint: string[] | null;
|
|
59
|
+
cmd: string[] | null;
|
|
60
|
+
workingDir: string;
|
|
61
|
+
created: string;
|
|
62
|
+
}
|
|
63
|
+
export interface LogChunk {
|
|
64
|
+
containerId: string;
|
|
65
|
+
text: string;
|
|
66
|
+
}
|
|
67
|
+
export interface WSMessage {
|
|
68
|
+
type: 'graph' | 'stats' | 'event' | 'error' | 'log_chunk' | 'subscribe_logs' | 'unsubscribe_logs';
|
|
69
|
+
data: GraphData | ContainerStats | DockerEvent | LogChunk | {
|
|
70
|
+
message: string;
|
|
71
|
+
} | {
|
|
72
|
+
containerId: string;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export interface SystemInfo {
|
|
76
|
+
dockerVersion: string;
|
|
77
|
+
os: string;
|
|
78
|
+
totalMemory: number;
|
|
79
|
+
cpus: number;
|
|
80
|
+
containersRunning: number;
|
|
81
|
+
containersStopped: number;
|
|
82
|
+
images: number;
|
|
83
|
+
}
|
|
84
|
+
export interface MetricPoint {
|
|
85
|
+
cpu: number;
|
|
86
|
+
memory: number;
|
|
87
|
+
time: number;
|
|
88
|
+
}
|
|
89
|
+
export interface ServerOptions {
|
|
90
|
+
port: number;
|
|
91
|
+
composeFile: string;
|
|
92
|
+
open: boolean;
|
|
93
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.graph-wrapper.svelte-nmiszu{position:relative;width:100%;height:100%}.graph-controls.svelte-nmiszu{position:absolute;left:16px;display:flex;flex-direction:column;gap:4px;z-index:10}.graph-ctrl-btn.svelte-nmiszu{width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:#080a18b3;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);border:1px solid rgba(0,228,255,.1);border-radius:6px;color:#7a8599cc;cursor:pointer;transition:all .2s}.graph-ctrl-btn.svelte-nmiszu:hover{color:#00e4ff;border-color:#00e4ff40;background:#00e4ff14}.ctrl-divider.svelte-nmiszu{width:18px;height:1px;background:#00e4ff14;margin:4px auto;border-radius:1px}.help-glyph.svelte-nmiszu{font-family:Fira Code,monospace;font-size:13px;font-weight:600;line-height:1}.sparkline-container.svelte-1apk6lc{display:flex;flex-direction:column;gap:2px}.sparkline-label.svelte-1apk6lc{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:2px;color:#3e4a5c}.sparkline-svg.svelte-1apk6lc{display:block}.status-bar.svelte-1rygals{width:100%;height:100%;background:var(--bg-surface);-webkit-backdrop-filter:blur(24px) saturate(1.2);backdrop-filter:blur(24px) saturate(1.2);border-top:1px solid var(--border-glow);display:flex;flex-direction:column;overflow:hidden}.status-bar.svelte-1rygals:before{content:"";position:absolute;top:-1px;left:0;right:0;height:1px;background:linear-gradient(90deg,var(--accent-cyan-dim),transparent 40%);z-index:1}.status-bar-header.svelte-1rygals{display:flex;align-items:center;justify-content:space-between;padding:8px 16px;border-bottom:1px solid var(--border-subtle);flex-shrink:0}.status-summary.svelte-1rygals{display:flex;gap:16px;align-items:center}.status-chip.svelte-1rygals{display:flex;align-items:center;gap:6px;font-size:11px;font-weight:500;color:var(--text-secondary);letter-spacing:.3px}.sys-info-divider.svelte-1rygals{width:1px;height:12px;background:var(--border-glow)}.sys-info.svelte-1rygals{font-family:var(--font-mono);font-size:10px;color:var(--text-dim);letter-spacing:.3px}.event-label.svelte-1rygals{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:2px;color:var(--text-dim)}.event-list.svelte-1rygals{flex:1;overflow-y:auto;padding:2px 0}.event-empty.svelte-1rygals{padding:12px 16px;font-size:11px;color:var(--text-dim);font-style:italic}.event-row.svelte-1rygals{display:flex;gap:12px;padding:4px 16px;font-size:11px;align-items:center;transition:background .15s}.event-row.svelte-1rygals:hover{background:#00e4ff08}.event-time.svelte-1rygals{font-family:var(--font-mono);font-size:10px;color:var(--text-dim);min-width:68px}.event-action.svelte-1rygals{min-width:65px;font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:.5px}.event-action.start.svelte-1rygals{color:var(--accent-green)}.event-action.stop.svelte-1rygals{color:var(--accent-amber)}.event-action.die.svelte-1rygals,.event-action.destroy.svelte-1rygals,.event-action.kill.svelte-1rygals{color:var(--accent-red)}.event-action.create.svelte-1rygals{color:var(--accent-cyan)}.event-action.pause.svelte-1rygals{color:var(--accent-purple)}.event-action.unpause.svelte-1rygals{color:var(--accent-green)}.event-actor.svelte-1rygals{color:var(--text-secondary);font-weight:400}.event-type.svelte-1rygals{color:var(--text-dim);margin-left:auto;font-size:10px}.event-header-right.svelte-1rygals{display:flex;align-items:center;gap:8px}.healthcheck-toggle.svelte-1rygals{font-family:var(--font-mono);font-size:9px;font-weight:600;padding:2px 6px;border-radius:8px;border:1px solid var(--border-subtle);background:transparent;color:var(--text-dim);cursor:pointer;transition:all .2s;letter-spacing:.5px;text-decoration:line-through;opacity:.5}.healthcheck-toggle.active.svelte-1rygals{text-decoration:line-through;opacity:.5}.healthcheck-toggle.svelte-1rygals:not(.active){text-decoration:none;opacity:1;color:var(--accent-cyan);border-color:var(--border-glow)}.healthcheck-toggle.svelte-1rygals:hover{border-color:var(--border-glow);opacity:.8}.kbd-overlay.svelte-c0hob1{position:fixed;top:0;right:0;bottom:0;left:0;z-index:100;display:flex;align-items:center;justify-content:center;background:#04040e99;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);animation:svelte-c0hob1-fadeIn .15s ease-out}.kbd-panel.svelte-c0hob1{background:#080a18f2;border:1px solid rgba(0,228,255,.12);border-radius:12px;padding:24px 32px;min-width:280px;-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px)}.kbd-title.svelte-c0hob1{font-family:Chakra Petch,sans-serif;font-size:13px;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:#00e4ffcc;margin-bottom:16px;padding-bottom:10px;border-bottom:1px solid rgba(255,255,255,.04)}.kbd-row.svelte-c0hob1{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:6px 0;font-size:13px;color:#7a8599}kbd.svelte-c0hob1{font-family:Fira Code,monospace;font-size:11px;padding:3px 8px;background:#0000004d;border:1px solid rgba(255,255,255,.08);border-radius:4px;color:#e2e8f0;min-width:28px;text-align:center}@keyframes svelte-c0hob1-fadeIn{0%{opacity:0}to{opacity:1}}.pm-overlay.svelte-ucg24v{position:fixed;top:0;right:0;bottom:0;left:0;z-index:100;display:flex;align-items:center;justify-content:center;background:#04040e99;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);animation:svelte-ucg24v-fadeIn .15s ease-out}.pm-panel.svelte-ucg24v{background:#080a18f2;border:1px solid rgba(0,228,255,.12);border-radius:12px;min-width:380px;max-width:500px;max-height:70vh;display:flex;flex-direction:column;overflow:hidden;-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px)}.pm-header.svelte-ucg24v{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid rgba(255,255,255,.04)}.pm-title.svelte-ucg24v{font-family:Chakra Petch,sans-serif;font-size:13px;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:#00e4ffcc}.pm-close.svelte-ucg24v{background:none;border:1px solid rgba(255,255,255,.04);color:#3e4a5c;width:24px;height:24px;border-radius:4px;cursor:pointer;font-size:14px;display:flex;align-items:center;justify-content:center;transition:all .2s}.pm-close.svelte-ucg24v:hover{color:#e2e8f0;border-color:#00e4ff1a}.pm-loading.svelte-ucg24v,.pm-empty.svelte-ucg24v{padding:24px;text-align:center;color:#3e4a5c;font-size:12px;font-style:italic}.pm-list.svelte-ucg24v{overflow-y:auto;padding:8px 0}.pm-project.svelte-ucg24v{display:flex;align-items:center;justify-content:space-between;padding:10px 18px;gap:12px;transition:background .15s}.pm-project.svelte-ucg24v:hover{background:#00e4ff05}.pm-project-info.svelte-ucg24v{display:flex;flex-direction:column;gap:3px;min-width:0}.pm-project-name.svelte-ucg24v{font-family:Fira Code,monospace;font-size:13px;font-weight:500;color:#e2e8f0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.pm-project-counts.svelte-ucg24v{display:flex;gap:8px}.pm-count.svelte-ucg24v{font-size:10px;font-weight:500;letter-spacing:.3px}.pm-count.running.svelte-ucg24v{color:#00ff6a}.pm-count.stopped.svelte-ucg24v{color:#3e4a5c}.pm-actions.svelte-ucg24v{display:flex;gap:5px;flex-shrink:0}.pm-btn.svelte-ucg24v{font-family:Chakra Petch,sans-serif;font-size:10px;font-weight:600;padding:4px 10px;border-radius:6px;border:1px solid;cursor:pointer;transition:all .2s;letter-spacing:.3px}.pm-btn.svelte-ucg24v:disabled{opacity:.3;cursor:not-allowed}.pm-btn.up.svelte-ucg24v{color:#00ff6a;border-color:#00ff6a26;background:#00ff6a0f}.pm-btn.up.svelte-ucg24v:hover:not(:disabled){background:#00ff6a24}.pm-btn.restart.svelte-ucg24v{color:#00e4ff;border-color:#00e4ff26;background:#00e4ff0f}.pm-btn.restart.svelte-ucg24v:hover:not(:disabled){background:#00e4ff24}.pm-btn.stop.svelte-ucg24v{color:#ff8a2b;border-color:#ff8a2b26;background:#ff8a2b0f}.pm-btn.stop.svelte-ucg24v:hover:not(:disabled){background:#ff8a2b24}.pm-btn.down.svelte-ucg24v{color:#ff2b4e;border-color:#ff2b4e26;background:#ff2b4e0f}.pm-btn.down.svelte-ucg24v:hover:not(:disabled){background:#ff2b4e24}@keyframes svelte-ucg24v-fadeIn{0%{opacity:0}to{opacity:1}}.toast-container.svelte-1eookdc{position:fixed;bottom:200px;left:50%;transform:translate(-50%);z-index:50;display:flex;flex-direction:column;gap:8px;align-items:center}.toast.svelte-1eookdc{display:flex;align-items:center;gap:8px;padding:8px 20px;background:#080a18eb;-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,.06);border-radius:20px;font-family:Chakra Petch,sans-serif;font-size:12px;font-weight:500;color:#e2e8f0;animation:svelte-1eookdc-toastIn .3s ease-out;white-space:nowrap}.toast-dot.svelte-1eookdc{width:6px;height:6px;border-radius:50%;flex-shrink:0}.toast-success.svelte-1eookdc .toast-dot:where(.svelte-1eookdc){background:#00ff6a;box-shadow:0 0 6px #00ff6a66}.toast-error.svelte-1eookdc .toast-dot:where(.svelte-1eookdc){background:#ff2b4e;box-shadow:0 0 6px #ff2b4e66}.toast-info.svelte-1eookdc .toast-dot:where(.svelte-1eookdc){background:#00e4ff;box-shadow:0 0 6px #00e4ff66}.toast-success.svelte-1eookdc{border-color:#00ff6a26}.toast-error.svelte-1eookdc{border-color:#ff2b4e26}.toast-info.svelte-1eookdc{border-color:#00e4ff26}@keyframes svelte-1eookdc-toastIn{0%{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}:root{--bg-void: #04040e;--bg-surface: rgba(8, 10, 24, .82);--bg-surface-solid: #080a18;--bg-panel: rgba(10, 12, 28, .6);--bg-inset: rgba(0, 0, 0, .35);--accent-cyan: #00e4ff;--accent-cyan-dim: rgba(0, 228, 255, .1);--accent-cyan-glow: rgba(0, 228, 255, .25);--accent-amber: #ff8a2b;--accent-amber-dim: rgba(255, 138, 43, .12);--accent-green: #00ff6a;--accent-green-dim: rgba(0, 255, 106, .1);--accent-red: #ff2b4e;--accent-red-dim: rgba(255, 43, 78, .12);--accent-purple: #a855f7;--text-primary: #e2e8f0;--text-secondary: #7a8599;--text-dim: #3e4a5c;--border-glow: rgba(0, 228, 255, .1);--border-subtle: rgba(255, 255, 255, .04);--font-display: "Orbitron", sans-serif;--font-ui: "Chakra Petch", sans-serif;--font-mono: "Fira Code", monospace;--radius-sm: 4px;--radius-md: 8px;--radius-lg: 12px;--space-xs: 2px;--space-sm: 4px;--space-md: 8px;--space-lg: 12px;--space-xl: 16px;--space-2xl: 20px;--space-3xl: 24px;--space-4xl: 32px;--text-xs: 9px;--text-sm: 10px;--text-base: 11px;--text-md: 12px;--text-lg: 13px;--text-xl: 14px;--text-2xl: 15px}*,*:before,*:after{margin:0;padding:0;box-sizing:border-box}body{font-family:var(--font-ui);background:var(--bg-void);color:var(--text-primary);overflow:hidden;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}#root{width:100vw;height:100vh}.app{position:relative;width:100%;height:100%}.graph-layer{position:absolute;top:0;right:0;bottom:0;left:0;z-index:0}.graph-vignette{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;background:radial-gradient(ellipse at 50% 50%,transparent 35%,rgba(4,4,14,.65) 100%);pointer-events:none}.graph-scanlines{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;background:repeating-linear-gradient(0deg,transparent,transparent 3px,rgba(0,228,255,.008) 3px,rgba(0,228,255,.008) 4px);pointer-events:none}.hud-bar{position:absolute;top:14px;left:14px;z-index:20;display:flex;align-items:center;gap:3px;background:#060812b3;-webkit-backdrop-filter:blur(20px) saturate(1.3);backdrop-filter:blur(20px) saturate(1.3);border:1px solid rgba(0,228,255,.06);border-radius:10px;padding:4px;animation:fadeSlideDown .5s ease-out .15s both;box-shadow:0 0 0 1px #0000004d,0 4px 20px #0006,inset 0 1px #ffffff05}.hud-group{display:flex;align-items:center;gap:8px;padding:4px 10px;border-radius:7px;transition:background .2s}.hud-group:hover{background:#ffffff05}.brand-group{gap:6px;padding-left:8px}.hud-logo{font-family:var(--font-display);font-size:12px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:var(--accent-cyan);text-shadow:0 0 14px var(--accent-cyan-glow)}.hud-version{font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:.5px;opacity:.6}.hud-connection{display:flex;align-items:center;gap:5px;font-size:10px;font-weight:600;letter-spacing:.8px;text-transform:uppercase;color:var(--text-dim)}.hud-connection.active{color:var(--accent-green)}.hud-connection.active .pulse-dot{background:var(--accent-green);box-shadow:0 0 5px var(--accent-green),0 0 12px #00ff6a33}.hud-connection.disconnected{color:var(--accent-red)}.hud-connection.disconnected .pulse-dot{background:var(--accent-red);box-shadow:0 0 5px var(--accent-red);animation:none}.hud-icon-btn{display:flex;align-items:center;justify-content:center;width:26px;height:26px;background:transparent;border:1px solid rgba(255,255,255,.04);border-radius:6px;color:var(--text-dim);cursor:pointer;transition:all .2s;padding:0}.hud-icon-btn:hover{color:var(--accent-cyan);border-color:#00e4ff33;background:#00e4ff0f}.search-group{padding:2px 4px}.search-container{position:relative;display:flex;align-items:center}.search-icon{position:absolute;left:8px;color:var(--text-dim);pointer-events:none;opacity:.6}.search-input{font-family:var(--font-ui);font-size:11px;font-weight:400;padding:5px 24px 5px 26px;width:140px;background:#ffffff08;border:1px solid rgba(255,255,255,.04);border-radius:6px;color:var(--text-primary);outline:none;transition:all .25s}.search-input::placeholder{color:var(--text-dim);font-weight:300;opacity:.6}.search-input:focus{width:200px;border-color:#00e4ff33;background:#00e4ff0a;box-shadow:0 0 8px #00e4ff0f}.search-clear{position:absolute;right:5px;background:none;border:none;color:var(--text-dim);font-size:13px;cursor:pointer;padding:1px 3px;line-height:1;border-radius:3px;opacity:.6}.search-clear:hover{color:var(--text-primary);opacity:1}.filter-group{gap:3px;padding:4px 6px}.filter-chip{display:flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:6px;border:1px solid rgba(255,255,255,.03);background:transparent;cursor:pointer;transition:all .2s;padding:0}.filter-chip:hover{border-color:#ffffff14;background:#ffffff08}.filter-chip.active{border-color:#00e4ff33;background:#00e4ff0f}.filter-chip .dot{width:6px;height:6px}.pulse-dot{width:7px;height:7px;border-radius:50%;animation:pulse 2.5s ease-in-out infinite}.connection-banner{position:absolute;top:20px;left:50%;transform:translate(-50%);z-index:25;background:#ff2b4e26;border:1px solid rgba(255,43,78,.2);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);color:var(--accent-red);padding:8px 24px;border-radius:20px;font-size:12px;font-weight:500;letter-spacing:.5px;animation:fadeSlideDown .5s ease-out}.empty-state{position:absolute;top:50%;left:calc(50% - var(--sidebar-w, 380px) / 2);transform:translate(-50%,-50%);text-align:center;z-index:10;animation:fadeIn .8s ease-out .5s both}.empty-state h2{font-family:var(--font-ui);font-weight:600;font-size:20px;color:var(--text-secondary);margin-bottom:8px;letter-spacing:.5px}.empty-state p{font-size:13px;color:var(--text-dim);margin-bottom:20px}.empty-state code{display:inline-block;padding:8px 20px;background:var(--bg-inset);border:1px solid var(--border-glow);border-radius:var(--radius-md);font-family:var(--font-mono);font-size:13px;color:var(--accent-cyan);letter-spacing:.5px}.sidebar-wrap{position:absolute;top:0;right:0;bottom:0;z-index:15;animation:fadeSlideLeft .5s ease-out .3s both}.sidebar{width:100%;height:100%;background:var(--bg-surface);-webkit-backdrop-filter:blur(30px) saturate(1.2);backdrop-filter:blur(30px) saturate(1.2);border-left:1px solid var(--border-glow);display:flex;flex-direction:column;overflow:hidden}.sidebar:before{content:"";position:absolute;top:0;right:0;bottom:0;left:0;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,.015) 2px,rgba(0,0,0,.015) 4px);pointer-events:none;z-index:0}.sidebar>*{position:relative;z-index:1}.sidebar-empty{padding:32px 24px;flex:1;display:flex;flex-direction:column}.sidebar-empty .brand{font-family:var(--font-display);font-size:14px;font-weight:700;letter-spacing:3px;text-transform:uppercase;color:var(--accent-cyan);text-shadow:0 0 16px var(--accent-cyan-glow);margin-bottom:6px}.sidebar-empty .brand-sub{font-size:12px;color:var(--text-dim);margin-bottom:40px;font-weight:300;font-style:italic}.sidebar-empty .instruction{font-size:13px;color:var(--text-secondary);line-height:1.6;margin-bottom:32px}.legend{margin-top:auto}.legend-title{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:2px;color:var(--text-dim);margin-bottom:14px;padding-bottom:8px;border-bottom:1px solid var(--border-subtle)}.legend-item,.legend-line{display:flex;align-items:center;gap:10px;font-size:11px;color:var(--text-secondary);margin-bottom:8px}.sidebar-header{display:flex;align-items:center;justify-content:space-between;padding:12px 14px 12px 20px;border-bottom:1px solid var(--border-subtle);position:relative;gap:10px}.sidebar-header:after{content:"";position:absolute;bottom:-1px;left:0;right:0;height:1px;background:linear-gradient(90deg,var(--accent-cyan-dim),transparent 60%)}.sidebar-title{display:flex;align-items:center;gap:10px;min-width:0;flex:1}.sidebar-title h3{font-family:var(--font-ui);font-size:14px;font-weight:600;letter-spacing:.3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.header-right{display:flex;align-items:center;gap:5px;flex-shrink:0}.header-sep{width:1px;height:16px;background:var(--border-subtle);margin:0 2px}.close-btn{background:none;border:1px solid var(--border-subtle);color:var(--text-dim);width:26px;height:26px;border-radius:var(--radius-sm);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:15px;transition:all .2s}.close-btn:hover{border-color:var(--border-glow);color:var(--text-primary);background:var(--accent-cyan-dim)}.act-icon{width:30px;height:26px;display:flex;align-items:center;justify-content:center;background:#00e4ff0f;border:1px solid rgba(0,228,255,.12);border-radius:6px;color:var(--accent-cyan);cursor:pointer;transition:all .2s;padding:0}.act-icon:hover:not(:disabled){background:#00e4ff24;border-color:#00e4ff4d;box-shadow:0 0 10px #00e4ff1f;transform:translateY(-1px)}.act-icon:active:not(:disabled){transform:translateY(0)}.act-icon.danger{color:var(--accent-red);background:#ff2b4e0f;border-color:#ff2b4e1f}.act-icon.danger:hover:not(:disabled){background:#ff2b4e24;border-color:#ff2b4e4d;box-shadow:0 0 10px #ff2b4e1f}.act-icon.success{color:var(--accent-green);background:#00ff6a0f;border-color:#00ff6a1f}.act-icon.success:hover:not(:disabled){background:#00ff6a24;border-color:#00ff6a4d;box-shadow:0 0 10px #00ff6a1f}.act-icon:disabled{opacity:.3;cursor:not-allowed}.act-icon.spinning svg{animation:spin .8s linear infinite}.sidebar-tabs{display:flex;padding:0 20px;gap:2px;border-bottom:1px solid var(--border-subtle)}.tab{flex:1;background:none;border:none;color:var(--text-dim);padding:12px 0;font-family:var(--font-ui);font-size:11px;font-weight:600;cursor:pointer;text-transform:uppercase;letter-spacing:1.5px;border-bottom:2px solid transparent;transition:all .25s;position:relative}.tab:hover{color:var(--text-secondary)}.tab.active{color:var(--accent-cyan);border-bottom-color:var(--accent-cyan);text-shadow:0 0 12px var(--accent-cyan-glow)}.sidebar-content{padding:16px 20px;flex:1;overflow-y:auto}.info-section{margin-bottom:18px}.field-label{display:block;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:2px;color:var(--text-dim);margin-bottom:5px}.info-section .mono{font-family:var(--font-mono);font-size:12px;color:var(--text-primary);word-break:break-all;line-height:1.5}.tag{display:inline-block;padding:3px 10px;background:var(--bg-inset);border:1px solid var(--border-subtle);border-radius:var(--radius-sm);margin:3px 4px 3px 0;font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);transition:border-color .2s}.tag:hover{border-color:var(--border-glow)}.copyable{background:none;border:none;padding:0;cursor:pointer;text-align:left;color:inherit;font:inherit;word-break:break-all;line-height:1.5;border-radius:3px;transition:background .15s}.copyable:hover{background:var(--accent-cyan-dim);color:var(--accent-cyan)}.status-text{font-size:13px;font-weight:500}.status-text.running{color:var(--accent-green)}.status-text.exited{color:var(--text-dim)}.status-text.paused{color:var(--accent-purple)}.status-text.restarting{color:var(--accent-amber)}.gauge{display:flex;align-items:center;gap:12px;margin-top:6px}.progress-bar{flex:1;height:5px;background:var(--bg-inset);border-radius:3px;overflow:hidden;position:relative}.progress-fill{height:100%;border-radius:3px;transition:width .8s cubic-bezier(.16,1,.3,1);position:relative}.progress-fill.cpu{background:linear-gradient(90deg,var(--accent-cyan),#00ff99);box-shadow:0 0 10px #00e4ff4d}.progress-fill.memory{background:linear-gradient(90deg,var(--accent-purple),#ff66aa);box-shadow:0 0 10px #a855f74d}.gauge-value{font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);min-width:45px;text-align:right}.sidebar-logs{flex:1;overflow-y:auto;padding:8px}@keyframes spin{to{transform:rotate(360deg)}}.field-label-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:5px}.reveal-btn{font-family:var(--font-ui);font-size:10px;font-weight:500;padding:2px 10px;border-radius:10px;border:1px solid var(--border-glow);background:transparent;color:var(--text-dim);cursor:pointer;transition:all .2s}.reveal-btn:hover{color:var(--accent-cyan);border-color:var(--accent-cyan-dim)}.env-list{background:var(--bg-inset);border:1px solid var(--border-subtle);border-radius:var(--radius-md);padding:6px 0;max-height:250px;overflow-y:auto}.env-row{padding:3px 10px;font-size:10.5px;line-height:1.5;word-break:break-all;color:var(--text-secondary)}.env-row:hover{background:#00e4ff08}.mount-row{display:flex;align-items:flex-start;gap:8px;margin-bottom:6px}.sparkline-row{padding:4px 0}.env-loading{padding:20px;text-align:center;color:var(--text-dim);font-size:12px;font-style:italic}.log-search-bar{display:flex;align-items:center;gap:8px;padding:6px 8px;border-bottom:1px solid var(--border-subtle);flex-shrink:0}.log-search-input{flex:1;font-family:var(--font-mono);font-size:11px;padding:4px 8px;background:var(--bg-inset);border:1px solid var(--border-subtle);border-radius:var(--radius-sm);color:var(--text-primary);outline:none}.log-search-input:focus{border-color:var(--border-glow)}.log-match-count{font-family:var(--font-mono);font-size:10px;color:var(--text-dim);white-space:nowrap}.sidebar-logs pre{font-family:var(--font-mono);font-size:10.5px;line-height:1.7;color:var(--text-secondary);white-space:pre-wrap;word-break:break-all;padding:12px;background:var(--bg-inset);border-radius:var(--radius-md);border:1px solid var(--border-subtle);margin:4px}.statusbar-wrap{position:absolute;bottom:0;left:0;z-index:15;animation:fadeSlideUp .5s ease-out .4s both}.dot{width:6px;height:6px;border-radius:50%;display:inline-block;flex-shrink:0}.dot.green{background:var(--accent-green);box-shadow:0 0 6px #00ff6a66}.dot.red{background:var(--accent-red);box-shadow:0 0 6px #ff2b4e66}.dot.gray{background:var(--text-dim)}.dot.amber{background:var(--accent-amber);box-shadow:0 0 6px #ff8a2b66}.status-dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0}.status-dot.running{background:var(--accent-green);box-shadow:0 0 8px #00ff6a59}.status-dot.running.blue,.status-dot.cyan{background:var(--accent-cyan);box-shadow:0 0 8px #00e4ff59}.status-dot.unhealthy{background:var(--accent-red);box-shadow:0 0 8px #ff2b4e59;animation:pulse-red 2s ease-in-out infinite}.status-dot.exited{background:var(--text-dim);box-shadow:none}.status-dot.other{background:var(--accent-amber);box-shadow:0 0 8px #ff8a2b59}.line{display:inline-block;width:24px;height:2px;border-radius:1px}.line.depends{background:var(--accent-amber);box-shadow:0 0 4px #ff8a2b4d}.line.network{background:var(--accent-cyan);opacity:.4}.resize-handle-v{position:absolute;top:0;left:-3px;width:6px;height:100%;cursor:col-resize;z-index:20}.resize-handle-v:after{content:"";position:absolute;top:50%;left:2px;transform:translateY(-50%);width:2px;height:32px;border-radius:1px;background:#00e4ff00;transition:background .2s,height .2s}.resize-handle-v:hover:after{background:#00e4ff59;height:48px}.resize-handle-h{position:absolute;top:-3px;left:0;width:100%;height:6px;cursor:row-resize;z-index:20}.resize-handle-h:after{content:"";position:absolute;left:50%;top:2px;transform:translate(-50%);height:2px;width:32px;border-radius:1px;background:#00e4ff00;transition:background .2s,width .2s}.resize-handle-h:hover:after{background:#00e4ff59;width:48px}.app.is-dragging{cursor:col-resize}.app.is-dragging *{pointer-events:none!important;-webkit-user-select:none!important;user-select:none!important}.app.is-dragging .resize-handle-v,.app.is-dragging .resize-handle-h{pointer-events:auto!important}.log-level-error{color:var(--accent-red)}.log-level-warn{color:var(--accent-amber)}.log-level-debug{opacity:.45}.log-highlight{background:#fd33;border-radius:2px;padding:0 1px}::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:#00e4ff1f;border-radius:2px}::-webkit-scrollbar-thumb:hover{background:#00e4ff40}@keyframes fadeSlideDown{0%{opacity:0;transform:translateY(-14px)}to{opacity:1;transform:translateY(0)}}@keyframes fadeSlideLeft{0%{opacity:0;transform:translate(24px)}to{opacity:1;transform:translate(0)}}@keyframes fadeSlideUp{0%{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes pulse{0%,to{opacity:1}50%{opacity:.4}}@keyframes pulse-red{0%,to{box-shadow:0 0 8px #ff2b4e59}50%{box-shadow:0 0 14px #ff2b4e99}}
|