tlc-claude-code 2.0.1 → 2.1.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/.claude/commands/tlc/deploy.md +194 -2
- package/.claude/commands/tlc/e2e-verify.md +214 -0
- package/.claude/commands/tlc/guard.md +191 -0
- package/.claude/commands/tlc/help.md +32 -0
- package/.claude/commands/tlc/init.md +73 -37
- package/.claude/commands/tlc/llm.md +19 -4
- package/.claude/commands/tlc/preflight.md +134 -0
- package/.claude/commands/tlc/review.md +17 -4
- package/.claude/commands/tlc/watchci.md +159 -0
- package/.claude/hooks/tlc-block-tools.sh +41 -0
- package/.claude/hooks/tlc-capture-exchange.sh +50 -0
- package/.claude/hooks/tlc-post-build.sh +38 -0
- package/.claude/hooks/tlc-post-push.sh +22 -0
- package/.claude/hooks/tlc-prompt-guard.sh +69 -0
- package/.claude/hooks/tlc-session-init.sh +123 -0
- package/CLAUDE.md +12 -0
- package/bin/install.js +171 -2
- package/bin/postinstall.js +45 -26
- package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/docker-compose.dev.yml +18 -12
- package/package.json +3 -1
- package/server/index.js +228 -2
- package/server/lib/capture-bridge.js +242 -0
- package/server/lib/capture-bridge.test.js +363 -0
- package/server/lib/capture-guard.js +140 -0
- package/server/lib/capture-guard.test.js +182 -0
- package/server/lib/command-runner.js +159 -0
- package/server/lib/command-runner.test.js +92 -0
- package/server/lib/deploy/runners/dependency-runner.js +106 -0
- package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
- package/server/lib/deploy/runners/secrets-runner.js +174 -0
- package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
- package/server/lib/deploy/security-gates.js +11 -24
- package/server/lib/deploy/security-gates.test.js +9 -2
- package/server/lib/deploy-engine.js +182 -0
- package/server/lib/deploy-engine.test.js +147 -0
- package/server/lib/docker-api.js +137 -0
- package/server/lib/docker-api.test.js +202 -0
- package/server/lib/docker-client.js +297 -0
- package/server/lib/docker-client.test.js +308 -0
- package/server/lib/input-sanitizer.js +86 -0
- package/server/lib/input-sanitizer.test.js +117 -0
- package/server/lib/launchd-agent.js +225 -0
- package/server/lib/launchd-agent.test.js +185 -0
- package/server/lib/memory-api.js +3 -1
- package/server/lib/memory-api.test.js +3 -5
- package/server/lib/memory-bridge-e2e.test.js +160 -0
- package/server/lib/memory-committer.js +18 -4
- package/server/lib/memory-committer.test.js +21 -0
- package/server/lib/memory-hooks-capture.test.js +69 -4
- package/server/lib/memory-hooks-integration.test.js +98 -0
- package/server/lib/memory-hooks.js +42 -4
- package/server/lib/memory-store-adapter.js +105 -0
- package/server/lib/memory-store-adapter.test.js +141 -0
- package/server/lib/memory-wiring-e2e.test.js +93 -0
- package/server/lib/nginx-config.js +114 -0
- package/server/lib/nginx-config.test.js +82 -0
- package/server/lib/ollama-health.js +91 -0
- package/server/lib/ollama-health.test.js +74 -0
- package/server/lib/port-guard.js +44 -0
- package/server/lib/port-guard.test.js +65 -0
- package/server/lib/project-scanner.js +37 -2
- package/server/lib/project-scanner.test.js +152 -0
- package/server/lib/remember-command.js +2 -0
- package/server/lib/remember-command.test.js +23 -0
- package/server/lib/security/crypto-utils.test.js +2 -2
- package/server/lib/semantic-recall.js +1 -1
- package/server/lib/semantic-recall.test.js +17 -0
- package/server/lib/ssh-client.js +184 -0
- package/server/lib/ssh-client.test.js +127 -0
- package/server/lib/vps-api.js +184 -0
- package/server/lib/vps-api.test.js +208 -0
- package/server/lib/vps-bootstrap.js +124 -0
- package/server/lib/vps-bootstrap.test.js +79 -0
- package/server/lib/vps-monitor.js +126 -0
- package/server/lib/vps-monitor.test.js +98 -0
- package/server/lib/workspace-api.js +182 -1
- package/server/lib/workspace-api.test.js +474 -0
- package/server/package-lock.json +737 -0
- package/server/package.json +3 -0
- package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
- package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
- package/dashboard-web/dist/assets/index-W36XHPC5.js.map +0 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
|
|
5
|
+
const { createDockerRouter } = await import('./docker-api.js');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Helper: create mock docker client
|
|
9
|
+
*/
|
|
10
|
+
function createMockDockerClient(available = true) {
|
|
11
|
+
return {
|
|
12
|
+
isAvailable: vi.fn().mockResolvedValue(
|
|
13
|
+
available
|
|
14
|
+
? { available: true, version: '24.0.0', apiVersion: '1.43' }
|
|
15
|
+
: { available: false, error: 'Docker not available' }
|
|
16
|
+
),
|
|
17
|
+
listContainers: vi.fn().mockResolvedValue([
|
|
18
|
+
{
|
|
19
|
+
id: 'abc123',
|
|
20
|
+
name: 'tlc-dev-dashboard',
|
|
21
|
+
image: 'node:20-alpine',
|
|
22
|
+
state: 'running',
|
|
23
|
+
status: 'Up 2 hours',
|
|
24
|
+
ports: [{ private: 3147, public: 3147, type: 'tcp' }],
|
|
25
|
+
created: 1708300000,
|
|
26
|
+
labels: {},
|
|
27
|
+
},
|
|
28
|
+
]),
|
|
29
|
+
getContainer: vi.fn().mockResolvedValue({
|
|
30
|
+
id: 'abc123',
|
|
31
|
+
name: 'tlc-dev-dashboard',
|
|
32
|
+
image: 'node:20-alpine',
|
|
33
|
+
state: 'running',
|
|
34
|
+
env: ['NODE_ENV=development'],
|
|
35
|
+
mounts: [],
|
|
36
|
+
networks: {},
|
|
37
|
+
}),
|
|
38
|
+
startContainer: vi.fn().mockResolvedValue(),
|
|
39
|
+
stopContainer: vi.fn().mockResolvedValue(),
|
|
40
|
+
restartContainer: vi.fn().mockResolvedValue(),
|
|
41
|
+
removeContainer: vi.fn().mockResolvedValue(),
|
|
42
|
+
getContainerStats: vi.fn().mockResolvedValue({
|
|
43
|
+
cpuPercent: 2.5,
|
|
44
|
+
memoryUsage: 104857600,
|
|
45
|
+
memoryLimit: 2147483648,
|
|
46
|
+
networkRx: 1024000,
|
|
47
|
+
networkTx: 512000,
|
|
48
|
+
}),
|
|
49
|
+
getContainerLogs: vi.fn().mockResolvedValue('log line 1\nlog line 2\n'),
|
|
50
|
+
listImages: vi.fn().mockResolvedValue([
|
|
51
|
+
{ id: 'sha256:abc', tags: ['node:20-alpine'], size: 180000000, created: 1708200000 },
|
|
52
|
+
]),
|
|
53
|
+
listVolumes: vi.fn().mockResolvedValue([
|
|
54
|
+
{ name: 'postgres-data', driver: 'local', mountpoint: '/var/lib/docker/volumes/pg/_data' },
|
|
55
|
+
]),
|
|
56
|
+
matchContainerToProject: vi.fn().mockReturnValue(null),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createApp(dockerClient) {
|
|
61
|
+
const app = express();
|
|
62
|
+
app.use(express.json());
|
|
63
|
+
app.use('/docker', createDockerRouter({ dockerClient }));
|
|
64
|
+
return app;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('Docker API Router', () => {
|
|
68
|
+
let mockClient;
|
|
69
|
+
let app;
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
mockClient = createMockDockerClient();
|
|
73
|
+
app = createApp(mockClient);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('GET /docker/status', () => {
|
|
77
|
+
it('returns Docker status and version', async () => {
|
|
78
|
+
const res = await request(app).get('/docker/status');
|
|
79
|
+
expect(res.status).toBe(200);
|
|
80
|
+
expect(res.body.available).toBe(true);
|
|
81
|
+
expect(res.body.version).toBe('24.0.0');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns 503 when Docker unavailable', async () => {
|
|
85
|
+
const unavailableClient = createMockDockerClient(false);
|
|
86
|
+
const unavailableApp = createApp(unavailableClient);
|
|
87
|
+
const res = await request(unavailableApp).get('/docker/status');
|
|
88
|
+
expect(res.status).toBe(503);
|
|
89
|
+
expect(res.body.available).toBe(false);
|
|
90
|
+
expect(res.body.error).toBeTruthy();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('GET /docker/containers', () => {
|
|
95
|
+
it('returns list of containers', async () => {
|
|
96
|
+
const res = await request(app).get('/docker/containers');
|
|
97
|
+
expect(res.status).toBe(200);
|
|
98
|
+
expect(res.body).toHaveLength(1);
|
|
99
|
+
expect(res.body[0].name).toBe('tlc-dev-dashboard');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('passes all=true query parameter', async () => {
|
|
103
|
+
await request(app).get('/docker/containers?all=true');
|
|
104
|
+
expect(mockClient.listContainers).toHaveBeenCalledWith(true);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('GET /docker/containers/:id', () => {
|
|
109
|
+
it('returns container detail', async () => {
|
|
110
|
+
const res = await request(app).get('/docker/containers/abc123');
|
|
111
|
+
expect(res.status).toBe(200);
|
|
112
|
+
expect(res.body.id).toBe('abc123');
|
|
113
|
+
expect(mockClient.getContainer).toHaveBeenCalledWith('abc123');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('returns 404 for unknown container', async () => {
|
|
117
|
+
mockClient.getContainer.mockRejectedValue(
|
|
118
|
+
Object.assign(new Error('no such container'), { statusCode: 404 })
|
|
119
|
+
);
|
|
120
|
+
const res = await request(app).get('/docker/containers/nonexistent');
|
|
121
|
+
expect(res.status).toBe(404);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('POST /docker/containers/:id/start', () => {
|
|
126
|
+
it('starts a container', async () => {
|
|
127
|
+
const res = await request(app).post('/docker/containers/abc123/start');
|
|
128
|
+
expect(res.status).toBe(200);
|
|
129
|
+
expect(mockClient.startContainer).toHaveBeenCalledWith('abc123');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('POST /docker/containers/:id/stop', () => {
|
|
134
|
+
it('stops a container', async () => {
|
|
135
|
+
const res = await request(app).post('/docker/containers/abc123/stop');
|
|
136
|
+
expect(res.status).toBe(200);
|
|
137
|
+
expect(mockClient.stopContainer).toHaveBeenCalledWith('abc123');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('POST /docker/containers/:id/restart', () => {
|
|
142
|
+
it('restarts a container', async () => {
|
|
143
|
+
const res = await request(app).post('/docker/containers/abc123/restart');
|
|
144
|
+
expect(res.status).toBe(200);
|
|
145
|
+
expect(mockClient.restartContainer).toHaveBeenCalledWith('abc123');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('DELETE /docker/containers/:id', () => {
|
|
150
|
+
it('removes a container', async () => {
|
|
151
|
+
const res = await request(app).delete('/docker/containers/abc123');
|
|
152
|
+
expect(res.status).toBe(200);
|
|
153
|
+
expect(mockClient.removeContainer).toHaveBeenCalledWith('abc123', false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('removes with force when requested', async () => {
|
|
157
|
+
const res = await request(app).delete('/docker/containers/abc123?force=true');
|
|
158
|
+
expect(res.status).toBe(200);
|
|
159
|
+
expect(mockClient.removeContainer).toHaveBeenCalledWith('abc123', true);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('GET /docker/containers/:id/logs', () => {
|
|
164
|
+
it('returns container logs', async () => {
|
|
165
|
+
const res = await request(app).get('/docker/containers/abc123/logs');
|
|
166
|
+
expect(res.status).toBe(200);
|
|
167
|
+
expect(res.body.logs).toContain('log line 1');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('passes tail parameter', async () => {
|
|
171
|
+
await request(app).get('/docker/containers/abc123/logs?tail=50');
|
|
172
|
+
expect(mockClient.getContainerLogs).toHaveBeenCalledWith('abc123', { tail: 50 });
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('GET /docker/containers/:id/stats', () => {
|
|
177
|
+
it('returns container stats snapshot', async () => {
|
|
178
|
+
const res = await request(app).get('/docker/containers/abc123/stats');
|
|
179
|
+
expect(res.status).toBe(200);
|
|
180
|
+
expect(res.body.cpuPercent).toBe(2.5);
|
|
181
|
+
expect(res.body.memoryUsage).toBe(104857600);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('GET /docker/images', () => {
|
|
186
|
+
it('returns list of images', async () => {
|
|
187
|
+
const res = await request(app).get('/docker/images');
|
|
188
|
+
expect(res.status).toBe(200);
|
|
189
|
+
expect(res.body).toHaveLength(1);
|
|
190
|
+
expect(res.body[0].tags).toContain('node:20-alpine');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('GET /docker/volumes', () => {
|
|
195
|
+
it('returns list of volumes', async () => {
|
|
196
|
+
const res = await request(app).get('/docker/volumes');
|
|
197
|
+
expect(res.status).toBe(200);
|
|
198
|
+
expect(res.body).toHaveLength(1);
|
|
199
|
+
expect(res.body[0].name).toBe('postgres-data');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker Client — wraps dockerode for Docker socket communication
|
|
3
|
+
* Phase 80 Task 1
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const Docker = require('dockerode');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create a Docker client instance
|
|
10
|
+
* @param {Object} options
|
|
11
|
+
* @param {string} [options.socketPath=/var/run/docker.sock] - Docker socket path
|
|
12
|
+
* @param {Object} [options._docker] - Injected Docker instance (for testing)
|
|
13
|
+
* @returns {Object} Docker client API
|
|
14
|
+
*/
|
|
15
|
+
function createDockerClient(options = {}) {
|
|
16
|
+
const socketPath = options.socketPath || '/var/run/docker.sock';
|
|
17
|
+
const docker = options._docker || new Docker({ socketPath });
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if Docker daemon is accessible
|
|
21
|
+
*/
|
|
22
|
+
async function isAvailable() {
|
|
23
|
+
try {
|
|
24
|
+
await docker.ping();
|
|
25
|
+
const info = await docker.version();
|
|
26
|
+
return { available: true, version: info.Version, apiVersion: info.ApiVersion };
|
|
27
|
+
} catch (err) {
|
|
28
|
+
return { available: false, error: err.message };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* List containers
|
|
34
|
+
* @param {boolean} [all=false] - Include stopped containers
|
|
35
|
+
*/
|
|
36
|
+
async function listContainers(all = false) {
|
|
37
|
+
const containers = await docker.listContainers({ all });
|
|
38
|
+
return containers.map(c => ({
|
|
39
|
+
id: c.Id,
|
|
40
|
+
name: (c.Names[0] || '').replace(/^\//, ''),
|
|
41
|
+
image: c.Image,
|
|
42
|
+
state: c.State,
|
|
43
|
+
status: c.Status,
|
|
44
|
+
ports: (c.Ports || []).map(p => ({
|
|
45
|
+
private: p.PrivatePort,
|
|
46
|
+
public: p.PublicPort,
|
|
47
|
+
type: p.Type,
|
|
48
|
+
})),
|
|
49
|
+
created: c.Created,
|
|
50
|
+
labels: c.Labels || {},
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get container detail
|
|
56
|
+
* @param {string} id - Container ID or name
|
|
57
|
+
*/
|
|
58
|
+
async function getContainer(id) {
|
|
59
|
+
const container = docker.getContainer(id);
|
|
60
|
+
const info = await container.inspect();
|
|
61
|
+
return {
|
|
62
|
+
id: info.Id,
|
|
63
|
+
name: (info.Name || '').replace(/^\//, ''),
|
|
64
|
+
image: info.Config.Image,
|
|
65
|
+
state: info.State.Status,
|
|
66
|
+
startedAt: info.State.StartedAt,
|
|
67
|
+
env: info.Config.Env || [],
|
|
68
|
+
mounts: (info.Mounts || []).map(m => ({
|
|
69
|
+
source: m.Source,
|
|
70
|
+
destination: m.Destination,
|
|
71
|
+
rw: m.RW,
|
|
72
|
+
})),
|
|
73
|
+
networks: info.NetworkSettings.Networks || {},
|
|
74
|
+
ports: info.HostConfig.PortBindings || {},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Start a container
|
|
80
|
+
* @param {string} id - Container ID
|
|
81
|
+
*/
|
|
82
|
+
async function startContainer(id) {
|
|
83
|
+
const container = docker.getContainer(id);
|
|
84
|
+
await container.start();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Stop a container
|
|
89
|
+
* @param {string} id - Container ID
|
|
90
|
+
*/
|
|
91
|
+
async function stopContainer(id) {
|
|
92
|
+
const container = docker.getContainer(id);
|
|
93
|
+
await container.stop();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Restart a container
|
|
98
|
+
* @param {string} id - Container ID
|
|
99
|
+
*/
|
|
100
|
+
async function restartContainer(id) {
|
|
101
|
+
const container = docker.getContainer(id);
|
|
102
|
+
await container.restart();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Remove a container
|
|
107
|
+
* @param {string} id - Container ID
|
|
108
|
+
* @param {boolean} [force=false] - Force removal
|
|
109
|
+
*/
|
|
110
|
+
async function removeContainer(id, force = false) {
|
|
111
|
+
const container = docker.getContainer(id);
|
|
112
|
+
await container.remove({ force });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get container stats snapshot
|
|
117
|
+
* @param {string} id - Container ID
|
|
118
|
+
*/
|
|
119
|
+
async function getContainerStats(id) {
|
|
120
|
+
const container = docker.getContainer(id);
|
|
121
|
+
const stats = await container.stats({ stream: false });
|
|
122
|
+
|
|
123
|
+
// Calculate CPU %
|
|
124
|
+
const cpuDelta = (stats.cpu_stats?.cpu_usage?.total_usage || 0) - (stats.precpu_stats?.cpu_usage?.total_usage || 0);
|
|
125
|
+
const systemDelta = (stats.cpu_stats?.system_cpu_usage || 0) - (stats.precpu_stats?.system_cpu_usage || 0);
|
|
126
|
+
const numCpus = stats.cpu_stats?.online_cpus || 1;
|
|
127
|
+
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * numCpus * 100 : 0;
|
|
128
|
+
|
|
129
|
+
// Memory
|
|
130
|
+
const cache = stats.memory_stats?.stats?.cache || stats.memory_stats?.stats?.inactive_file || 0;
|
|
131
|
+
const memoryUsage = (stats.memory_stats?.usage || 0) - cache;
|
|
132
|
+
const memoryLimit = stats.memory_stats?.limit || 0;
|
|
133
|
+
|
|
134
|
+
// Network
|
|
135
|
+
let networkRx = 0;
|
|
136
|
+
let networkTx = 0;
|
|
137
|
+
if (stats.networks) {
|
|
138
|
+
for (const iface of Object.values(stats.networks)) {
|
|
139
|
+
networkRx += iface.rx_bytes || 0;
|
|
140
|
+
networkTx += iface.tx_bytes || 0;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { cpuPercent, memoryUsage, memoryLimit, networkRx, networkTx };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get container logs
|
|
149
|
+
* @param {string} id - Container ID
|
|
150
|
+
* @param {Object} [opts]
|
|
151
|
+
* @param {number} [opts.tail=100] - Number of lines
|
|
152
|
+
*/
|
|
153
|
+
async function getContainerLogs(id, opts = {}) {
|
|
154
|
+
const container = docker.getContainer(id);
|
|
155
|
+
const logs = await container.logs({
|
|
156
|
+
stdout: true,
|
|
157
|
+
stderr: true,
|
|
158
|
+
tail: opts.tail || 100,
|
|
159
|
+
timestamps: true,
|
|
160
|
+
});
|
|
161
|
+
// logs may be Buffer or string
|
|
162
|
+
return typeof logs === 'string' ? logs : logs.toString('utf8');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Stream container logs (live)
|
|
167
|
+
* @param {string} id - Container ID
|
|
168
|
+
* @param {Function} callback - Called with each log chunk
|
|
169
|
+
* @returns {Function} abort function
|
|
170
|
+
*/
|
|
171
|
+
function streamContainerLogs(id, callback) {
|
|
172
|
+
let aborted = false;
|
|
173
|
+
let streamRef = null;
|
|
174
|
+
const container = docker.getContainer(id);
|
|
175
|
+
container.logs({ follow: true, stdout: true, stderr: true, tail: 50, timestamps: true })
|
|
176
|
+
.then(stream => {
|
|
177
|
+
streamRef = stream;
|
|
178
|
+
if (aborted) { stream.destroy && stream.destroy(); return; }
|
|
179
|
+
stream.on('data', chunk => {
|
|
180
|
+
if (!aborted) callback(chunk.toString('utf8'));
|
|
181
|
+
});
|
|
182
|
+
stream.on('end', () => {});
|
|
183
|
+
})
|
|
184
|
+
.catch((err) => { callback && callback(null, err); });
|
|
185
|
+
return () => { aborted = true; if (streamRef) { streamRef.destroy && streamRef.destroy(); } };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Stream container stats (live)
|
|
190
|
+
* @param {string} id - Container ID
|
|
191
|
+
* @param {Function} callback - Called with each stats update
|
|
192
|
+
* @returns {Function} abort function
|
|
193
|
+
*/
|
|
194
|
+
function streamContainerStats(id, callback) {
|
|
195
|
+
let aborted = false;
|
|
196
|
+
let streamRef = null;
|
|
197
|
+
const container = docker.getContainer(id);
|
|
198
|
+
container.stats({ stream: true })
|
|
199
|
+
.then(stream => {
|
|
200
|
+
streamRef = stream;
|
|
201
|
+
if (aborted) { stream.destroy && stream.destroy(); return; }
|
|
202
|
+
let buffer = '';
|
|
203
|
+
stream.on('data', chunk => {
|
|
204
|
+
if (aborted) { stream.destroy(); return; }
|
|
205
|
+
buffer += chunk.toString('utf8');
|
|
206
|
+
const lines = buffer.split('\n');
|
|
207
|
+
buffer = lines.pop();
|
|
208
|
+
for (const line of lines) {
|
|
209
|
+
if (line.trim()) {
|
|
210
|
+
try {
|
|
211
|
+
const stats = JSON.parse(line);
|
|
212
|
+
const cpuDelta = (stats.cpu_stats?.cpu_usage?.total_usage || 0) - (stats.precpu_stats?.cpu_usage?.total_usage || 0);
|
|
213
|
+
const systemDelta = (stats.cpu_stats?.system_cpu_usage || 0) - (stats.precpu_stats?.system_cpu_usage || 0);
|
|
214
|
+
const numCpus = stats.cpu_stats?.online_cpus || 1;
|
|
215
|
+
callback({
|
|
216
|
+
cpuPercent: systemDelta > 0 ? (cpuDelta / systemDelta) * numCpus * 100 : 0,
|
|
217
|
+
memoryUsage: stats.memory_stats?.usage || 0,
|
|
218
|
+
memoryLimit: stats.memory_stats?.limit || 0,
|
|
219
|
+
});
|
|
220
|
+
} catch {}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
})
|
|
225
|
+
.catch((err) => { callback && callback(null, err); });
|
|
226
|
+
return () => { aborted = true; if (streamRef) { streamRef.destroy && streamRef.destroy(); } };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* List images
|
|
231
|
+
*/
|
|
232
|
+
async function listImages() {
|
|
233
|
+
const images = await docker.listImages();
|
|
234
|
+
return images.map(img => ({
|
|
235
|
+
id: img.Id,
|
|
236
|
+
tags: img.RepoTags || [],
|
|
237
|
+
size: img.Size,
|
|
238
|
+
created: img.Created,
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* List volumes
|
|
244
|
+
*/
|
|
245
|
+
async function listVolumes() {
|
|
246
|
+
const result = await docker.listVolumes();
|
|
247
|
+
return (result.Volumes || []).map(v => ({
|
|
248
|
+
name: v.Name,
|
|
249
|
+
driver: v.Driver,
|
|
250
|
+
mountpoint: v.Mountpoint,
|
|
251
|
+
createdAt: v.CreatedAt,
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Match a container to a TLC project by name or labels
|
|
257
|
+
* @param {Object} container - { name, labels }
|
|
258
|
+
* @param {Array} projects - [{ name, path }]
|
|
259
|
+
* @returns {string|null} matched project name or null
|
|
260
|
+
*/
|
|
261
|
+
function matchContainerToProject(container, projects) {
|
|
262
|
+
// Match by compose project label
|
|
263
|
+
const composeProject = container.labels && container.labels['com.docker.compose.project'];
|
|
264
|
+
if (composeProject) {
|
|
265
|
+
const match = projects.find(p => p.name.toLowerCase() === composeProject.toLowerCase());
|
|
266
|
+
if (match) return match.name;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Match by container name containing project name
|
|
270
|
+
const cName = (container.name || '').toLowerCase();
|
|
271
|
+
for (const project of projects) {
|
|
272
|
+
const pName = project.name.toLowerCase();
|
|
273
|
+
if (cName.includes(pName)) return project.name;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
isAvailable,
|
|
281
|
+
listContainers,
|
|
282
|
+
getContainer,
|
|
283
|
+
startContainer,
|
|
284
|
+
stopContainer,
|
|
285
|
+
restartContainer,
|
|
286
|
+
removeContainer,
|
|
287
|
+
getContainerStats,
|
|
288
|
+
getContainerLogs,
|
|
289
|
+
streamContainerLogs,
|
|
290
|
+
streamContainerStats,
|
|
291
|
+
listImages,
|
|
292
|
+
listVolumes,
|
|
293
|
+
matchContainerToProject,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = { createDockerClient };
|