@soyrageagency/docker-mcp 1.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/LICENSE +21 -0
- package/README.md +882 -0
- package/dist/ai/credentials.d.ts +72 -0
- package/dist/ai/credentials.js +157 -0
- package/dist/ai/credentials.js.map +1 -0
- package/dist/ai/login.d.ts +22 -0
- package/dist/ai/login.js +189 -0
- package/dist/ai/login.js.map +1 -0
- package/dist/ai/provider.d.ts +29 -0
- package/dist/ai/provider.js +113 -0
- package/dist/ai/provider.js.map +1 -0
- package/dist/assets.d.ts +20 -0
- package/dist/assets.js +56 -0
- package/dist/assets.js.map +1 -0
- package/dist/branding.d.ts +31 -0
- package/dist/branding.js +61 -0
- package/dist/branding.js.map +1 -0
- package/dist/config.d.ts +65 -0
- package/dist/config.js +128 -0
- package/dist/config.js.map +1 -0
- package/dist/docker/client.d.ts +66 -0
- package/dist/docker/client.js +161 -0
- package/dist/docker/client.js.map +1 -0
- package/dist/docker/compose.d.ts +74 -0
- package/dist/docker/compose.js +126 -0
- package/dist/docker/compose.js.map +1 -0
- package/dist/entry.d.ts +15 -0
- package/dist/entry.js +33 -0
- package/dist/entry.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +100 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +25 -0
- package/dist/logger.js +68 -0
- package/dist/logger.js.map +1 -0
- package/dist/panel/index.d.ts +16 -0
- package/dist/panel/index.js +53 -0
- package/dist/panel/index.js.map +1 -0
- package/dist/panel/public/app.js +1004 -0
- package/dist/panel/public/index.html +243 -0
- package/dist/panel/public/styles.css +428 -0
- package/dist/panel/server.d.ts +23 -0
- package/dist/panel/server.js +355 -0
- package/dist/panel/server.js.map +1 -0
- package/dist/panel/service.d.ts +327 -0
- package/dist/panel/service.js +1356 -0
- package/dist/panel/service.js.map +1 -0
- package/dist/plugins.d.ts +50 -0
- package/dist/plugins.js +121 -0
- package/dist/plugins.js.map +1 -0
- package/dist/ragedocker.d.ts +21 -0
- package/dist/ragedocker.js +164 -0
- package/dist/ragedocker.js.map +1 -0
- package/dist/tools/about.d.ts +15 -0
- package/dist/tools/about.js +55 -0
- package/dist/tools/about.js.map +1 -0
- package/dist/tools/compose.d.ts +19 -0
- package/dist/tools/compose.js +145 -0
- package/dist/tools/compose.js.map +1 -0
- package/dist/tools/containers.d.ts +13 -0
- package/dist/tools/containers.js +119 -0
- package/dist/tools/containers.js.map +1 -0
- package/dist/tools/context.d.ts +37 -0
- package/dist/tools/context.js +13 -0
- package/dist/tools/context.js.map +1 -0
- package/dist/tools/images.d.ts +11 -0
- package/dist/tools/images.js +38 -0
- package/dist/tools/images.js.map +1 -0
- package/dist/tools/index.d.ts +15 -0
- package/dist/tools/index.js +34 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/lifecycle.d.ts +16 -0
- package/dist/tools/lifecycle.js +165 -0
- package/dist/tools/lifecycle.js.map +1 -0
- package/dist/tools/logs.d.ts +15 -0
- package/dist/tools/logs.js +110 -0
- package/dist/tools/logs.js.map +1 -0
- package/dist/tools/system.d.ts +14 -0
- package/dist/tools/system.js +108 -0
- package/dist/tools/system.js.map +1 -0
- package/dist/tui/ansi.d.ts +66 -0
- package/dist/tui/ansi.js +118 -0
- package/dist/tui/ansi.js.map +1 -0
- package/dist/tui/app.d.ts +160 -0
- package/dist/tui/app.js +1339 -0
- package/dist/tui/app.js.map +1 -0
- package/dist/tui/box.d.ts +18 -0
- package/dist/tui/box.js +38 -0
- package/dist/tui/box.js.map +1 -0
- package/dist/tui/index.d.ts +19 -0
- package/dist/tui/index.js +66 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/update/channel.d.ts +82 -0
- package/dist/update/channel.js +178 -0
- package/dist/update/channel.js.map +1 -0
- package/dist/updates.json +42 -0
- package/dist/utils/format.d.ts +24 -0
- package/dist/utils/format.js +69 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/result.d.ts +35 -0
- package/dist/utils/result.js +31 -0
- package/dist/utils/result.js.map +1 -0
- package/package.json +90 -0
- package/scripts/install.mjs +132 -0
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Docker Panel — front-end logic (vanilla JS, no build step).
|
|
3
|
+
* Crafted by SoyRage Agency — https://soyrage.es/
|
|
4
|
+
* Attribution must remain intact (see LICENSE).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
const $ = (sel) => document.querySelector(sel);
|
|
10
|
+
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
|
11
|
+
|
|
12
|
+
/* ------------------------------------------------- icons (Lucide, inline) */
|
|
13
|
+
// Professional SVG icons embedded inline — no emojis, no external requests.
|
|
14
|
+
// Paths are from Lucide (https://lucide.dev, ISC license).
|
|
15
|
+
const ICONS = {
|
|
16
|
+
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
|
17
|
+
folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
|
|
18
|
+
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
|
|
19
|
+
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
|
20
|
+
camera: '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
|
|
21
|
+
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
|
|
22
|
+
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
|
|
23
|
+
restart: '<path d="M21 12a9 9 0 1 1-9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>',
|
|
24
|
+
refresh: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
|
|
25
|
+
up: '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
|
|
26
|
+
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
|
27
|
+
chevron: '<path d="m9 18 6-6-6-6"/>',
|
|
28
|
+
coffee: '<path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" x2="6" y1="2" y2="4"/><line x1="10" x2="10" y1="2" y2="4"/><line x1="14" x2="14" y1="2" y2="4"/>',
|
|
29
|
+
save: '<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/>',
|
|
30
|
+
sparkles: '<path d="M9.9 2.6 8.5 6.4 4.7 7.8l3.8 1.4 1.4 3.8 1.4-3.8 3.8-1.4-3.8-1.4z"/><path d="M18 7v4"/><path d="M20 9h-4"/><path d="M17 17v3"/><path d="M18.5 18.5h-3"/>',
|
|
31
|
+
ai: '<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>',
|
|
32
|
+
terminal: '<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>',
|
|
33
|
+
drive: '<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>',
|
|
34
|
+
layers: '<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
|
|
35
|
+
rotate: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
|
|
36
|
+
};
|
|
37
|
+
const FILLED = new Set(["play", "stop"]);
|
|
38
|
+
function icon(name) {
|
|
39
|
+
const g = ICONS[name];
|
|
40
|
+
if (!g) return "";
|
|
41
|
+
const attrs = FILLED.has(name)
|
|
42
|
+
? 'fill="currentColor" stroke="none"'
|
|
43
|
+
: 'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
|
|
44
|
+
return `<svg class="ico" viewBox="0 0 24 24" ${attrs} aria-hidden="true">${g}</svg>`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const state = {
|
|
48
|
+
meta: { demo: false, readOnly: false },
|
|
49
|
+
autoRestart: new Set(),
|
|
50
|
+
filter: "",
|
|
51
|
+
autoRefresh: true,
|
|
52
|
+
timer: null,
|
|
53
|
+
tab: "overview",
|
|
54
|
+
containers: [],
|
|
55
|
+
files: { name: "", path: "/" },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/* ---------------------------------------------------------------- helpers */
|
|
59
|
+
|
|
60
|
+
async function api(path, options) {
|
|
61
|
+
const res = await fetch(path, options);
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
const body = await res.json().catch(() => ({}));
|
|
64
|
+
throw new Error(body.error || `${res.status} ${res.statusText}`);
|
|
65
|
+
}
|
|
66
|
+
return res.json();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function bytes(n) {
|
|
70
|
+
if (!n) return "0 B";
|
|
71
|
+
const u = ["B", "KB", "MB", "GB", "TB"];
|
|
72
|
+
const e = Math.min(Math.floor(Math.log(n) / Math.log(1024)), u.length - 1);
|
|
73
|
+
const v = n / Math.pow(1024, e);
|
|
74
|
+
return `${v.toFixed(e === 0 ? 0 : v >= 100 ? 0 : 1)} ${u[e]}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function escapeHtml(s) {
|
|
78
|
+
return String(s).replace(/[&<>"']/g, (c) =>
|
|
79
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function timeAgo(iso) {
|
|
84
|
+
const d = (Date.now() - new Date(iso).getTime()) / 1000;
|
|
85
|
+
if (d < 60) return `${Math.round(d)}s ago`;
|
|
86
|
+
if (d < 3600) return `${Math.round(d / 60)}m ago`;
|
|
87
|
+
return `${Math.round(d / 3600)}h ago`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* ------------------------------------------------------------------- tabs */
|
|
91
|
+
|
|
92
|
+
function switchTab(tab) {
|
|
93
|
+
state.tab = tab;
|
|
94
|
+
$$(".tab").forEach((b) => b.classList.toggle("active", b.dataset.tab === tab));
|
|
95
|
+
$$(".tabpane").forEach((p) => p.classList.toggle("hidden", p.id !== `tab-${tab}`));
|
|
96
|
+
if (tab === "files") initFiles();
|
|
97
|
+
if (tab === "alerts") loadAlerts();
|
|
98
|
+
if (tab === "terminal") $("#term-input").focus();
|
|
99
|
+
if (tab === "backups") initBackups();
|
|
100
|
+
if (tab === "system") loadSystem();
|
|
101
|
+
if (tab === "compose") initCompose();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* --------------------------------------------------------------- compose */
|
|
105
|
+
|
|
106
|
+
let composeReady = false;
|
|
107
|
+
async function initCompose() {
|
|
108
|
+
const sel = $("#cmp-file");
|
|
109
|
+
if (!composeReady) {
|
|
110
|
+
composeReady = true;
|
|
111
|
+
$("#cmp-reload").addEventListener("click", loadComposeFile);
|
|
112
|
+
sel.addEventListener("change", loadComposeFile);
|
|
113
|
+
$("#cmp-save").addEventListener("click", saveCompose);
|
|
114
|
+
$("#cmp-up").addEventListener("click", () => composeAction("up"));
|
|
115
|
+
$("#cmp-restart").addEventListener("click", () => composeAction("restart"));
|
|
116
|
+
$("#cmp-down").addEventListener("click", () => composeAction("down"));
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const { files } = await api("/api/compose/list");
|
|
120
|
+
sel.innerHTML = files.length
|
|
121
|
+
? files.map((f) => `<option value="${escapeHtml(f)}">${escapeHtml(f)}</option>`).join("")
|
|
122
|
+
: '<option value="">No compose files found nearby</option>';
|
|
123
|
+
if (files.length) await loadComposeFile();
|
|
124
|
+
else { $("#cmp-editor").value = ""; $("#cmp-status").textContent = "No docker-compose.yml found near the working directory."; }
|
|
125
|
+
} catch (err) {
|
|
126
|
+
$("#cmp-status").textContent = err.message;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function loadComposeFile() {
|
|
131
|
+
const path = $("#cmp-file").value;
|
|
132
|
+
if (!path) return;
|
|
133
|
+
$("#cmp-status").textContent = "Loading…";
|
|
134
|
+
try {
|
|
135
|
+
const { content } = await api(`/api/compose?path=${encodeURIComponent(path)}`);
|
|
136
|
+
$("#cmp-editor").value = content;
|
|
137
|
+
$("#cmp-status").textContent = "";
|
|
138
|
+
} catch (err) {
|
|
139
|
+
$("#cmp-editor").value = "";
|
|
140
|
+
$("#cmp-status").textContent = err.message;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function saveCompose() {
|
|
145
|
+
const path = $("#cmp-file").value;
|
|
146
|
+
if (!path) return;
|
|
147
|
+
$("#cmp-status").textContent = "Saving…";
|
|
148
|
+
try {
|
|
149
|
+
const r = await api("/api/compose", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path, content: $("#cmp-editor").value }) });
|
|
150
|
+
$("#cmp-status").textContent = `Saved ${r.bytes} bytes.`;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
$("#cmp-status").textContent = `Save failed: ${err.message}`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function composeAction(action) {
|
|
157
|
+
const path = $("#cmp-file").value;
|
|
158
|
+
if (!path) return;
|
|
159
|
+
if (action === "down" && !confirm("compose down will stop and remove the stack's containers. Continue?")) return;
|
|
160
|
+
$("#cmp-status").textContent = `Running compose ${action}…`;
|
|
161
|
+
try {
|
|
162
|
+
const r = await api("/api/compose/action", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path, action }) });
|
|
163
|
+
$("#cmp-status").textContent = `compose ${action} → exit ${r.code}`;
|
|
164
|
+
await refresh();
|
|
165
|
+
} catch (err) {
|
|
166
|
+
$("#cmp-status").textContent = `compose ${action} failed: ${err.message}`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* --------------------------------------------------------------- overview */
|
|
171
|
+
|
|
172
|
+
function stateClass(s) {
|
|
173
|
+
s = (s || "").toLowerCase();
|
|
174
|
+
if (s.includes("run")) return "running";
|
|
175
|
+
if (s.includes("exit") || s.includes("dead")) return "exited";
|
|
176
|
+
if (s.includes("pause")) return "paused";
|
|
177
|
+
return "created";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function usageCell(level, label, pct) {
|
|
181
|
+
const tone = level >= 80 ? "hot" : level >= 50 ? "warm" : "cool";
|
|
182
|
+
return `<div class="usage"><span class="mono">${escapeHtml(label)}</span>
|
|
183
|
+
<div class="meter ${tone}"><span style="width:${Math.min(100, pct).toFixed(1)}%"></span></div></div>`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Render an inline SVG area sparkline (values are 0–100). */
|
|
187
|
+
function renderChart(id, values, color) {
|
|
188
|
+
const el = $("#" + id);
|
|
189
|
+
if (!el) return;
|
|
190
|
+
const W = 320, H = 64, pad = 5;
|
|
191
|
+
if (!values.length) { el.innerHTML = '<div class="muted" style="padding:8px">collecting…</div>'; return; }
|
|
192
|
+
const step = values.length > 1 ? (W - 2 * pad) / (values.length - 1) : 0;
|
|
193
|
+
const y = (v) => H - pad - (Math.max(0, Math.min(100, v)) / 100) * (H - 2 * pad);
|
|
194
|
+
const pts = values.map((v, i) => [pad + i * step, y(v)]);
|
|
195
|
+
const line = pts.map((p, i) => (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(" ");
|
|
196
|
+
const area = `${line} L ${(pad + (values.length - 1) * step).toFixed(1)} ${H - pad} L ${pad} ${H - pad} Z`;
|
|
197
|
+
el.innerHTML =
|
|
198
|
+
`<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" class="spark">
|
|
199
|
+
<defs><linearGradient id="g-${id}" x1="0" y1="0" x2="0" y2="1">
|
|
200
|
+
<stop offset="0" stop-color="${color}" stop-opacity="0.35"/>
|
|
201
|
+
<stop offset="1" stop-color="${color}" stop-opacity="0"/></linearGradient></defs>
|
|
202
|
+
<path d="${area}" fill="url(#g-${id})"/>
|
|
203
|
+
<path d="${line}" fill="none" stroke="${color}" stroke-width="1.5"/>
|
|
204
|
+
</svg>`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function loadHistory() {
|
|
208
|
+
try {
|
|
209
|
+
const { points } = await api("/api/history");
|
|
210
|
+
renderChart("chart-cpu", points.map((p) => p.cpu), "#3b9ef0");
|
|
211
|
+
renderChart("chart-mem", points.map((p) => p.mem), "#3ad07f");
|
|
212
|
+
const last = points[points.length - 1] || {};
|
|
213
|
+
$("#cpu-now").textContent = `${(last.cpu ?? 0).toFixed(1)} %`;
|
|
214
|
+
$("#mem-now").textContent = bytes(last.memBytes || 0);
|
|
215
|
+
} catch { /* best effort */ }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function renderStats(snap) {
|
|
219
|
+
const sys = snap.system;
|
|
220
|
+
const memPct = sys.memoryBytes ? (snap.memoryUsed / sys.memoryBytes) * 100 : 0;
|
|
221
|
+
const cpuPct = sys.cpus ? Math.min(100, snap.cpuTotal / sys.cpus) : snap.cpuTotal;
|
|
222
|
+
const cards = [
|
|
223
|
+
{ k: "Running", v: `${sys.containersRunning}<small> / ${sys.containersTotal}</small>` },
|
|
224
|
+
{ k: "Images", v: `${sys.images}` },
|
|
225
|
+
{ k: "CPU load", v: `${cpuPct.toFixed(1)}<small> %</small>`, bar: cpuPct },
|
|
226
|
+
{ k: "Memory used", v: `${bytes(snap.memoryUsed)}`, sub: `of ${bytes(sys.memoryBytes)}`, bar: memPct },
|
|
227
|
+
{ k: "vCPUs", v: `${sys.cpus}` },
|
|
228
|
+
];
|
|
229
|
+
$("#stats").innerHTML = cards
|
|
230
|
+
.map((c) => {
|
|
231
|
+
const bar = c.bar !== undefined
|
|
232
|
+
? `<div class="meter"><span style="width:${Math.min(100, c.bar).toFixed(1)}%"></span></div>`
|
|
233
|
+
: "";
|
|
234
|
+
const sub = c.sub ? `<div class="sub">${c.sub}</div>` : "";
|
|
235
|
+
return `<div class="stat"><div class="k">${c.k}</div><div class="v">${c.v}</div>${sub}${bar}</div>`;
|
|
236
|
+
})
|
|
237
|
+
.join("");
|
|
238
|
+
$("#engine").textContent = `${escapeHtml(sys.os)} · Docker ${escapeHtml(sys.engine)}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function renderContainers(list) {
|
|
242
|
+
const disabled = state.meta.readOnly ? "disabled" : "";
|
|
243
|
+
const filter = state.filter.toLowerCase();
|
|
244
|
+
const rows = list.filter((c) => !filter || c.name.toLowerCase().includes(filter) || c.image.toLowerCase().includes(filter));
|
|
245
|
+
|
|
246
|
+
$("#containers").innerHTML = rows
|
|
247
|
+
.map((c) => {
|
|
248
|
+
const cls = stateClass(c.state);
|
|
249
|
+
const ports = c.ports.length
|
|
250
|
+
? `<div class="chips">${c.ports.map((p) => `<span class="chip">${escapeHtml(p)}</span>`).join("")}</div>`
|
|
251
|
+
: '<span class="muted">—</span>';
|
|
252
|
+
const running = c.state === "running";
|
|
253
|
+
const cpu = running ? usageCell(Math.min(100, c.cpu ?? 0), `${(c.cpu ?? 0).toFixed(1)}%`, Math.min(100, c.cpu ?? 0)) : '<span class="muted">—</span>';
|
|
254
|
+
const memPct = c.memoryLimit ? ((c.memory ?? 0) / c.memoryLimit) * 100 : 0;
|
|
255
|
+
const mem = running ? usageCell(memPct, bytes(c.memory ?? 0), memPct) : '<span class="muted">—</span>';
|
|
256
|
+
const on = state.autoRestart.has(c.name);
|
|
257
|
+
return `
|
|
258
|
+
<tr>
|
|
259
|
+
<td><span class="dot ${cls}">${escapeHtml(c.state)}</span></td>
|
|
260
|
+
<td class="name clickable" data-logs="${escapeHtml(c.name)}" title="View logs">${escapeHtml(c.name)}</td>
|
|
261
|
+
<td class="mono">${escapeHtml(c.image)}</td>
|
|
262
|
+
<td>${cpu}</td>
|
|
263
|
+
<td>${mem}</td>
|
|
264
|
+
<td>${ports}</td>
|
|
265
|
+
<td class="center">
|
|
266
|
+
<button class="toggle ${on ? "on" : ""}" data-auto="${escapeHtml(c.name)}" title="Auto-restart">${icon("restart")}</button>
|
|
267
|
+
</td>
|
|
268
|
+
<td>
|
|
269
|
+
<div class="actions">
|
|
270
|
+
<button class="act" title="Details" data-details="${escapeHtml(c.name)}">${icon("info")}</button>
|
|
271
|
+
<button class="act" title="Files" data-files="${escapeHtml(c.name)}">${icon("folder")}</button>
|
|
272
|
+
<button class="act" title="Shell (exec)" data-exec="${escapeHtml(c.name)}">${icon("terminal")}</button>
|
|
273
|
+
<button class="act" title="Attach volume" data-vol="${escapeHtml(c.name)}" ${disabled}>${icon("drive")}</button>
|
|
274
|
+
<button class="act" title="Snapshot" data-snap="${escapeHtml(c.name)}">${icon("camera")}</button>
|
|
275
|
+
<button class="act" title="Start" data-act="start" data-name="${escapeHtml(c.name)}" ${disabled}>${icon("play")}</button>
|
|
276
|
+
<button class="act" title="Stop" data-act="stop" data-name="${escapeHtml(c.name)}" ${disabled}>${icon("stop")}</button>
|
|
277
|
+
<button class="act" title="Restart" data-act="restart" data-name="${escapeHtml(c.name)}" ${disabled}>${icon("restart")}</button>
|
|
278
|
+
</div>
|
|
279
|
+
</td>
|
|
280
|
+
</tr>`;
|
|
281
|
+
})
|
|
282
|
+
.join("") || `<tr><td colspan="8" class="muted">No containers match “${escapeHtml(state.filter)}”.</td></tr>`;
|
|
283
|
+
|
|
284
|
+
$("#containers").querySelectorAll("[data-act]").forEach((b) => b.addEventListener("click", () => act(b.dataset.name, b.dataset.act)));
|
|
285
|
+
$("#containers").querySelectorAll("[data-logs]").forEach((c) => c.addEventListener("click", () => showLogs(c.dataset.logs)));
|
|
286
|
+
$("#containers").querySelectorAll("[data-files]").forEach((b) => b.addEventListener("click", () => openFiles(b.dataset.files)));
|
|
287
|
+
$("#containers").querySelectorAll("[data-auto]").forEach((b) => b.addEventListener("click", () => toggleAuto(b.dataset.auto)));
|
|
288
|
+
$("#containers").querySelectorAll("[data-details]").forEach((b) => b.addEventListener("click", () => showDetails(b.dataset.details)));
|
|
289
|
+
$("#containers").querySelectorAll("[data-snap]").forEach((b) => b.addEventListener("click", () => quickSnapshot(b.dataset.snap)));
|
|
290
|
+
$("#containers").querySelectorAll("[data-exec]").forEach((b) => b.addEventListener("click", () => openExec(b.dataset.exec)));
|
|
291
|
+
$("#containers").querySelectorAll("[data-vol]").forEach((b) => b.addEventListener("click", () => openVolume(b.dataset.vol)));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* ------------------------------------------------- exec console (drawer) */
|
|
295
|
+
|
|
296
|
+
function openExec(name) {
|
|
297
|
+
openDrawer(`Shell · ${name}`, "");
|
|
298
|
+
const ro = state.meta.readOnly;
|
|
299
|
+
$("#drawer-body").innerHTML = `
|
|
300
|
+
<div class="exec-wrap">
|
|
301
|
+
<div class="muted" style="margin-bottom:8px">Runs one command via <span class="mono">docker exec ${escapeHtml(name)} sh -lc …</span>${ro ? ' — <span class="badge badge-ro">READ-ONLY</span>' : ""}</div>
|
|
302
|
+
<div class="term-out" id="exec-out" style="min-height:220px"></div>
|
|
303
|
+
<div class="term-input-wrap">
|
|
304
|
+
<span class="prompt">${icon("chevron")}</span>
|
|
305
|
+
<input id="exec-input" class="term-input" autocomplete="off" spellcheck="false" placeholder="e.g. ls -la / · ps aux · cat /etc/os-release" ${ro ? "disabled" : ""} />
|
|
306
|
+
</div>
|
|
307
|
+
</div>`;
|
|
308
|
+
const out = $("#exec-out");
|
|
309
|
+
const input = $("#exec-input");
|
|
310
|
+
const run = async () => {
|
|
311
|
+
const command = input.value.trim();
|
|
312
|
+
if (!command) return;
|
|
313
|
+
input.value = "";
|
|
314
|
+
out.insertAdjacentHTML("beforeend", `<div class="mono" style="color:var(--accent)">❯ ${escapeHtml(command)}</div>`);
|
|
315
|
+
try {
|
|
316
|
+
const r = await api("/api/exec", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, command }) });
|
|
317
|
+
out.insertAdjacentHTML("beforeend", `<pre class="logs" style="margin:2px 0 10px">${escapeHtml(r.output)}</pre>`);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
out.insertAdjacentHTML("beforeend", `<div class="mono" style="color:var(--red)">${escapeHtml(err.message)}</div>`);
|
|
320
|
+
}
|
|
321
|
+
out.scrollTop = out.scrollHeight;
|
|
322
|
+
};
|
|
323
|
+
if (input) { input.addEventListener("keydown", (e) => { if (e.key === "Enter") run(); }); setTimeout(() => input.focus(), 40); }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/* --------------------------------------------- attach volume (drawer) */
|
|
327
|
+
|
|
328
|
+
function openVolume(name) {
|
|
329
|
+
openDrawer(`Attach volume · ${name}`, "");
|
|
330
|
+
$("#drawer-body").innerHTML = `
|
|
331
|
+
<div class="form-col">
|
|
332
|
+
<p class="muted">Recreates <b>${escapeHtml(name)}</b> from its own image with an extra bind mount, keeping env, ports, network and restart policy. The previous container is stopped and kept as <span class="mono">${escapeHtml(name)}-preattach-…</span> so it's reversible.</p>
|
|
333
|
+
<label>Host path <input id="vol-host" class="input" placeholder="/srv/data" /></label>
|
|
334
|
+
<label>Container path <input id="vol-cont" class="input" placeholder="/data" /></label>
|
|
335
|
+
<label class="chk"><input type="checkbox" id="vol-ro" /> Read-only (:ro)</label>
|
|
336
|
+
<div class="editor-actions"><button id="vol-go" class="primary">${icon("drive")} Recreate with volume</button></div>
|
|
337
|
+
<div id="vol-status" class="ed-status muted"></div>
|
|
338
|
+
</div>`;
|
|
339
|
+
$("#vol-go").addEventListener("click", async () => {
|
|
340
|
+
const hostPath = $("#vol-host").value.trim();
|
|
341
|
+
const containerPath = $("#vol-cont").value.trim();
|
|
342
|
+
const readOnly = $("#vol-ro").checked;
|
|
343
|
+
if (!hostPath || !containerPath) { $("#vol-status").textContent = "Both paths are required."; return; }
|
|
344
|
+
if (!confirm(`Recreate ${name} with ${hostPath}:${containerPath}${readOnly ? " (ro)" : ""}? The container will be restarted.`)) return;
|
|
345
|
+
$("#vol-go").disabled = true; $("#vol-status").textContent = "Recreating…";
|
|
346
|
+
try {
|
|
347
|
+
const r = await api("/api/volume/attach", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, hostPath, containerPath, readOnly }) });
|
|
348
|
+
$("#vol-status").textContent = r.message;
|
|
349
|
+
await refresh();
|
|
350
|
+
} catch (err) {
|
|
351
|
+
$("#vol-status").textContent = `Failed: ${err.message}`;
|
|
352
|
+
} finally { $("#vol-go").disabled = false; }
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function renderImages(list) {
|
|
357
|
+
$("#images").innerHTML = list
|
|
358
|
+
.map((i) => `<tr><td class="name">${escapeHtml(i.tag)}</td><td class="mono">${escapeHtml(i.id)}</td><td class="right mono">${bytes(i.sizeBytes)}</td></tr>`)
|
|
359
|
+
.join("");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function act(name, action) {
|
|
363
|
+
try {
|
|
364
|
+
await api("/api/action", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, action }) });
|
|
365
|
+
await refresh();
|
|
366
|
+
} catch (err) {
|
|
367
|
+
alert(`Action failed: ${err.message}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function toggleAuto(name) {
|
|
372
|
+
const enabled = !state.autoRestart.has(name);
|
|
373
|
+
try {
|
|
374
|
+
const r = await api("/api/autorestart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, enabled }) });
|
|
375
|
+
state.autoRestart = new Set(r.enabled);
|
|
376
|
+
renderContainers(state.containers);
|
|
377
|
+
} catch (err) {
|
|
378
|
+
alert(`Could not toggle auto-restart: ${err.message}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* ------------------------------------------------------------------ files */
|
|
383
|
+
|
|
384
|
+
function initFiles() {
|
|
385
|
+
const sel = $("#fs-container");
|
|
386
|
+
const names = state.containers.map((c) => c.name);
|
|
387
|
+
sel.innerHTML = names.map((n) => `<option value="${escapeHtml(n)}">${escapeHtml(n)}</option>`).join("");
|
|
388
|
+
if (!state.files.name || !names.includes(state.files.name)) {
|
|
389
|
+
state.files = { name: names[0] || "", path: "/" };
|
|
390
|
+
}
|
|
391
|
+
sel.value = state.files.name;
|
|
392
|
+
loadDir();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function openFiles(name) {
|
|
396
|
+
state.files = { name, path: "/" };
|
|
397
|
+
switchTab("files");
|
|
398
|
+
$("#fs-container").value = name;
|
|
399
|
+
loadDir();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function renderCrumbs() {
|
|
403
|
+
const parts = state.files.path.split("/").filter(Boolean);
|
|
404
|
+
const crumbs = ['<span class="crumb" data-path="/">/ root</span>'];
|
|
405
|
+
let acc = "";
|
|
406
|
+
for (const p of parts) {
|
|
407
|
+
acc += "/" + p;
|
|
408
|
+
crumbs.push(`<span class="sep">/</span><span class="crumb" data-path="${escapeHtml(acc)}">${escapeHtml(p)}</span>`);
|
|
409
|
+
}
|
|
410
|
+
const el = $("#fs-crumbs");
|
|
411
|
+
el.innerHTML = crumbs.join("");
|
|
412
|
+
el.querySelectorAll("[data-path]").forEach((c) => c.addEventListener("click", () => { state.files.path = c.dataset.path; loadDir(); }));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function loadDir() {
|
|
416
|
+
const { name, path } = state.files;
|
|
417
|
+
if (!name) { $("#fs-list").innerHTML = '<tr><td colspan="3" class="muted">No container selected.</td></tr>'; return; }
|
|
418
|
+
renderCrumbs();
|
|
419
|
+
$("#fs-list").innerHTML = '<tr><td colspan="3" class="muted">Loading…</td></tr>';
|
|
420
|
+
try {
|
|
421
|
+
const { entries } = await api(`/api/files?name=${encodeURIComponent(name)}&path=${encodeURIComponent(path)}`);
|
|
422
|
+
$("#fs-list").innerHTML = entries
|
|
423
|
+
.map((e) => {
|
|
424
|
+
const ic = e.type === "dir" ? icon("folder") : e.type === "link" ? icon("link") : icon("file");
|
|
425
|
+
const nm = e.type === "link" && e.target ? `${escapeHtml(e.name)} <span class="muted">→ ${escapeHtml(e.target)}</span>` : escapeHtml(e.name);
|
|
426
|
+
const cls = e.type === "dir" ? "clickable dir" : e.type === "file" ? "clickable file" : "";
|
|
427
|
+
return `<tr><td class="fic">${ic}</td><td class="name ${cls}" data-type="${e.type}" data-name="${escapeHtml(e.name)}">${nm}</td><td class="right mono">${e.type === "file" ? bytes(e.size) : "—"}</td></tr>`;
|
|
428
|
+
})
|
|
429
|
+
.join("") || '<tr><td colspan="3" class="muted">(empty)</td></tr>';
|
|
430
|
+
|
|
431
|
+
$("#fs-list").querySelectorAll("[data-type]").forEach((el) => {
|
|
432
|
+
el.addEventListener("click", () => {
|
|
433
|
+
const child = (state.files.path === "/" ? "" : state.files.path) + "/" + el.dataset.name;
|
|
434
|
+
if (el.dataset.type === "dir") { state.files.path = child; loadDir(); }
|
|
435
|
+
else if (el.dataset.type === "file") openEditor(name, child);
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
} catch (err) {
|
|
439
|
+
$("#fs-list").innerHTML = `<tr><td colspan="3" class="muted">${escapeHtml(err.message)}</td></tr>`;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/* ----------------------------------------------------------------- alerts */
|
|
444
|
+
|
|
445
|
+
async function loadAlerts() {
|
|
446
|
+
const box = $("#alerts-list");
|
|
447
|
+
box.innerHTML = '<div class="muted pad">Loading…</div>';
|
|
448
|
+
try {
|
|
449
|
+
const { alerts } = await api("/api/alerts");
|
|
450
|
+
updateAlertBadge(alerts);
|
|
451
|
+
if (alerts.length === 0) { box.innerHTML = '<div class="ok pad">✓ All clear — no active alerts.</div>'; return; }
|
|
452
|
+
box.innerHTML = alerts
|
|
453
|
+
.map((a) => `<div class="alert ${a.level}">
|
|
454
|
+
<span class="lvl">${a.level}</span>
|
|
455
|
+
<span class="src mono">${escapeHtml(a.source)}</span>
|
|
456
|
+
<span class="msg">${escapeHtml(a.message)}</span>
|
|
457
|
+
<span class="when muted">${timeAgo(a.time)}</span>
|
|
458
|
+
</div>`)
|
|
459
|
+
.join("");
|
|
460
|
+
} catch (err) {
|
|
461
|
+
box.innerHTML = `<div class="muted pad">Could not load alerts: ${escapeHtml(err.message)}</div>`;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function updateAlertBadge(alerts) {
|
|
466
|
+
const crit = alerts.filter((a) => a.level === "critical" || a.level === "warning").length;
|
|
467
|
+
const badge = $("#alert-count");
|
|
468
|
+
badge.textContent = crit;
|
|
469
|
+
badge.classList.toggle("hidden", crit === 0);
|
|
470
|
+
badge.classList.toggle("crit", alerts.some((a) => a.level === "critical"));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/* ----------------------------------------------------------------- drawer */
|
|
474
|
+
|
|
475
|
+
function drawerPre(text) {
|
|
476
|
+
$("#drawer-body").innerHTML = `<pre class="logs">${escapeHtml(text)}</pre>`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function showLogs(name) {
|
|
480
|
+
openDrawer(`Logs · ${name}`, "Loading…");
|
|
481
|
+
try {
|
|
482
|
+
const { logs } = await api(`/api/logs?name=${encodeURIComponent(name)}`);
|
|
483
|
+
drawerPre(logs || "(no output)");
|
|
484
|
+
} catch (err) {
|
|
485
|
+
drawerPre(`Error: ${err.message}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/* ------------------------------------------------------------- file editor */
|
|
490
|
+
|
|
491
|
+
async function openEditor(name, path) {
|
|
492
|
+
openDrawer(`Edit · ${name} : ${path}`, "Loading…");
|
|
493
|
+
const ro = state.meta.readOnly;
|
|
494
|
+
const aiBtn = state.meta.ai && !ro ? `<button id="ed-ai" class="ghost">${icon("sparkles")} AI edit</button>` : "";
|
|
495
|
+
const saveBtn = ro ? `<span class="badge badge-ro">READ-ONLY</span>` : `<button id="ed-save" class="primary">${icon("save")} Save</button>`;
|
|
496
|
+
$("#drawer-body").innerHTML = `
|
|
497
|
+
<div class="editor-bar">
|
|
498
|
+
<span class="mono muted">${escapeHtml(path)}</span>
|
|
499
|
+
<div class="editor-actions">${aiBtn}${saveBtn}</div>
|
|
500
|
+
</div>
|
|
501
|
+
<textarea id="editor" class="editor" spellcheck="false" ${ro ? "readonly" : ""}></textarea>
|
|
502
|
+
<div id="ed-status" class="ed-status muted"></div>`;
|
|
503
|
+
const ta = $("#editor");
|
|
504
|
+
ta.value = "Loading…";
|
|
505
|
+
try {
|
|
506
|
+
const { content, truncated } = await api(`/api/file?name=${encodeURIComponent(name)}&path=${encodeURIComponent(path)}`);
|
|
507
|
+
ta.value = content || "";
|
|
508
|
+
if (truncated) $("#ed-status").textContent = "File truncated to 128 KB for viewing.";
|
|
509
|
+
} catch (err) {
|
|
510
|
+
ta.value = `Error: ${err.message}`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const save = $("#ed-save");
|
|
514
|
+
if (save) save.addEventListener("click", async () => {
|
|
515
|
+
save.disabled = true; $("#ed-status").textContent = "Saving…";
|
|
516
|
+
try {
|
|
517
|
+
const r = await api("/api/file", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, path, content: ta.value }) });
|
|
518
|
+
$("#ed-status").textContent = `Saved ${r.bytes} bytes to ${r.path}.`;
|
|
519
|
+
} catch (err) {
|
|
520
|
+
$("#ed-status").textContent = `Save failed: ${err.message}`;
|
|
521
|
+
} finally { save.disabled = false; }
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
const ai = $("#ed-ai");
|
|
525
|
+
if (ai) ai.addEventListener("click", async () => {
|
|
526
|
+
const instruction = prompt("Describe the change for the AI copilot:");
|
|
527
|
+
if (!instruction) return;
|
|
528
|
+
ai.disabled = true; $("#ed-status").textContent = "AI is editing…";
|
|
529
|
+
try {
|
|
530
|
+
const r = await api("/api/ai", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: "edit", prompt: instruction, context: ta.value }) });
|
|
531
|
+
if (r.content != null) { ta.value = r.content; $("#ed-status").textContent = `AI applied your change (${r.source}). Review, then Save.`; }
|
|
532
|
+
else $("#ed-status").textContent = r.text || "AI returned no content.";
|
|
533
|
+
} catch (err) {
|
|
534
|
+
$("#ed-status").textContent = `AI failed: ${err.message}`;
|
|
535
|
+
} finally { ai.disabled = false; }
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function openDrawer(title, body) {
|
|
540
|
+
$("#drawer-title").textContent = title;
|
|
541
|
+
drawerPre(body || "");
|
|
542
|
+
$("#drawer").classList.remove("hidden");
|
|
543
|
+
$("#scrim").classList.remove("hidden");
|
|
544
|
+
document.body.classList.add("locked"); // lock background scroll (bugfix)
|
|
545
|
+
$("#drawer-body").scrollTop = 0;
|
|
546
|
+
}
|
|
547
|
+
function closeDrawer() {
|
|
548
|
+
$("#drawer").classList.add("hidden");
|
|
549
|
+
$("#scrim").classList.add("hidden");
|
|
550
|
+
document.body.classList.remove("locked");
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/* --------------------------------------------------------------- details */
|
|
554
|
+
|
|
555
|
+
async function showDetails(name) {
|
|
556
|
+
openDrawer(`Details · ${name}`, "Loading…");
|
|
557
|
+
try {
|
|
558
|
+
const d = await api(`/api/inspect?name=${encodeURIComponent(name)}`);
|
|
559
|
+
const line = (k, v) => `${k.padEnd(14)}${v}`;
|
|
560
|
+
const body = [
|
|
561
|
+
line("Name", d.name),
|
|
562
|
+
line("Image", d.image),
|
|
563
|
+
line("ID", d.id),
|
|
564
|
+
line("Created", d.created),
|
|
565
|
+
line("State", `${d.state} (health: ${d.health})`),
|
|
566
|
+
line("Restart", d.restartPolicy),
|
|
567
|
+
line("Command", d.command || "—"),
|
|
568
|
+
line("Ports", d.ports.join(", ")),
|
|
569
|
+
"",
|
|
570
|
+
"Networks:",
|
|
571
|
+
...d.networks.map((n) => " • " + n),
|
|
572
|
+
"",
|
|
573
|
+
"Mounts:",
|
|
574
|
+
...(d.mounts.length ? d.mounts.map((m) => " • " + m) : [" —"]),
|
|
575
|
+
"",
|
|
576
|
+
"Environment:",
|
|
577
|
+
...(d.env.length ? d.env.map((e) => " " + e) : [" —"]),
|
|
578
|
+
].join("\n");
|
|
579
|
+
drawerPre(body);
|
|
580
|
+
} catch (err) {
|
|
581
|
+
drawerPre(`Error: ${err.message}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/* --------------------------------------------------------------- terminal */
|
|
586
|
+
|
|
587
|
+
const CATALOG = [
|
|
588
|
+
{ t: "docker ps -a", d: "List all containers (running + stopped)" },
|
|
589
|
+
{ t: "docker ps", d: "List running containers" },
|
|
590
|
+
{ t: "docker images", d: "List local images with sizes" },
|
|
591
|
+
{ t: "docker stats", d: "Live CPU / memory usage" },
|
|
592
|
+
{ t: "docker logs <c>", d: "Tail a container's logs" },
|
|
593
|
+
{ t: "docker logs -f <c>", d: "Follow logs live" },
|
|
594
|
+
{ t: "docker inspect <c>", d: "Full low-level config" },
|
|
595
|
+
{ t: "docker restart <c>", d: "Restart a container" },
|
|
596
|
+
{ t: "docker stop <c>", d: "Gracefully stop a container" },
|
|
597
|
+
{ t: "docker start <c>", d: "Start a stopped container" },
|
|
598
|
+
{ t: "docker exec <c> sh -lc 'ls -la /app'", d: "Run a command inside a container" },
|
|
599
|
+
{ t: "docker top <c>", d: "Processes running in a container" },
|
|
600
|
+
{ t: "docker network ls", d: "List networks" },
|
|
601
|
+
{ t: "docker volume ls", d: "List volumes" },
|
|
602
|
+
{ t: "docker system df", d: "Disk usage summary" },
|
|
603
|
+
{ t: "docker info", d: "Daemon-wide information" },
|
|
604
|
+
{ t: "docker version", d: "Client & server versions" },
|
|
605
|
+
{ t: "docker compose ps", d: "List a Compose stack's services" },
|
|
606
|
+
{ t: "docker compose up -d", d: "Deploy/refresh a stack in the background" },
|
|
607
|
+
{ t: "docker compose logs -f", d: "Follow a stack's logs" },
|
|
608
|
+
{ t: "docker pull <image>", d: "Pull the latest image" },
|
|
609
|
+
];
|
|
610
|
+
|
|
611
|
+
const term = { sugg: [], sel: 0, open: false };
|
|
612
|
+
|
|
613
|
+
/** Levenshtein edit distance (small strings) — used for typo tolerance. */
|
|
614
|
+
function lev(a, b) {
|
|
615
|
+
const m = a.length, n = b.length;
|
|
616
|
+
if (!m) return n; if (!n) return m;
|
|
617
|
+
const d = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
|
|
618
|
+
for (let j = 0; j <= n; j++) d[0][j] = j;
|
|
619
|
+
for (let i = 1; i <= m; i++)
|
|
620
|
+
for (let j = 1; j <= n; j++)
|
|
621
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
622
|
+
return d[m][n];
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Build ranked command suggestions. Tolerant of typos in the `docker` keyword
|
|
627
|
+
* (e.g. "dokcer", "dcoker" → docker) and of omitting it entirely (typing just
|
|
628
|
+
* "ps" or "logs web" still suggests the full command), so novices never get
|
|
629
|
+
* the prompt wrong.
|
|
630
|
+
*/
|
|
631
|
+
function buildSuggestions(input) {
|
|
632
|
+
const names = state.containers.map((c) => c.name);
|
|
633
|
+
const expanded = [];
|
|
634
|
+
for (const e of CATALOG) {
|
|
635
|
+
if (e.t.includes("<c>")) {
|
|
636
|
+
const pool = names.length ? names : ["<container>"];
|
|
637
|
+
for (const n of pool) expanded.push({ text: e.t.replace("<c>", n), desc: e.d });
|
|
638
|
+
} else {
|
|
639
|
+
expanded.push({ text: e.t, desc: e.d });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const q = input.trim().toLowerCase();
|
|
644
|
+
if (!q) return expanded.slice(0, 8);
|
|
645
|
+
|
|
646
|
+
const tokens = q.split(/\s+/);
|
|
647
|
+
const first = tokens[0];
|
|
648
|
+
const rest = tokens.slice(1).join(" ");
|
|
649
|
+
// Is the first token "docker" (or a near-miss typo of it)?
|
|
650
|
+
const dockerish = first === "docker" || "docker".startsWith(first) || lev(first, "docker") <= 2;
|
|
651
|
+
|
|
652
|
+
const scored = expanded
|
|
653
|
+
.map((s) => {
|
|
654
|
+
const text = s.text.toLowerCase();
|
|
655
|
+
const afterDocker = text.slice(7); // drop "docker "
|
|
656
|
+
let score = 0;
|
|
657
|
+
if (text.startsWith(q)) score = 6;
|
|
658
|
+
else if (text.includes(q)) score = 4;
|
|
659
|
+
else if (dockerish && (rest === "" || afterDocker.includes(rest))) score = 3; // typo'd/partial docker
|
|
660
|
+
else if (afterDocker.startsWith(first)) score = 2; // typed a subcommand without "docker"
|
|
661
|
+
else if (afterDocker.includes(first)) score = 1;
|
|
662
|
+
return { ...s, score };
|
|
663
|
+
})
|
|
664
|
+
.filter((s) => s.score > 0)
|
|
665
|
+
.sort((a, b) => b.score - a.score || a.text.length - b.text.length);
|
|
666
|
+
|
|
667
|
+
const list = scored.slice(0, 8);
|
|
668
|
+
// When AI is available and the input isn't a strong docker match, offer to
|
|
669
|
+
// ask the AI copilot (natural language → command).
|
|
670
|
+
if (state.meta.ai && q.length >= 3) {
|
|
671
|
+
const strong = list[0] && list[0].score >= 4;
|
|
672
|
+
if (!strong) list.unshift({ ai: true, text: `Ask AI: ${input.trim()}`, raw: input.trim(), desc: "AI copilot → docker command" });
|
|
673
|
+
}
|
|
674
|
+
return list.slice(0, 8);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function renderSuggestions() {
|
|
678
|
+
const box = $("#term-sugg");
|
|
679
|
+
if (!term.open || term.sugg.length === 0) { box.classList.add("hidden"); return; }
|
|
680
|
+
box.classList.remove("hidden");
|
|
681
|
+
box.innerHTML = term.sugg
|
|
682
|
+
.map((s, i) => `<div class="sugg ${i === term.sel ? "sel" : ""} ${s.ai ? "ai" : ""}" data-i="${i}">
|
|
683
|
+
<span class="sugg-cmd mono">${s.ai ? icon("sparkles") + " " : ""}${escapeHtml(s.text)}</span><span class="sugg-desc">${escapeHtml(s.desc)}</span></div>`)
|
|
684
|
+
.join("");
|
|
685
|
+
box.querySelectorAll(".sugg").forEach((el) => el.addEventListener("mousedown", (ev) => { ev.preventDefault(); acceptSugg(Number(el.dataset.i), true); }));
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function acceptSugg(i, run) {
|
|
689
|
+
const s = term.sugg[i];
|
|
690
|
+
if (!s) return;
|
|
691
|
+
if (s.ai) { term.open = false; renderSuggestions(); askAI(s.raw); return; }
|
|
692
|
+
$("#term-input").value = s.text;
|
|
693
|
+
term.open = false; renderSuggestions();
|
|
694
|
+
if (run) runTerm();
|
|
695
|
+
$("#term-input").focus();
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Ask the AI copilot for a command; propose it (user reviews, then Enter). */
|
|
699
|
+
async function askAI(text) {
|
|
700
|
+
const out = $("#term-out");
|
|
701
|
+
out.insertAdjacentHTML("beforeend", `<div class="to-cmd"><span class="prompt">${icon("sparkles")}</span> ${escapeHtml("Ask AI: " + text)}</div>`);
|
|
702
|
+
try {
|
|
703
|
+
const r = await api("/api/ai", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: "command", prompt: text }) });
|
|
704
|
+
if (r.command) {
|
|
705
|
+
out.insertAdjacentHTML("beforeend", `<div class="to-out"><b>${escapeHtml(r.command)}</b>${r.explanation ? " — " + escapeHtml(r.explanation) : ""}<span class="muted"> (review, then Enter to run)</span></div>`);
|
|
706
|
+
const inp = $("#term-input"); inp.value = r.command; inp.focus();
|
|
707
|
+
} else {
|
|
708
|
+
out.insertAdjacentHTML("beforeend", `<div class="to-out muted">${escapeHtml(r.text || "(no answer)")}</div>`);
|
|
709
|
+
}
|
|
710
|
+
} catch (err) {
|
|
711
|
+
out.insertAdjacentHTML("beforeend", `<div class="to-out err">${escapeHtml(err.message)}</div>`);
|
|
712
|
+
}
|
|
713
|
+
out.scrollTop = out.scrollHeight;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function onTermInput(e) {
|
|
717
|
+
term.sugg = buildSuggestions(e.target.value);
|
|
718
|
+
term.sel = 0;
|
|
719
|
+
term.open = term.sugg.length > 0;
|
|
720
|
+
renderSuggestions();
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function onTermKey(e) {
|
|
724
|
+
const n = term.sugg.length;
|
|
725
|
+
if (term.open && n) {
|
|
726
|
+
if (e.key === "ArrowDown") { term.sel = (term.sel + 1) % n; renderSuggestions(); e.preventDefault(); return; }
|
|
727
|
+
if (e.key === "ArrowUp") { term.sel = (term.sel - 1 + n) % n; renderSuggestions(); e.preventDefault(); return; }
|
|
728
|
+
if (e.key === "Tab") { e.preventDefault(); acceptSugg(term.sel, false); return; }
|
|
729
|
+
if (e.key === "Enter") { e.preventDefault(); acceptSugg(term.sel, true); return; }
|
|
730
|
+
if (e.key === "Escape") { term.open = false; renderSuggestions(); return; }
|
|
731
|
+
}
|
|
732
|
+
if (e.key === "Enter") { e.preventDefault(); runTerm(); }
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function runTerm() {
|
|
736
|
+
const input = $("#term-input");
|
|
737
|
+
const cmd = input.value.trim();
|
|
738
|
+
if (!cmd) return;
|
|
739
|
+
const out = $("#term-out");
|
|
740
|
+
out.insertAdjacentHTML("beforeend", `<div class="to-cmd"><span class="prompt">${icon("chevron")}</span> ${escapeHtml(cmd)}</div>`);
|
|
741
|
+
input.value = ""; term.open = false; renderSuggestions();
|
|
742
|
+
try {
|
|
743
|
+
const r = await api("/api/run", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ command: cmd }) });
|
|
744
|
+
const cls = r.code === 0 ? "to-out" : "to-out err";
|
|
745
|
+
out.insertAdjacentHTML("beforeend", `<div class="${cls}">${escapeHtml(r.output)}</div>`);
|
|
746
|
+
} catch (err) {
|
|
747
|
+
out.insertAdjacentHTML("beforeend", `<div class="to-out err">${escapeHtml(err.message)}</div>`);
|
|
748
|
+
}
|
|
749
|
+
out.scrollTop = out.scrollHeight;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function seedTerminal() {
|
|
753
|
+
const aiLine = state.meta.ai
|
|
754
|
+
? `\n<b>AI copilot</b> is on: type a request in plain English (e.g. <b>why did web crash</b>) and pick <b>Ask AI</b> — it proposes a command you review, then run.`
|
|
755
|
+
: "";
|
|
756
|
+
$("#term-out").innerHTML =
|
|
757
|
+
`<div class="to-out muted">Docker terminal — type <b>docker …</b>, press <b>Tab</b> to complete, <b>↑/↓</b> to pick, <b>Enter</b> to run.
|
|
758
|
+
Read-only commands always work; write commands respect read-only mode. Try: <b>docker ps -a</b>${aiLine}</div>`;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/* --------------------------------------------------------------- backups */
|
|
762
|
+
|
|
763
|
+
function initBackups() {
|
|
764
|
+
const sel = $("#bk-container");
|
|
765
|
+
sel.innerHTML = state.containers.map((c) => `<option value="${escapeHtml(c.name)}">${escapeHtml(c.name)}</option>`).join("");
|
|
766
|
+
$("#bk-dir").textContent = `→ ${state.meta.backupDir || "./snapshots"}`;
|
|
767
|
+
$("#sc-hint").innerHTML =
|
|
768
|
+
"Snapshots are written to the directory above. Set a <b>notify email</b> and a webhook (<code>DOCKER_MCP_BACKUP_WEBHOOK</code>) to forward backups to email, Google Drive or S3 via Zapier / Make / n8n.";
|
|
769
|
+
loadSchedule();
|
|
770
|
+
loadSnapshots();
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
async function createSnapshotUI() {
|
|
774
|
+
const container = $("#bk-container").value;
|
|
775
|
+
const type = $("#bk-type").value;
|
|
776
|
+
if (!container) return;
|
|
777
|
+
$("#bk-create").disabled = true;
|
|
778
|
+
try {
|
|
779
|
+
await api("/api/backup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ container, type }) });
|
|
780
|
+
await loadSnapshots();
|
|
781
|
+
} catch (err) {
|
|
782
|
+
alert(`Snapshot failed: ${err.message}`);
|
|
783
|
+
} finally {
|
|
784
|
+
$("#bk-create").disabled = false;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function quickSnapshot(name) {
|
|
789
|
+
try {
|
|
790
|
+
await api("/api/backup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ container: name, type: "commit" }) });
|
|
791
|
+
switchTab("backups");
|
|
792
|
+
} catch (err) {
|
|
793
|
+
alert(`Snapshot failed: ${err.message}`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function loadSnapshots() {
|
|
798
|
+
try {
|
|
799
|
+
const { snapshots } = await api("/api/backups");
|
|
800
|
+
const ro = state.meta.readOnly;
|
|
801
|
+
$("#bk-list").innerHTML = snapshots.length
|
|
802
|
+
? snapshots.map((s) => `<tr>
|
|
803
|
+
<td class="mono">${escapeHtml(new Date(s.createdAt).toLocaleString())}</td>
|
|
804
|
+
<td class="name">${escapeHtml(s.container)}</td>
|
|
805
|
+
<td><span class="tagpill">${escapeHtml(s.type)}</span></td>
|
|
806
|
+
<td class="mono">${escapeHtml(s.ref)}</td>
|
|
807
|
+
<td class="muted">${escapeHtml(s.destination)}</td>
|
|
808
|
+
<td class="right mono">${bytes(s.sizeBytes)}</td>
|
|
809
|
+
<td class="right">${ro ? "" : `<button class="ghost sm" data-restore="${escapeHtml(s.id)}" title="Restore">${icon("rotate")} Restore</button>`}</td></tr>`).join("")
|
|
810
|
+
: '<tr><td colspan="7" class="muted">No snapshots yet.</td></tr>';
|
|
811
|
+
$("#bk-list").querySelectorAll("[data-restore]").forEach((b) => b.addEventListener("click", () => restoreSnapshot(b.dataset.restore)));
|
|
812
|
+
} catch (err) {
|
|
813
|
+
$("#bk-list").innerHTML = `<tr><td colspan="7" class="muted">${escapeHtml(err.message)}</td></tr>`;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async function restoreSnapshot(id) {
|
|
818
|
+
if (!confirm("Restore this snapshot? A commit snapshot starts a NEW container; an export imports a new image. Nothing existing is overwritten.")) return;
|
|
819
|
+
try {
|
|
820
|
+
const r = await api("/api/restore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id }) });
|
|
821
|
+
alert(r.message);
|
|
822
|
+
await refresh();
|
|
823
|
+
} catch (err) {
|
|
824
|
+
alert(`Restore failed: ${err.message}`);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function loadSchedule() {
|
|
829
|
+
try {
|
|
830
|
+
const s = await api("/api/schedule");
|
|
831
|
+
$("#sc-enabled").checked = !!s.enabled;
|
|
832
|
+
$("#sc-time").value = s.time || "03:00";
|
|
833
|
+
$("#sc-type").value = s.type || "commit";
|
|
834
|
+
$("#sc-containers").value = (s.containers || ["all"]).join(",");
|
|
835
|
+
$("#sc-email").value = s.email || "";
|
|
836
|
+
} catch { /* best effort */ }
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
async function saveSchedule() {
|
|
840
|
+
const body = {
|
|
841
|
+
enabled: $("#sc-enabled").checked,
|
|
842
|
+
time: $("#sc-time").value,
|
|
843
|
+
type: $("#sc-type").value,
|
|
844
|
+
containers: $("#sc-containers").value.split(",").map((x) => x.trim()).filter(Boolean),
|
|
845
|
+
email: $("#sc-email").value,
|
|
846
|
+
};
|
|
847
|
+
try {
|
|
848
|
+
await api("/api/schedule", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
849
|
+
$("#sc-save").textContent = "Saved ✓";
|
|
850
|
+
setTimeout(() => ($("#sc-save").textContent = "Save"), 1500);
|
|
851
|
+
} catch (err) {
|
|
852
|
+
alert(`Could not save schedule: ${err.message}`);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/* ---------------------------------------------------------------- system */
|
|
857
|
+
|
|
858
|
+
async function loadSystem() {
|
|
859
|
+
try {
|
|
860
|
+
const [{ networks }, { volumes }] = await Promise.all([api("/api/networks"), api("/api/volumes")]);
|
|
861
|
+
$("#net-list").innerHTML = networks.map((n) => `<tr><td class="name">${escapeHtml(n.name)}</td><td class="mono">${escapeHtml(n.id)}</td><td>${escapeHtml(n.driver)}</td><td class="muted">${escapeHtml(n.scope)}</td></tr>`).join("");
|
|
862
|
+
$("#vol-list").innerHTML = volumes.map((v) => `<tr><td class="name">${escapeHtml(v.name)}</td><td>${escapeHtml(v.driver)}</td><td class="mono muted">${escapeHtml(v.mountpoint)}</td></tr>`).join("");
|
|
863
|
+
} catch (err) {
|
|
864
|
+
$("#net-list").innerHTML = `<tr><td colspan="4" class="muted">${escapeHtml(err.message)}</td></tr>`;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/* ---------------------------------------------------------------- refresh */
|
|
869
|
+
|
|
870
|
+
async function refresh() {
|
|
871
|
+
const [snap, auto] = await Promise.all([api("/api/snapshot"), api("/api/autorestart").catch(() => ({ enabled: [] }))]);
|
|
872
|
+
state.containers = snap.containers;
|
|
873
|
+
state.autoRestart = new Set(auto.enabled);
|
|
874
|
+
renderStats(snap);
|
|
875
|
+
renderContainers(snap.containers);
|
|
876
|
+
renderImages(snap.images);
|
|
877
|
+
loadHistory();
|
|
878
|
+
// Keep the alert badge fresh without visiting the tab.
|
|
879
|
+
api("/api/alerts").then(({ alerts }) => updateAlertBadge(alerts)).catch(() => {});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function setAutoRefresh(on) {
|
|
883
|
+
state.autoRefresh = on;
|
|
884
|
+
if (state.timer) { clearInterval(state.timer); state.timer = null; }
|
|
885
|
+
if (on) state.timer = setInterval(() => refresh().catch(() => {}), 5000);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/* -------------------------------------------------------------- bootstrap */
|
|
889
|
+
|
|
890
|
+
async function init() {
|
|
891
|
+
try {
|
|
892
|
+
state.meta = await api("/api/meta");
|
|
893
|
+
if (state.meta.demo) $("#badge-demo").classList.remove("hidden");
|
|
894
|
+
if (state.meta.readOnly) $("#badge-ro").classList.remove("hidden");
|
|
895
|
+
document.title = `Docker Panel · ${state.meta.author || "SoyRage Agency"}`;
|
|
896
|
+
} catch { /* best effort */ }
|
|
897
|
+
|
|
898
|
+
// Swap glyphs for professional inline SVG icons.
|
|
899
|
+
const setIcon = (sel, name, label) => { const el = $(sel); if (el) el.innerHTML = icon(name) + (label ? " " + label : ""); };
|
|
900
|
+
setIcon("#refresh", "refresh", "Refresh");
|
|
901
|
+
setIcon("#alerts-refresh", "refresh", "Refresh");
|
|
902
|
+
setIcon("#bk-refresh", "refresh", "Refresh");
|
|
903
|
+
setIcon("#bk-create", "camera", "Snapshot now");
|
|
904
|
+
setIcon("#fs-up", "up", "Up");
|
|
905
|
+
setIcon("#drawer-close", "x");
|
|
906
|
+
const dn = $(".donate"); if (dn) dn.innerHTML = icon("coffee") + " Support";
|
|
907
|
+
$$(".prompt").forEach((el) => (el.innerHTML = icon("chevron")));
|
|
908
|
+
|
|
909
|
+
$$(".tab").forEach((b) => b.addEventListener("click", () => switchTab(b.dataset.tab)));
|
|
910
|
+
if (state.meta.terminal === false) $('.tab[data-tab="terminal"]').classList.add("hidden");
|
|
911
|
+
$("#refresh").addEventListener("click", () => refresh().catch(showError));
|
|
912
|
+
$("#alerts-refresh").addEventListener("click", loadAlerts);
|
|
913
|
+
|
|
914
|
+
// Terminal
|
|
915
|
+
seedTerminal();
|
|
916
|
+
$("#term-input").addEventListener("input", onTermInput);
|
|
917
|
+
$("#term-input").addEventListener("keydown", onTermKey);
|
|
918
|
+
$("#term-input").addEventListener("focus", onTermInput); // suggest immediately
|
|
919
|
+
$("#term-input").addEventListener("blur", () => setTimeout(() => { term.open = false; renderSuggestions(); }, 120));
|
|
920
|
+
|
|
921
|
+
// Backups
|
|
922
|
+
$("#bk-create").addEventListener("click", createSnapshotUI);
|
|
923
|
+
$("#bk-refresh").addEventListener("click", loadSnapshots);
|
|
924
|
+
$("#sc-save").addEventListener("click", saveSchedule);
|
|
925
|
+
$("#search").addEventListener("input", (e) => { state.filter = e.target.value; renderContainers(state.containers); });
|
|
926
|
+
$("#auto").addEventListener("change", (e) => setAutoRefresh(e.target.checked));
|
|
927
|
+
$("#fs-container").addEventListener("change", (e) => { state.files = { name: e.target.value, path: "/" }; loadDir(); });
|
|
928
|
+
$("#fs-up").addEventListener("click", () => {
|
|
929
|
+
const p = state.files.path.replace(/\/[^/]*$/, "") || "/";
|
|
930
|
+
state.files.path = p; loadDir();
|
|
931
|
+
});
|
|
932
|
+
$("#drawer-close").addEventListener("click", closeDrawer);
|
|
933
|
+
$("#scrim").addEventListener("click", closeDrawer);
|
|
934
|
+
document.addEventListener("keydown", (e) => e.key === "Escape" && closeDrawer());
|
|
935
|
+
|
|
936
|
+
refresh().catch(showError);
|
|
937
|
+
setAutoRefresh(true);
|
|
938
|
+
loadUpdates();
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/* ------------------------------------------------------ update channel */
|
|
942
|
+
|
|
943
|
+
// Fetch the update/announcement channel and, if there's a newer version or a
|
|
944
|
+
// fresh announcement, show a dismissable banner with the changelog. Silent on
|
|
945
|
+
// any failure — a notice you never see is better than an error you do.
|
|
946
|
+
async function loadUpdates() {
|
|
947
|
+
let status;
|
|
948
|
+
try {
|
|
949
|
+
({ status } = await api("/api/updates"));
|
|
950
|
+
} catch { return; }
|
|
951
|
+
if (!status) return;
|
|
952
|
+
|
|
953
|
+
const dismissed = localStorage.getItem("rd-update-dismissed") || "";
|
|
954
|
+
const banner = $("#update-banner");
|
|
955
|
+
if (!banner) return;
|
|
956
|
+
|
|
957
|
+
if (status.hasUpdate && dismissed !== status.latest) {
|
|
958
|
+
const rel = status.newer[0] || {};
|
|
959
|
+
const items = (rel.highlights || []).slice(0, 4).map((h) => `<li>${escapeHtml(h)}</li>`).join("");
|
|
960
|
+
banner.className = "update-banner" + (status.critical ? " critical" : "");
|
|
961
|
+
banner.innerHTML = `
|
|
962
|
+
<div class="ub-main">
|
|
963
|
+
<span class="ub-tag">${status.critical ? "Security update" : "Update"}</span>
|
|
964
|
+
<span class="ub-text">
|
|
965
|
+
<b>${escapeHtml(status.product || "Docker MCP Server")} ${escapeHtml(status.latest)}</b> is available
|
|
966
|
+
<span class="muted">(you have ${escapeHtml(status.current)})</span>
|
|
967
|
+
— ${escapeHtml(rel.title || "see what changed")}
|
|
968
|
+
</span>
|
|
969
|
+
</div>
|
|
970
|
+
${items ? `<ul class="ub-list">${items}</ul>` : ""}
|
|
971
|
+
<div class="ub-actions">
|
|
972
|
+
${rel.url ? `<a class="primary sm" href="${escapeHtml(rel.url)}" target="_blank" rel="noreferrer">View release ↗</a>` : ""}
|
|
973
|
+
<button class="ghost sm" id="ub-dismiss">Dismiss</button>
|
|
974
|
+
</div>`;
|
|
975
|
+
banner.classList.remove("hidden");
|
|
976
|
+
const btn = $("#ub-dismiss");
|
|
977
|
+
if (btn) btn.addEventListener("click", () => { localStorage.setItem("rd-update-dismissed", status.latest); banner.classList.add("hidden"); });
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// No version bump — surface the newest un-dismissed announcement, if any.
|
|
982
|
+
const ann = (status.announcements || [])[0];
|
|
983
|
+
if (ann && localStorage.getItem("rd-ann-dismissed") !== ann.id) {
|
|
984
|
+
banner.className = "update-banner announce" + (ann.level === "warning" ? " critical" : "");
|
|
985
|
+
banner.innerHTML = `
|
|
986
|
+
<div class="ub-main">
|
|
987
|
+
<span class="ub-tag">News</span>
|
|
988
|
+
<span class="ub-text"><b>${escapeHtml(ann.title)}</b> — ${escapeHtml(ann.body)}</span>
|
|
989
|
+
</div>
|
|
990
|
+
<div class="ub-actions">
|
|
991
|
+
${ann.url ? `<a class="primary sm" href="${escapeHtml(ann.url)}" target="_blank" rel="noreferrer">Open ↗</a>` : ""}
|
|
992
|
+
<button class="ghost sm" id="ub-dismiss">Dismiss</button>
|
|
993
|
+
</div>`;
|
|
994
|
+
banner.classList.remove("hidden");
|
|
995
|
+
const btn = $("#ub-dismiss");
|
|
996
|
+
if (btn) btn.addEventListener("click", () => { localStorage.setItem("rd-ann-dismissed", ann.id); banner.classList.add("hidden"); });
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function showError(err) {
|
|
1001
|
+
$("#containers").innerHTML = `<tr><td colspan="8" class="muted">Could not load data: ${escapeHtml(err.message)}. Try demo mode (DOCKER_MCP_PANEL_DEMO=true).</td></tr>`;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
init();
|