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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Manuel Tomé
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# DockScope
|
|
2
|
+
|
|
3
|
+
**Visual, interactive Docker infrastructure debugger.**
|
|
4
|
+
|
|
5
|
+
A browser-based 3D dependency graph of your Docker services with live health status, log streams, metrics, and container actions. Think of it as a mission control dashboard for your Docker Compose stacks.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **3D Force Graph** — Containers rendered as interactive nodes with dependency and network links
|
|
10
|
+
- **Live Health Status** — Real-time container state with color-coded nodes (healthy, unhealthy, stopped)
|
|
11
|
+
- **Log Streaming** — Live container logs with ANSI color support, timestamp shortening, and search
|
|
12
|
+
- **Container Actions** — Start, stop, restart containers directly from the UI
|
|
13
|
+
- **Compose Project Management** — Up, down, stop, restart entire compose projects
|
|
14
|
+
- **Environment Inspector** — View env vars (with secret masking), labels, mounts, and config
|
|
15
|
+
- **Metrics & Sparklines** — CPU, memory, and network I/O with 5-minute history charts
|
|
16
|
+
- **Node Importance Heuristic** — Nodes sized by exposed ports, connections, dependency depth, CPU, memory, network I/O, and network count
|
|
17
|
+
- **Project Clustering** — Compose projects grouped with translucent enclosure spheres and labels
|
|
18
|
+
- **Health Propagation** — Pulsing warning rings on containers whose dependencies are broken
|
|
19
|
+
- **Search & Filter** — Search by name/image, filter by status (running/stopped/unhealthy)
|
|
20
|
+
- **Keyboard Shortcuts** — `/` search, `F` zoom-to-fit, `R` reset camera, `Esc` close, `?` help
|
|
21
|
+
- **Resizable Panels** — Drag to resize sidebar and status bar
|
|
22
|
+
- **Event Stream** — Live Docker events with health check toggle
|
|
23
|
+
- **System Info** — Docker version, CPU count, total memory
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx dockscope up
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or install globally:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install -g dockscope
|
|
35
|
+
dockscope up
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires Docker to be running. Opens a browser at `http://localhost:4681`.
|
|
39
|
+
|
|
40
|
+
### Options
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
dockscope up [options]
|
|
44
|
+
|
|
45
|
+
-p, --port <port> Server port (default: 4681)
|
|
46
|
+
-f, --file <path> Docker Compose file path (default: auto-detect)
|
|
47
|
+
--no-open Don't open browser automatically
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Scan Mode
|
|
51
|
+
|
|
52
|
+
Output the container graph as JSON (no UI):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
dockscope scan
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Development
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
git clone https://github.com/ManuelR-T/dockscope.git
|
|
62
|
+
cd dockscope
|
|
63
|
+
npm install
|
|
64
|
+
npm run dev
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
This starts the backend on port `4681` and the Vite dev server on port `4680` with hot reload.
|
|
68
|
+
|
|
69
|
+
### Scripts
|
|
70
|
+
|
|
71
|
+
| Command | Description |
|
|
72
|
+
|---------|-------------|
|
|
73
|
+
| `npm run dev` | Start dev server (backend + frontend with HMR) |
|
|
74
|
+
| `npm run build` | Production build |
|
|
75
|
+
| `npm run start` | Run production build |
|
|
76
|
+
| `npm run lint` | ESLint check |
|
|
77
|
+
| `npm run lint:fix` | ESLint auto-fix |
|
|
78
|
+
| `npm run format` | Prettier format all files |
|
|
79
|
+
| `npm run format:check` | Prettier check |
|
|
80
|
+
| `npm run typecheck` | TypeScript type check |
|
|
81
|
+
|
|
82
|
+
## Tech Stack
|
|
83
|
+
|
|
84
|
+
| Layer | Technology |
|
|
85
|
+
|-------|-----------|
|
|
86
|
+
| **Frontend** | Svelte 5 (runes), Three.js, 3d-force-graph, three-spritetext |
|
|
87
|
+
| **Backend** | Express, WebSocket (ws), dockerode |
|
|
88
|
+
| **Build** | Vite, TypeScript (ESNext) |
|
|
89
|
+
| **CLI** | Commander |
|
|
90
|
+
| **Linting** | ESLint, Prettier, commitlint (conventional commits) |
|
|
91
|
+
| **CI/CD** | GitHub Actions (lint, typecheck, build, auto-release to npm) |
|
|
92
|
+
|
|
93
|
+
## Architecture
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
Docker daemon
|
|
97
|
+
|
|
|
98
|
+
+--> dockerode (client.ts, metrics.ts, logs.ts, links.ts)
|
|
99
|
+
| |
|
|
100
|
+
| +--> Express REST API (routes.ts)
|
|
101
|
+
| +--> WebSocket broadcasts (server/index.ts)
|
|
102
|
+
| |
|
|
103
|
+
| +--> Svelte 5 store (docker.svelte.ts)
|
|
104
|
+
| |
|
|
105
|
+
| +--> GraphView.svelte (3d-force-graph + Three.js)
|
|
106
|
+
| +--> Sidebar (Info / Env / Logs tabs)
|
|
107
|
+
| +--> StatusBar (event stream + system info)
|
|
108
|
+
|
|
|
109
|
+
+--> Compose parser (compose.ts)
|
|
110
|
+
|
|
|
111
|
+
+--> depends_on + network link extraction
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### API Endpoints
|
|
115
|
+
|
|
116
|
+
| Method | Path | Description |
|
|
117
|
+
|--------|------|-------------|
|
|
118
|
+
| GET | `/api/graph` | Full graph data (nodes + links) |
|
|
119
|
+
| GET | `/api/containers/:id/stats` | CPU, memory, network I/O |
|
|
120
|
+
| GET | `/api/containers/:id/logs` | Container logs |
|
|
121
|
+
| GET | `/api/containers/:id/inspect` | Env, labels, mounts, config |
|
|
122
|
+
| GET | `/api/containers/:id/history` | Metric history (sparkline data) |
|
|
123
|
+
| POST | `/api/containers/:id/start\|stop\|restart` | Container actions |
|
|
124
|
+
| GET | `/api/projects` | List compose projects |
|
|
125
|
+
| POST | `/api/projects/:name/up\|down\|stop\|start\|restart` | Project actions |
|
|
126
|
+
| GET | `/api/system` | Docker version, CPUs, memory |
|
|
127
|
+
| WS | `/ws` | Real-time graph, stats, events, log streaming |
|
|
128
|
+
|
|
129
|
+
### Keyboard Shortcuts
|
|
130
|
+
|
|
131
|
+
| Key | Action |
|
|
132
|
+
|-----|--------|
|
|
133
|
+
| `/` or `Ctrl+K` | Focus search |
|
|
134
|
+
| `Escape` | Close panel / clear search |
|
|
135
|
+
| `F` | Zoom to fit |
|
|
136
|
+
| `R` | Reset camera |
|
|
137
|
+
| `?` | Toggle shortcut help |
|
|
138
|
+
|
|
139
|
+
## Contributing
|
|
140
|
+
|
|
141
|
+
This project uses [Conventional Commits](https://www.conventionalcommits.org/). All commit messages must follow the format:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
type(scope): description
|
|
145
|
+
|
|
146
|
+
feat: add new feature
|
|
147
|
+
fix: fix a bug
|
|
148
|
+
docs: update documentation
|
|
149
|
+
refactor: code restructuring
|
|
150
|
+
chore: maintenance tasks
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Setup
|
|
154
|
+
|
|
155
|
+
1. Fork the repo on GitHub
|
|
156
|
+
2. Clone your fork: `git clone https://github.com/<your-username>/dockscope.git`
|
|
157
|
+
3. `npm install`
|
|
158
|
+
4. Create a branch: `git checkout -b feat/my-feature`
|
|
159
|
+
5. `npm run dev` to start development
|
|
160
|
+
6. Make your changes
|
|
161
|
+
7. `npm run lint && npm run format:check && npm run build` to verify
|
|
162
|
+
8. Commit with conventional messages: `git commit -m "feat: add my feature"`
|
|
163
|
+
9. Push to your fork: `git push origin feat/my-feature`
|
|
164
|
+
10. Open a PR against `main` on the upstream repo
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
[MIT](LICENSE)
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { startServer } from './server/index.js';
|
|
4
|
+
import { buildGraph, checkConnection } from './docker/client.js';
|
|
5
|
+
const program = new Command();
|
|
6
|
+
program
|
|
7
|
+
.name('dockscope')
|
|
8
|
+
.description('Visual, interactive Docker infrastructure debugger')
|
|
9
|
+
.version('0.1.0');
|
|
10
|
+
program
|
|
11
|
+
.command('up')
|
|
12
|
+
.description('Start the DockScope dashboard')
|
|
13
|
+
.option('-p, --port <port>', 'Server port', '4681')
|
|
14
|
+
.option('-f, --file <path>', 'Docker Compose file path', 'docker-compose.yml')
|
|
15
|
+
.option('--no-open', "Don't open browser automatically")
|
|
16
|
+
.action(async (opts) => {
|
|
17
|
+
const port = parseInt(opts.port, 10);
|
|
18
|
+
console.log(`
|
|
19
|
+
____ _ ____
|
|
20
|
+
| _ \\ ___ ___| | _/ ___| ___ ___ _ __ ___
|
|
21
|
+
| | | |/ _ \\ / __| |/ \\___ \\ / __/ _ \\| '_ \\ / _ \\
|
|
22
|
+
| |_| | (_) | (__| < ___) | (_| (_) | |_) | __/
|
|
23
|
+
|____/ \\___/ \\___|_|\\_\\____/ \\___\\___/| .__/ \\___|
|
|
24
|
+
|_| v0.1.0
|
|
25
|
+
`);
|
|
26
|
+
await startServer({ port, composeFile: opts.file, open: opts.open !== false });
|
|
27
|
+
const url = `http://localhost:${port}`;
|
|
28
|
+
console.log(` Dashboard: ${url}`);
|
|
29
|
+
console.log(` API: ${url}/api/graph`);
|
|
30
|
+
console.log(` WebSocket: ws://localhost:${port}/ws\n`);
|
|
31
|
+
console.log('');
|
|
32
|
+
console.log(' Press Ctrl+C to stop\n');
|
|
33
|
+
if (opts.open !== false) {
|
|
34
|
+
const open = (await import('open')).default;
|
|
35
|
+
await open(url);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
program
|
|
39
|
+
.command('scan')
|
|
40
|
+
.description('Scan Docker environment and output graph data as JSON')
|
|
41
|
+
.option('-f, --file <path>', 'Docker Compose file path', 'docker-compose.yml')
|
|
42
|
+
.action(async (opts) => {
|
|
43
|
+
const connected = await checkConnection();
|
|
44
|
+
if (!connected) {
|
|
45
|
+
console.error('Cannot connect to Docker daemon. Is Docker running?');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const graph = await buildGraph(opts.file);
|
|
49
|
+
console.log(JSON.stringify(graph, null, 2));
|
|
50
|
+
});
|
|
51
|
+
program.parse();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { GraphData, ContainerStats, ContainerInspect, SystemInfo, DockerEvent } from '../types.js';
|
|
2
|
+
export declare function checkConnection(): Promise<boolean>;
|
|
3
|
+
export declare function buildGraph(composeFile?: string): Promise<GraphData>;
|
|
4
|
+
export declare const getContainerStats: (id: string) => Promise<ContainerStats>;
|
|
5
|
+
export declare const getContainerLogs: (id: string, tail?: number) => Promise<string>;
|
|
6
|
+
export declare const streamContainerLogs: (id: string, onData: (t: string) => void, onError?: (e: Error) => void) => () => void;
|
|
7
|
+
/** List all compose projects (live + cached) with their container counts */
|
|
8
|
+
export declare function listComposeProjects(): Promise<{
|
|
9
|
+
name: string;
|
|
10
|
+
running: number;
|
|
11
|
+
stopped: number;
|
|
12
|
+
}[]>;
|
|
13
|
+
/** Run a docker compose action on a specific project */
|
|
14
|
+
export declare function composeAction(project: string, action: 'up' | 'down' | 'stop' | 'start' | 'restart'): Promise<string>;
|
|
15
|
+
export declare function containerAction(containerId: string, action: 'start' | 'stop' | 'restart'): Promise<void>;
|
|
16
|
+
export declare function inspectContainer(containerId: string): Promise<ContainerInspect>;
|
|
17
|
+
export declare function getSystemInfo(): Promise<SystemInfo>;
|
|
18
|
+
export declare function watchEvents(callback: (event: DockerEvent) => void, onError?: (err: Error) => void): () => void;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import Dockerode from 'dockerode';
|
|
2
|
+
import { exec } from 'child_process';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
import { getContainerStats as _getStats } from './metrics.js';
|
|
5
|
+
import { getContainerLogs as _getLogs, streamContainerLogs as _streamLogs } from './logs.js';
|
|
6
|
+
import { extractDependsOnFromLabels, extractDependsOnFromFile, extractNetworkLinks, } from './links.js';
|
|
7
|
+
const docker = new Dockerode();
|
|
8
|
+
export async function checkConnection() {
|
|
9
|
+
try {
|
|
10
|
+
await docker.ping();
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function buildGraph(composeFile) {
|
|
18
|
+
const containers = await docker.listContainers({ all: true });
|
|
19
|
+
const nodes = [];
|
|
20
|
+
const networkMap = new Map();
|
|
21
|
+
const containerProject = new Map();
|
|
22
|
+
for (const container of containers) {
|
|
23
|
+
const composeService = container.Labels['com.docker.compose.service'];
|
|
24
|
+
const project = container.Labels['com.docker.compose.project'] || '';
|
|
25
|
+
const rawName = composeService || container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12);
|
|
26
|
+
const hasDuplicate = containers.some((c) => c.Id !== container.Id &&
|
|
27
|
+
(c.Labels['com.docker.compose.service'] || c.Names[0]?.replace(/^\//, '')) === rawName &&
|
|
28
|
+
(c.Labels['com.docker.compose.project'] || '') !== project);
|
|
29
|
+
const serviceName = hasDuplicate && project ? `${project}/${rawName}` : rawName;
|
|
30
|
+
const shortId = container.Id.substring(0, 12);
|
|
31
|
+
containerProject.set(shortId, project);
|
|
32
|
+
const healthStatus = container.Status?.toLowerCase() || '';
|
|
33
|
+
let health = 'none';
|
|
34
|
+
if (healthStatus.includes('healthy') && !healthStatus.includes('unhealthy'))
|
|
35
|
+
health = 'healthy';
|
|
36
|
+
else if (healthStatus.includes('unhealthy'))
|
|
37
|
+
health = 'unhealthy';
|
|
38
|
+
else if (healthStatus.includes('starting') || healthStatus.includes('health:'))
|
|
39
|
+
health = 'starting';
|
|
40
|
+
nodes.push({
|
|
41
|
+
id: shortId,
|
|
42
|
+
name: serviceName,
|
|
43
|
+
project,
|
|
44
|
+
containerId: container.Id,
|
|
45
|
+
image: container.Image,
|
|
46
|
+
status: container.State,
|
|
47
|
+
health,
|
|
48
|
+
ports: [
|
|
49
|
+
...new Set(container.Ports.map((p) => `${p.PublicPort ? p.PublicPort + ':' : ''}${p.PrivatePort}/${p.Type}`)),
|
|
50
|
+
],
|
|
51
|
+
networks: Object.keys(container.NetworkSettings?.Networks || {}),
|
|
52
|
+
cpu: 0,
|
|
53
|
+
memory: 0,
|
|
54
|
+
memoryLimit: 0,
|
|
55
|
+
networkRx: 0,
|
|
56
|
+
networkTx: 0,
|
|
57
|
+
networkRxRate: 0,
|
|
58
|
+
networkTxRate: 0,
|
|
59
|
+
});
|
|
60
|
+
for (const net of Object.keys(container.NetworkSettings?.Networks || {})) {
|
|
61
|
+
if (!networkMap.has(net))
|
|
62
|
+
networkMap.set(net, []);
|
|
63
|
+
networkMap.get(net).push(shortId);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Build links from all sources
|
|
67
|
+
const { links: labelLinks, seen } = extractDependsOnFromLabels(containers, nodes, containerProject);
|
|
68
|
+
const fileLinks = await extractDependsOnFromFile(composeFile, nodes, containerProject, seen);
|
|
69
|
+
const netLinks = extractNetworkLinks(networkMap);
|
|
70
|
+
return { nodes, links: [...labelLinks, ...fileLinks, ...netLinks] };
|
|
71
|
+
}
|
|
72
|
+
// --- Delegate to extracted modules ---
|
|
73
|
+
export const getContainerStats = (id) => _getStats(docker, id);
|
|
74
|
+
export const getContainerLogs = (id, tail) => _getLogs(docker, id, tail);
|
|
75
|
+
export const streamContainerLogs = (id, onData, onError) => _streamLogs(docker, id, onData, onError);
|
|
76
|
+
const execAsync = promisify(exec);
|
|
77
|
+
const projectCache = new Map();
|
|
78
|
+
/** Cache project metadata from container labels */
|
|
79
|
+
function cacheProjectMeta(containers) {
|
|
80
|
+
for (const c of containers) {
|
|
81
|
+
const project = c.Labels['com.docker.compose.project'];
|
|
82
|
+
const workDir = c.Labels['com.docker.compose.project.working_dir'];
|
|
83
|
+
const configFiles = c.Labels['com.docker.compose.project.config_files'];
|
|
84
|
+
if (project && workDir && configFiles && !projectCache.has(project)) {
|
|
85
|
+
projectCache.set(project, { workDir, configFiles });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** List all compose projects (live + cached) with their container counts */
|
|
90
|
+
export async function listComposeProjects() {
|
|
91
|
+
const containers = await docker.listContainers({ all: true });
|
|
92
|
+
cacheProjectMeta(containers);
|
|
93
|
+
const projects = new Map();
|
|
94
|
+
// Live containers
|
|
95
|
+
for (const c of containers) {
|
|
96
|
+
const project = c.Labels['com.docker.compose.project'];
|
|
97
|
+
if (!project)
|
|
98
|
+
continue;
|
|
99
|
+
if (!projects.has(project))
|
|
100
|
+
projects.set(project, { running: 0, stopped: 0 });
|
|
101
|
+
const p = projects.get(project);
|
|
102
|
+
if (c.State === 'running')
|
|
103
|
+
p.running++;
|
|
104
|
+
else
|
|
105
|
+
p.stopped++;
|
|
106
|
+
}
|
|
107
|
+
// Cached projects with no live containers (after down)
|
|
108
|
+
for (const [name] of projectCache) {
|
|
109
|
+
if (!projects.has(name)) {
|
|
110
|
+
projects.set(name, { running: 0, stopped: 0 });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return [...projects.entries()]
|
|
114
|
+
.map(([name, counts]) => ({ name, ...counts }))
|
|
115
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
116
|
+
}
|
|
117
|
+
/** Get containers for a compose project */
|
|
118
|
+
async function getProjectContainers(project) {
|
|
119
|
+
return docker.listContainers({
|
|
120
|
+
all: true,
|
|
121
|
+
filters: { label: [`com.docker.compose.project=${project}`] },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/** Build the docker compose command from container labels or cache */
|
|
125
|
+
function getComposeCommand(project, containers) {
|
|
126
|
+
// Try live container labels first
|
|
127
|
+
let workDir = containers[0]?.Labels['com.docker.compose.project.working_dir'];
|
|
128
|
+
let configFiles = containers[0]?.Labels['com.docker.compose.project.config_files'];
|
|
129
|
+
// Fall back to cache (for projects that were downed)
|
|
130
|
+
if (!workDir || !configFiles) {
|
|
131
|
+
const cached = projectCache.get(project);
|
|
132
|
+
if (cached) {
|
|
133
|
+
workDir = cached.workDir;
|
|
134
|
+
configFiles = cached.configFiles;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!workDir || !configFiles)
|
|
138
|
+
return null;
|
|
139
|
+
const fileFlags = configFiles
|
|
140
|
+
.split(',')
|
|
141
|
+
.map((f) => `-f "${f.trim()}"`)
|
|
142
|
+
.join(' ');
|
|
143
|
+
return { cmd: `docker compose ${fileFlags}`, cwd: workDir };
|
|
144
|
+
}
|
|
145
|
+
/** Run a docker compose action on a specific project */
|
|
146
|
+
export async function composeAction(project, action) {
|
|
147
|
+
const containers = await getProjectContainers(project);
|
|
148
|
+
if (action === 'up' || action === 'down') {
|
|
149
|
+
const compose = getComposeCommand(project, containers);
|
|
150
|
+
if (compose) {
|
|
151
|
+
const subCmd = action === 'up' ? 'up -d' : 'down';
|
|
152
|
+
const { stdout, stderr } = await execAsync(`${compose.cmd} ${subCmd}`, { cwd: compose.cwd });
|
|
153
|
+
return stdout || stderr || `${action} completed`;
|
|
154
|
+
}
|
|
155
|
+
if (action === 'up') {
|
|
156
|
+
for (const c of containers) {
|
|
157
|
+
if (c.State !== 'running')
|
|
158
|
+
await docker.getContainer(c.Id).start();
|
|
159
|
+
}
|
|
160
|
+
return `Started containers in project ${project}`;
|
|
161
|
+
}
|
|
162
|
+
return 'Could not find compose config for down';
|
|
163
|
+
}
|
|
164
|
+
// stop / start / restart — act on individual containers
|
|
165
|
+
for (const c of containers) {
|
|
166
|
+
const container = docker.getContainer(c.Id);
|
|
167
|
+
if (action === 'stop' && c.State === 'running')
|
|
168
|
+
await container.stop();
|
|
169
|
+
else if (action === 'start' && c.State !== 'running')
|
|
170
|
+
await container.start();
|
|
171
|
+
else if (action === 'restart' && c.State === 'running')
|
|
172
|
+
await container.restart();
|
|
173
|
+
}
|
|
174
|
+
return `${action} completed for project ${project}`;
|
|
175
|
+
}
|
|
176
|
+
export async function containerAction(containerId, action) {
|
|
177
|
+
const container = docker.getContainer(containerId);
|
|
178
|
+
await container[action]();
|
|
179
|
+
}
|
|
180
|
+
export async function inspectContainer(containerId) {
|
|
181
|
+
const container = docker.getContainer(containerId);
|
|
182
|
+
const info = await container.inspect();
|
|
183
|
+
return {
|
|
184
|
+
id: info.Id.substring(0, 12),
|
|
185
|
+
env: info.Config.Env || [],
|
|
186
|
+
labels: info.Config.Labels || {},
|
|
187
|
+
mounts: (info.Mounts || []).map((m) => ({
|
|
188
|
+
type: m.Type || 'bind',
|
|
189
|
+
source: m.Source || '',
|
|
190
|
+
destination: m.Destination || '',
|
|
191
|
+
mode: m.Mode || 'rw',
|
|
192
|
+
})),
|
|
193
|
+
restartPolicy: info.HostConfig.RestartPolicy?.Name || 'no',
|
|
194
|
+
entrypoint: info.Config.Entrypoint || null,
|
|
195
|
+
cmd: info.Config.Cmd || null,
|
|
196
|
+
workingDir: info.Config.WorkingDir || '/',
|
|
197
|
+
created: info.Created,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
export async function getSystemInfo() {
|
|
201
|
+
const info = await docker.info();
|
|
202
|
+
return {
|
|
203
|
+
dockerVersion: info.ServerVersion || 'unknown',
|
|
204
|
+
os: `${info.OperatingSystem || 'unknown'} (${info.Architecture || ''})`,
|
|
205
|
+
totalMemory: info.MemTotal || 0,
|
|
206
|
+
cpus: info.NCPU || 0,
|
|
207
|
+
containersRunning: info.ContainersRunning || 0,
|
|
208
|
+
containersStopped: info.ContainersStopped || 0,
|
|
209
|
+
images: info.Images || 0,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
export function watchEvents(callback, onError) {
|
|
213
|
+
let destroyed = false;
|
|
214
|
+
let stream = null;
|
|
215
|
+
docker.getEvents({}, (err, eventStream) => {
|
|
216
|
+
if (err || !eventStream) {
|
|
217
|
+
onError?.(err || new Error('Failed to get event stream'));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (destroyed) {
|
|
221
|
+
eventStream.destroy?.();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
stream = eventStream;
|
|
225
|
+
eventStream.on('data', (chunk) => {
|
|
226
|
+
try {
|
|
227
|
+
const raw = JSON.parse(chunk.toString());
|
|
228
|
+
callback({
|
|
229
|
+
id: (raw.Actor?.ID || raw.id || '').substring(0, 12),
|
|
230
|
+
type: raw.Type || 'unknown',
|
|
231
|
+
action: raw.Action || raw.status || 'unknown',
|
|
232
|
+
actor: raw.Actor?.Attributes?.name ||
|
|
233
|
+
raw.Actor?.Attributes?.['com.docker.compose.service'] ||
|
|
234
|
+
raw.Actor?.ID?.substring(0, 12) ||
|
|
235
|
+
'unknown',
|
|
236
|
+
time: raw.time || Math.floor(Date.now() / 1000),
|
|
237
|
+
message: `${raw.Type || ''} ${raw.Action || ''}: ${raw.Actor?.Attributes?.name || raw.Actor?.ID?.substring(0, 12) || ''}`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
/* ignore */
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
eventStream.on('error', (e) => onError?.(e));
|
|
245
|
+
});
|
|
246
|
+
return () => {
|
|
247
|
+
destroyed = true;
|
|
248
|
+
stream?.destroy?.();
|
|
249
|
+
};
|
|
250
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface ComposeService {
|
|
2
|
+
name: string;
|
|
3
|
+
image: string;
|
|
4
|
+
ports: string[];
|
|
5
|
+
networks: string[];
|
|
6
|
+
dependsOn: string[];
|
|
7
|
+
volumes: string[];
|
|
8
|
+
environment: Record<string, string>;
|
|
9
|
+
labels: Record<string, string>;
|
|
10
|
+
healthcheck: {
|
|
11
|
+
test: string;
|
|
12
|
+
interval?: string;
|
|
13
|
+
timeout?: string;
|
|
14
|
+
retries?: number;
|
|
15
|
+
} | null;
|
|
16
|
+
resourceLimits: {
|
|
17
|
+
cpus?: string;
|
|
18
|
+
memory?: string;
|
|
19
|
+
} | null;
|
|
20
|
+
}
|
|
21
|
+
export interface ComposeData {
|
|
22
|
+
services: ComposeService[];
|
|
23
|
+
networks: string[];
|
|
24
|
+
}
|
|
25
|
+
export declare function parseComposeFile(filePath: string): Promise<ComposeData>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { parse } from 'yaml';
|
|
3
|
+
export async function parseComposeFile(filePath) {
|
|
4
|
+
const content = await readFile(filePath, 'utf-8');
|
|
5
|
+
const compose = parse(content);
|
|
6
|
+
if (!compose?.services) {
|
|
7
|
+
return { services: [], networks: [] };
|
|
8
|
+
}
|
|
9
|
+
const services = [];
|
|
10
|
+
for (const [name, svc] of Object.entries(compose.services)) {
|
|
11
|
+
const dependsOn = parseDependsOn(svc.depends_on);
|
|
12
|
+
services.push({
|
|
13
|
+
name,
|
|
14
|
+
image: svc.image || `${name}:latest`,
|
|
15
|
+
ports: Array.isArray(svc.ports) ? svc.ports.map(String) : [],
|
|
16
|
+
networks: parseNetworks(svc.networks),
|
|
17
|
+
dependsOn,
|
|
18
|
+
volumes: Array.isArray(svc.volumes) ? svc.volumes.map(String) : [],
|
|
19
|
+
environment: parseEnvironment(svc.environment),
|
|
20
|
+
labels: parseLabels(svc.labels),
|
|
21
|
+
healthcheck: parseHealthcheck(svc.healthcheck),
|
|
22
|
+
resourceLimits: parseResourceLimits(svc.deploy),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
const topLevelNetworks = compose.networks ? Object.keys(compose.networks) : [];
|
|
26
|
+
return { services, networks: topLevelNetworks };
|
|
27
|
+
}
|
|
28
|
+
function parseDependsOn(dep) {
|
|
29
|
+
if (!dep)
|
|
30
|
+
return [];
|
|
31
|
+
// Simple form: depends_on: [db, redis]
|
|
32
|
+
if (Array.isArray(dep))
|
|
33
|
+
return dep.map(String);
|
|
34
|
+
// Extended form: depends_on: { db: { condition: service_healthy } }
|
|
35
|
+
if (typeof dep === 'object')
|
|
36
|
+
return Object.keys(dep);
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
function parseNetworks(nets) {
|
|
40
|
+
if (!nets)
|
|
41
|
+
return [];
|
|
42
|
+
if (Array.isArray(nets))
|
|
43
|
+
return nets.map(String);
|
|
44
|
+
if (typeof nets === 'object')
|
|
45
|
+
return Object.keys(nets);
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
function parseEnvironment(env) {
|
|
49
|
+
if (!env)
|
|
50
|
+
return {};
|
|
51
|
+
if (Array.isArray(env)) {
|
|
52
|
+
const result = {};
|
|
53
|
+
for (const item of env) {
|
|
54
|
+
const s = String(item);
|
|
55
|
+
const eqIdx = s.indexOf('=');
|
|
56
|
+
if (eqIdx > 0)
|
|
57
|
+
result[s.substring(0, eqIdx)] = s.substring(eqIdx + 1);
|
|
58
|
+
else
|
|
59
|
+
result[s] = '';
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
if (typeof env === 'object') {
|
|
64
|
+
return Object.fromEntries(Object.entries(env).map(([k, v]) => [k, String(v ?? '')]));
|
|
65
|
+
}
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
function parseLabels(labels) {
|
|
69
|
+
if (!labels)
|
|
70
|
+
return {};
|
|
71
|
+
if (Array.isArray(labels)) {
|
|
72
|
+
const result = {};
|
|
73
|
+
for (const item of labels) {
|
|
74
|
+
const s = String(item);
|
|
75
|
+
const eqIdx = s.indexOf('=');
|
|
76
|
+
if (eqIdx > 0)
|
|
77
|
+
result[s.substring(0, eqIdx)] = s.substring(eqIdx + 1);
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
if (typeof labels === 'object') {
|
|
82
|
+
return Object.fromEntries(Object.entries(labels).map(([k, v]) => [k, String(v ?? '')]));
|
|
83
|
+
}
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
function parseHealthcheck(hc) {
|
|
87
|
+
if (!hc || typeof hc !== 'object')
|
|
88
|
+
return null;
|
|
89
|
+
const h = hc;
|
|
90
|
+
const test = Array.isArray(h.test) ? h.test.join(' ') : String(h.test || '');
|
|
91
|
+
if (!test)
|
|
92
|
+
return null;
|
|
93
|
+
return {
|
|
94
|
+
test,
|
|
95
|
+
interval: h.interval,
|
|
96
|
+
timeout: h.timeout,
|
|
97
|
+
retries: h.retries,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function parseResourceLimits(deploy) {
|
|
101
|
+
if (!deploy || typeof deploy !== 'object')
|
|
102
|
+
return null;
|
|
103
|
+
const d = deploy;
|
|
104
|
+
const limits = d.resources?.limits;
|
|
105
|
+
if (!limits)
|
|
106
|
+
return null;
|
|
107
|
+
return {
|
|
108
|
+
cpus: limits.cpus ? String(limits.cpus) : undefined,
|
|
109
|
+
memory: limits.memory ? String(limits.memory) : undefined,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ServiceNode, ServiceLink } from '../types.js';
|
|
2
|
+
/** Extract depends_on links from container labels (runtime) */
|
|
3
|
+
export declare function extractDependsOnFromLabels(containers: any[], nodes: ServiceNode[], containerProject: Map<string, string>): {
|
|
4
|
+
links: ServiceLink[];
|
|
5
|
+
seen: Set<string>;
|
|
6
|
+
};
|
|
7
|
+
/** Extract depends_on links from compose file */
|
|
8
|
+
export declare function extractDependsOnFromFile(composeFile: string | undefined, nodes: ServiceNode[], containerProject: Map<string, string>, seen: Set<string>): Promise<ServiceLink[]>;
|
|
9
|
+
/** Extract network-based links (containers sharing non-default networks) */
|
|
10
|
+
export declare function extractNetworkLinks(networkMap: Map<string, string[]>): ServiceLink[];
|