feat: add 7 new features — exec shell, SSE events, compose import, docker resources, resource limits, email notifications, auto-updates
- Container exec/shell via WebSocket + xterm.js (subtle >_ button on cards) - Live dashboard updates via SSE (resource alerts, health changes, update notices) - Docker Compose import with YAML parsing, preview, and dependency-ordered deploy - Volume & network management modal with disk usage overview - CPU/memory resource limits on deploy and live update - Email SMTP notifications (nodemailer) alongside Discord/Telegram/ntfy - Scheduled auto-update scheduler with maintenance windows (daily/weekly/monthly) New deps: ws, js-yaml, nodemailer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
125
dashcaddy-api/package-lock.json
generated
125
dashcaddy-api/package-lock.json
generated
@@ -14,14 +14,17 @@
|
|||||||
"express": "^4.22.1",
|
"express": "^4.22.1",
|
||||||
"express-rate-limit": "^7.5.1",
|
"express-rate-limit": "^7.5.1",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lru-cache": "^10.4.3",
|
"lru-cache": "^10.4.3",
|
||||||
|
"nodemailer": "^8.0.4",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"png-to-ico": "^2.1.8",
|
"png-to-ico": "^2.1.8",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"validator": "^13.11.0"
|
"validator": "^13.11.0",
|
||||||
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
@@ -61,6 +64,7 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -605,26 +609,6 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/eslintrc/node_modules/argparse": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Python-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/@eslint/eslintrc/node_modules/js-yaml": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"argparse": "^2.0.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"js-yaml": "bin/js-yaml.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@eslint/js": {
|
"node_modules/@eslint/js": {
|
||||||
"version": "8.57.1",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
|
||||||
@@ -1100,6 +1084,30 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
|
||||||
|
"version": "1.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||||
|
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sprintf-js": "~1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
||||||
|
"version": "3.14.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||||
|
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^1.0.7",
|
||||||
|
"esprima": "^4.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"js-yaml": "bin/js-yaml.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@istanbuljs/schema": {
|
"node_modules/@istanbuljs/schema": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
|
||||||
@@ -1805,6 +1813,7 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1894,14 +1903,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/argparse": {
|
"node_modules/argparse": {
|
||||||
"version": "1.0.10",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"dev": true,
|
"license": "Python-2.0"
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"sprintf-js": "~1.0.2"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/array-flatten": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
@@ -2188,6 +2193,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -3007,6 +3013,7 @@
|
|||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -3087,13 +3094,6 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint/node_modules/argparse": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Python-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/eslint/node_modules/escape-string-regexp": {
|
"node_modules/eslint/node_modules/escape-string-regexp": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
@@ -3124,19 +3124,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint/node_modules/js-yaml": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"argparse": "^2.0.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"js-yaml": "bin/js-yaml.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint/node_modules/locate-path": {
|
"node_modules/eslint/node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -4795,14 +4782,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "3.14.2",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^1.0.7",
|
"argparse": "^2.0.1"
|
||||||
"esprima": "^4.0.0"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"js-yaml": "bin/js-yaml.js"
|
"js-yaml": "bin/js-yaml.js"
|
||||||
@@ -5257,6 +5242,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "8.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
|
||||||
|
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/normalize-path": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
@@ -6918,6 +6912,27 @@
|
|||||||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
@@ -19,14 +19,17 @@
|
|||||||
"express": "^4.22.1",
|
"express": "^4.22.1",
|
||||||
"express-rate-limit": "^7.5.1",
|
"express-rate-limit": "^7.5.1",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lru-cache": "^10.4.3",
|
"lru-cache": "^10.4.3",
|
||||||
|
"nodemailer": "^8.0.4",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"png-to-ico": "^2.1.8",
|
"png-to-ico": "^2.1.8",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"validator": "^13.11.0"
|
"validator": "^13.11.0",
|
||||||
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
|
|||||||
334
dashcaddy-api/routes/apps/compose.js
Normal file
334
dashcaddy-api/routes/apps/compose.js
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const yaml = require('js-yaml');
|
||||||
|
const { DOCKER, REGEX } = require('../../constants');
|
||||||
|
const { ValidationError } = require('../../errors');
|
||||||
|
const platformPaths = require('../../platform-paths');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Docker Compose import routes
|
||||||
|
* Parse and deploy services from docker-compose.yml
|
||||||
|
* @param {Object} deps
|
||||||
|
*/
|
||||||
|
module.exports = function({ docker, caddy, servicesStateManager, portLockManager, asyncHandler, log, siteConfig, buildDomain, buildServiceUrl, addServiceToConfig, dns, notification }) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a compose YAML string into DashCaddy-compatible service configs
|
||||||
|
*/
|
||||||
|
function parseCompose(yamlStr, stackName) {
|
||||||
|
let doc;
|
||||||
|
try {
|
||||||
|
doc = yaml.load(yamlStr);
|
||||||
|
} catch (e) {
|
||||||
|
throw new ValidationError(`Invalid YAML: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doc || !doc.services || typeof doc.services !== 'object') {
|
||||||
|
throw new ValidationError('No services found in compose file');
|
||||||
|
}
|
||||||
|
|
||||||
|
const services = [];
|
||||||
|
const networks = Object.keys(doc.networks || {});
|
||||||
|
const volumes = Object.keys(doc.volumes || {});
|
||||||
|
|
||||||
|
for (const [name, svc] of Object.entries(doc.services)) {
|
||||||
|
if (!svc.image) {
|
||||||
|
// Build-based services can't be imported without the build context
|
||||||
|
services.push({ name, skip: true, reason: 'No image specified (build-only service)' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = {
|
||||||
|
name,
|
||||||
|
image: svc.image,
|
||||||
|
ports: [],
|
||||||
|
volumes: [],
|
||||||
|
environment: {},
|
||||||
|
restart: svc.restart || 'unless-stopped',
|
||||||
|
networks: svc.networks || [],
|
||||||
|
dependsOn: svc.depends_on || [],
|
||||||
|
labels: { 'sami.managed': 'true', 'sami.compose-stack': stackName, 'sami.compose-service': name },
|
||||||
|
resources: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse ports
|
||||||
|
if (svc.ports) {
|
||||||
|
for (const p of svc.ports) {
|
||||||
|
const str = String(p);
|
||||||
|
// Handle "8080:80", "8080:80/tcp", "127.0.0.1:8080:80"
|
||||||
|
const parts = str.split(':');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
parsed.ports.push({ host: parts[0], container: parts[1].split('/')[0], protocol: parts[1].includes('/') ? parts[1].split('/')[1] : 'tcp' });
|
||||||
|
} else if (parts.length === 3) {
|
||||||
|
parsed.ports.push({ host: parts[1], container: parts[2].split('/')[0], protocol: parts[2].includes('/') ? parts[2].split('/')[1] : 'tcp' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse volumes
|
||||||
|
if (svc.volumes) {
|
||||||
|
for (const v of svc.volumes) {
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
parsed.volumes.push(v);
|
||||||
|
} else if (v.source && v.target) {
|
||||||
|
const mode = v.read_only ? 'ro' : 'rw';
|
||||||
|
parsed.volumes.push(`${v.source}:${v.target}:${mode}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse environment
|
||||||
|
if (svc.environment) {
|
||||||
|
if (Array.isArray(svc.environment)) {
|
||||||
|
for (const e of svc.environment) {
|
||||||
|
const [key, ...val] = String(e).split('=');
|
||||||
|
parsed.environment[key] = val.join('=');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parsed.environment = { ...svc.environment };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse env_file entries (note: we record them but can't resolve file contents)
|
||||||
|
if (svc.env_file) {
|
||||||
|
parsed.envFileWarning = 'env_file references found — variables not imported (paste them as environment vars)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource limits
|
||||||
|
if (svc.deploy?.resources?.limits) {
|
||||||
|
const lim = svc.deploy.resources.limits;
|
||||||
|
if (lim.cpus) parsed.resources.cpus = parseFloat(lim.cpus);
|
||||||
|
if (lim.memory) {
|
||||||
|
const mem = String(lim.memory).toLowerCase();
|
||||||
|
if (mem.endsWith('g')) parsed.resources.memory = parseFloat(mem) * 1024;
|
||||||
|
else if (mem.endsWith('m')) parsed.resources.memory = parseFloat(mem);
|
||||||
|
else parsed.resources.memory = parseFloat(mem) / (1024 * 1024); // assume bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Legacy mem_limit / cpus
|
||||||
|
if (svc.mem_limit) {
|
||||||
|
const mem = String(svc.mem_limit).toLowerCase();
|
||||||
|
if (mem.endsWith('g')) parsed.resources.memory = parseFloat(mem) * 1024;
|
||||||
|
else if (mem.endsWith('m')) parsed.resources.memory = parseFloat(mem);
|
||||||
|
}
|
||||||
|
if (svc.cpus) parsed.resources.cpus = parseFloat(svc.cpus);
|
||||||
|
|
||||||
|
// Cap-add
|
||||||
|
if (svc.cap_add) parsed.capAdd = svc.cap_add;
|
||||||
|
|
||||||
|
services.push(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { services, networks, volumes, stackName };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Topological sort based on depends_on
|
||||||
|
*/
|
||||||
|
function topoSort(services) {
|
||||||
|
const graph = new Map();
|
||||||
|
const nameMap = new Map();
|
||||||
|
for (const svc of services) {
|
||||||
|
if (svc.skip) continue;
|
||||||
|
graph.set(svc.name, svc.dependsOn || []);
|
||||||
|
nameMap.set(svc.name, svc);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [];
|
||||||
|
const visited = new Set();
|
||||||
|
const visiting = new Set();
|
||||||
|
|
||||||
|
function visit(name) {
|
||||||
|
if (visited.has(name)) return;
|
||||||
|
if (visiting.has(name)) return; // circular — just break
|
||||||
|
visiting.add(name);
|
||||||
|
for (const dep of (graph.get(name) || [])) {
|
||||||
|
if (graph.has(dep)) visit(dep);
|
||||||
|
}
|
||||||
|
visiting.delete(name);
|
||||||
|
visited.add(name);
|
||||||
|
if (nameMap.has(name)) sorted.push(nameMap.get(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of graph.keys()) visit(name);
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /import-compose — parse YAML and return preview
|
||||||
|
router.post('/import-compose', asyncHandler(async (req, res) => {
|
||||||
|
const { yaml: yamlStr, stackName } = req.body;
|
||||||
|
if (!yamlStr || typeof yamlStr !== 'string') {
|
||||||
|
throw new ValidationError('yaml field is required (string)');
|
||||||
|
}
|
||||||
|
const name = (stackName || 'stack').replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 32) || 'stack';
|
||||||
|
const result = parseCompose(yamlStr, name);
|
||||||
|
res.json({ success: true, ...result });
|
||||||
|
}, 'compose-import'));
|
||||||
|
|
||||||
|
// POST /deploy-compose — deploy parsed services
|
||||||
|
router.post('/deploy-compose', asyncHandler(async (req, res) => {
|
||||||
|
const { services, networks, stackName, subdomainPrefix } = req.body;
|
||||||
|
if (!services || !Array.isArray(services) || services.length === 0) {
|
||||||
|
throw new ValidationError('services array is required');
|
||||||
|
}
|
||||||
|
const prefix = (subdomainPrefix || stackName || 'stack').replace(/[^a-zA-Z0-9-]/g, '').substring(0, 16);
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// Create networks first
|
||||||
|
if (networks && networks.length > 0) {
|
||||||
|
for (const net of networks) {
|
||||||
|
try {
|
||||||
|
await docker.client.createNetwork({ Name: `${prefix}_${net}`, Driver: 'bridge' });
|
||||||
|
results.push({ type: 'network', name: net, status: 'created' });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.statusCode === 409) {
|
||||||
|
results.push({ type: 'network', name: net, status: 'exists' });
|
||||||
|
} else {
|
||||||
|
results.push({ type: 'network', name: net, status: 'failed', error: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by dependency order
|
||||||
|
const sorted = topoSort(services.filter(s => !s.skip));
|
||||||
|
|
||||||
|
for (const svc of sorted) {
|
||||||
|
const containerName = `${DOCKER.CONTAINER_PREFIX}${prefix}-${svc.name}`;
|
||||||
|
const subdomain = `${prefix}-${svc.name}`;
|
||||||
|
try {
|
||||||
|
// Pull image
|
||||||
|
try {
|
||||||
|
await docker.pull(svc.image);
|
||||||
|
} catch (pullErr) {
|
||||||
|
// Check if local
|
||||||
|
const images = await docker.client.listImages({ filters: { reference: [svc.image] } });
|
||||||
|
if (images.length === 0) throw new Error(`Image ${svc.image} not found: ${pullErr.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build container config
|
||||||
|
const containerConfig = {
|
||||||
|
Image: svc.image,
|
||||||
|
name: containerName,
|
||||||
|
ExposedPorts: {},
|
||||||
|
HostConfig: {
|
||||||
|
PortBindings: {},
|
||||||
|
Binds: (svc.volumes || []).map(v => {
|
||||||
|
const [hostPath, ...rest] = v.split(':');
|
||||||
|
const translated = platformPaths.toDockerMountPath(hostPath);
|
||||||
|
return rest.length > 0 ? `${translated}:${rest.join(':')}` : translated;
|
||||||
|
}),
|
||||||
|
RestartPolicy: { Name: svc.restart || 'unless-stopped' },
|
||||||
|
LogConfig: DOCKER.LOG_CONFIG,
|
||||||
|
},
|
||||||
|
Env: Object.entries(svc.environment || {}).map(([k, v]) => `${k}=${v}`),
|
||||||
|
Labels: svc.labels || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ports
|
||||||
|
if (svc.ports) {
|
||||||
|
for (const p of svc.ports) {
|
||||||
|
const key = `${p.container}/${p.protocol || 'tcp'}`;
|
||||||
|
containerConfig.ExposedPorts[key] = {};
|
||||||
|
containerConfig.HostConfig.PortBindings[key] = [{ HostPort: String(p.host) }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resources
|
||||||
|
if (svc.resources?.memory) {
|
||||||
|
containerConfig.HostConfig.Memory = Math.round(svc.resources.memory * 1024 * 1024);
|
||||||
|
containerConfig.HostConfig.MemoryReservation = Math.round(svc.resources.memory * 1024 * 1024 * 0.5);
|
||||||
|
}
|
||||||
|
if (svc.resources?.cpus) {
|
||||||
|
containerConfig.HostConfig.NanoCpus = Math.round(svc.resources.cpus * 1e9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capabilities
|
||||||
|
if (svc.capAdd) containerConfig.HostConfig.CapAdd = svc.capAdd;
|
||||||
|
|
||||||
|
// Networks
|
||||||
|
if (svc.networks && svc.networks.length > 0) {
|
||||||
|
containerConfig.HostConfig.NetworkMode = `${prefix}_${svc.networks[0]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stale container with same name
|
||||||
|
try {
|
||||||
|
const existing = docker.client.getContainer(containerName);
|
||||||
|
await existing.remove({ force: true });
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
const container = await docker.client.createContainer(containerConfig);
|
||||||
|
await container.start();
|
||||||
|
|
||||||
|
// Determine port for Caddy/service registration
|
||||||
|
const mainPort = svc.ports?.[0]?.host || null;
|
||||||
|
|
||||||
|
// Add to services.json if it has a port (i.e., is web-accessible)
|
||||||
|
if (mainPort) {
|
||||||
|
const ip = siteConfig.dnsServerIp || 'localhost';
|
||||||
|
const serviceUrl = buildServiceUrl(subdomain);
|
||||||
|
|
||||||
|
await addServiceToConfig({
|
||||||
|
id: subdomain,
|
||||||
|
name: `${stackName || prefix}: ${svc.name}`,
|
||||||
|
logo: '/assets/docker.png',
|
||||||
|
url: serviceUrl,
|
||||||
|
containerId: container.id,
|
||||||
|
appTemplate: null,
|
||||||
|
routingMode: siteConfig.routingMode,
|
||||||
|
deployedAt: new Date().toISOString(),
|
||||||
|
deploymentManifest: {
|
||||||
|
templateId: null,
|
||||||
|
composeStack: stackName || prefix,
|
||||||
|
config: { subdomain, port: mainPort, ip }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ type: 'container', name: svc.name, containerId: container.id, status: 'deployed', subdomain: mainPort ? subdomain : null });
|
||||||
|
} catch (e) {
|
||||||
|
log.error('compose', `Failed to deploy service ${svc.name}`, { error: e.message });
|
||||||
|
results.push({ type: 'container', name: svc.name, status: 'failed', error: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skipped services
|
||||||
|
for (const svc of services.filter(s => s.skip)) {
|
||||||
|
results.push({ type: 'container', name: svc.name, status: 'skipped', reason: svc.reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, results, stackName: stackName || prefix });
|
||||||
|
}, 'compose-deploy'));
|
||||||
|
|
||||||
|
// DELETE /compose-stack/:stackName — remove an entire stack
|
||||||
|
router.delete('/compose-stack/:stackName', asyncHandler(async (req, res) => {
|
||||||
|
const { stackName } = req.params;
|
||||||
|
if (!stackName) throw new ValidationError('stackName is required');
|
||||||
|
|
||||||
|
const containers = await docker.client.listContainers({ all: true, filters: { label: [`sami.compose-stack=${stackName}`] } });
|
||||||
|
const removed = [];
|
||||||
|
|
||||||
|
for (const c of containers) {
|
||||||
|
try {
|
||||||
|
const container = docker.client.getContainer(c.Id);
|
||||||
|
await container.remove({ force: true });
|
||||||
|
removed.push({ name: c.Names[0], id: c.Id });
|
||||||
|
} catch (e) {
|
||||||
|
removed.push({ name: c.Names[0], id: c.Id, error: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from services.json
|
||||||
|
const services = await servicesStateManager.read();
|
||||||
|
const updated = (services.services || []).filter(s => {
|
||||||
|
const manifest = s.deploymentManifest;
|
||||||
|
return !(manifest && manifest.composeStack === stackName);
|
||||||
|
});
|
||||||
|
await servicesStateManager.update(data => { data.services = updated; });
|
||||||
|
|
||||||
|
res.json({ success: true, removed, count: removed.length });
|
||||||
|
}, 'compose-stack-delete'));
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
@@ -170,6 +170,18 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
containerConfig.HostConfig.CapAdd = processedTemplate.docker.capabilities;
|
containerConfig.HostConfig.CapAdd = processedTemplate.docker.capabilities;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resource limits (CPU and memory)
|
||||||
|
if (userConfig.resources) {
|
||||||
|
if (userConfig.resources.memory) {
|
||||||
|
const memBytes = Math.round(userConfig.resources.memory * 1024 * 1024); // MB to bytes
|
||||||
|
containerConfig.HostConfig.Memory = memBytes;
|
||||||
|
containerConfig.HostConfig.MemoryReservation = Math.round(memBytes * 0.5); // soft limit = 50%
|
||||||
|
}
|
||||||
|
if (userConfig.resources.cpus) {
|
||||||
|
containerConfig.HostConfig.NanoCpus = Math.round(userConfig.resources.cpus * 1e9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log.info('docker', 'Pulling image', { image: processedTemplate.docker.image });
|
log.info('docker', 'Pulling image', { image: processedTemplate.docker.image });
|
||||||
await docker.pull(processedTemplate.docker.image);
|
await docker.pull(processedTemplate.docker.image);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const initDeploy = require('./deploy');
|
|||||||
const initRemoval = require('./removal');
|
const initRemoval = require('./removal');
|
||||||
const initTemplates = require('./templates');
|
const initTemplates = require('./templates');
|
||||||
const initRestore = require('./restore');
|
const initRestore = require('./restore');
|
||||||
|
const initCompose = require('./compose');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apps routes aggregator
|
* Apps routes aggregator
|
||||||
@@ -44,6 +45,7 @@ module.exports = function(ctx) {
|
|||||||
router.use(initRemoval(subCtx));
|
router.use(initRemoval(subCtx));
|
||||||
router.use(initTemplates(subCtx));
|
router.use(initTemplates(subCtx));
|
||||||
router.use(initRestore(subCtx));
|
router.use(initRestore(subCtx));
|
||||||
|
router.use(initCompose(subCtx));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -190,6 +190,36 @@ module.exports = function({ docker, log, asyncHandler }) {
|
|||||||
success(res, { logs: logs.toString() });
|
success(res, { logs: logs.toString() });
|
||||||
}, 'container-logs'));
|
}, 'container-logs'));
|
||||||
|
|
||||||
|
// Update resource limits on a running container
|
||||||
|
router.put('/:id/resources', asyncHandler(async (req, res) => {
|
||||||
|
const container = await getVerifiedContainer(req.params.id);
|
||||||
|
const { memory, cpus } = req.body;
|
||||||
|
const updateConfig = {};
|
||||||
|
|
||||||
|
if (memory !== undefined) {
|
||||||
|
updateConfig.Memory = memory > 0 ? Math.round(memory * 1024 * 1024) : 0; // MB to bytes, 0 = unlimited
|
||||||
|
updateConfig.MemoryReservation = memory > 0 ? Math.round(memory * 1024 * 1024 * 0.5) : 0;
|
||||||
|
}
|
||||||
|
if (cpus !== undefined) {
|
||||||
|
updateConfig.NanoCpus = cpus > 0 ? Math.round(cpus * 1e9) : 0; // 0 = unlimited
|
||||||
|
}
|
||||||
|
|
||||||
|
await container.update(updateConfig);
|
||||||
|
success(res, { message: 'Resource limits updated' });
|
||||||
|
}, 'container-resources'));
|
||||||
|
|
||||||
|
// Get resource limits for a container
|
||||||
|
router.get('/:id/resources', asyncHandler(async (req, res) => {
|
||||||
|
const container = await getVerifiedContainer(req.params.id);
|
||||||
|
const info = await container.inspect();
|
||||||
|
const hc = info.HostConfig;
|
||||||
|
success(res, {
|
||||||
|
memory: hc.Memory ? Math.round(hc.Memory / 1024 / 1024) : 0, // bytes to MB
|
||||||
|
memoryReservation: hc.MemoryReservation ? Math.round(hc.MemoryReservation / 1024 / 1024) : 0,
|
||||||
|
cpus: hc.NanoCpus ? hc.NanoCpus / 1e9 : 0,
|
||||||
|
});
|
||||||
|
}, 'container-resources-get'));
|
||||||
|
|
||||||
// Delete container
|
// Delete container
|
||||||
router.delete('/:id', asyncHandler(async (req, res) => {
|
router.delete('/:id', asyncHandler(async (req, res) => {
|
||||||
const container = await getVerifiedContainer(req.params.id);
|
const container = await getVerifiedContainer(req.params.id);
|
||||||
|
|||||||
113
dashcaddy-api/routes/docker-resources.js
Normal file
113
dashcaddy-api/routes/docker-resources.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { success } = require('../response-helpers');
|
||||||
|
const { ValidationError } = require('../errors');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Docker resources route factory (volumes, networks, disk usage)
|
||||||
|
* @param {Object} deps
|
||||||
|
* @param {Object} deps.docker - Docker client wrapper
|
||||||
|
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||||
|
* @returns {express.Router}
|
||||||
|
*/
|
||||||
|
module.exports = function({ docker, asyncHandler }) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ===== VOLUMES =====
|
||||||
|
|
||||||
|
router.get('/volumes', asyncHandler(async (req, res) => {
|
||||||
|
const result = await docker.client.listVolumes();
|
||||||
|
const volumes = (result.Volumes || []).map(v => ({
|
||||||
|
name: v.Name,
|
||||||
|
driver: v.Driver,
|
||||||
|
mountpoint: v.Mountpoint,
|
||||||
|
scope: v.Scope,
|
||||||
|
created: v.CreatedAt,
|
||||||
|
labels: v.Labels || {},
|
||||||
|
}));
|
||||||
|
success(res, { volumes, count: volumes.length });
|
||||||
|
}, 'docker-volumes-list'));
|
||||||
|
|
||||||
|
router.post('/volumes', asyncHandler(async (req, res) => {
|
||||||
|
const { name, driver } = req.body;
|
||||||
|
if (!name || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(name)) {
|
||||||
|
throw new ValidationError('Invalid volume name');
|
||||||
|
}
|
||||||
|
const volume = await docker.client.createVolume({
|
||||||
|
Name: name,
|
||||||
|
Driver: driver || 'local',
|
||||||
|
});
|
||||||
|
success(res, { message: `Volume "${name}" created`, volume: { name: volume.name } });
|
||||||
|
}, 'docker-volumes-create'));
|
||||||
|
|
||||||
|
router.delete('/volumes/:name', asyncHandler(async (req, res) => {
|
||||||
|
const volume = docker.client.getVolume(req.params.name);
|
||||||
|
await volume.remove({ force: req.query.force === 'true' });
|
||||||
|
success(res, { message: `Volume "${req.params.name}" removed` });
|
||||||
|
}, 'docker-volumes-delete'));
|
||||||
|
|
||||||
|
// ===== NETWORKS =====
|
||||||
|
|
||||||
|
router.get('/networks', asyncHandler(async (req, res) => {
|
||||||
|
const networkList = await docker.client.listNetworks();
|
||||||
|
const networks = networkList.map(n => ({
|
||||||
|
id: n.Id.substring(0, 12),
|
||||||
|
name: n.Name,
|
||||||
|
driver: n.Driver,
|
||||||
|
scope: n.Scope,
|
||||||
|
internal: n.Internal,
|
||||||
|
containers: Object.keys(n.Containers || {}).length,
|
||||||
|
created: n.Created,
|
||||||
|
}));
|
||||||
|
success(res, { networks, count: networks.length });
|
||||||
|
}, 'docker-networks-list'));
|
||||||
|
|
||||||
|
router.post('/networks', asyncHandler(async (req, res) => {
|
||||||
|
const { name, driver } = req.body;
|
||||||
|
if (!name || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(name)) {
|
||||||
|
throw new ValidationError('Invalid network name');
|
||||||
|
}
|
||||||
|
const network = await docker.client.createNetwork({
|
||||||
|
Name: name,
|
||||||
|
Driver: driver || 'bridge',
|
||||||
|
});
|
||||||
|
success(res, { message: `Network "${name}" created`, id: network.id });
|
||||||
|
}, 'docker-networks-create'));
|
||||||
|
|
||||||
|
router.delete('/networks/:id', asyncHandler(async (req, res) => {
|
||||||
|
const network = docker.client.getNetwork(req.params.id);
|
||||||
|
await network.remove();
|
||||||
|
success(res, { message: 'Network removed' });
|
||||||
|
}, 'docker-networks-delete'));
|
||||||
|
|
||||||
|
// ===== DISK USAGE =====
|
||||||
|
|
||||||
|
router.get('/disk-usage', asyncHandler(async (req, res) => {
|
||||||
|
const df = await docker.client.df();
|
||||||
|
const summary = {
|
||||||
|
images: {
|
||||||
|
count: (df.Images || []).length,
|
||||||
|
size: (df.Images || []).reduce((sum, i) => sum + (i.Size || 0), 0),
|
||||||
|
reclaimable: (df.Images || []).filter(i => i.Containers === 0).reduce((sum, i) => sum + (i.Size || 0), 0),
|
||||||
|
},
|
||||||
|
containers: {
|
||||||
|
count: (df.Containers || []).length,
|
||||||
|
running: (df.Containers || []).filter(c => c.State === 'running').length,
|
||||||
|
size: (df.Containers || []).reduce((sum, c) => sum + (c.SizeRw || 0), 0),
|
||||||
|
},
|
||||||
|
volumes: {
|
||||||
|
count: (df.Volumes || []).length,
|
||||||
|
size: (df.Volumes || []).reduce((sum, v) => sum + (v.UsageData?.Size || 0), 0),
|
||||||
|
reclaimable: (df.Volumes || []).filter(v => v.UsageData?.RefCount === 0).reduce((sum, v) => sum + (v.UsageData?.Size || 0), 0),
|
||||||
|
},
|
||||||
|
buildCache: {
|
||||||
|
count: (df.BuildCache || []).length,
|
||||||
|
size: (df.BuildCache || []).reduce((sum, b) => sum + (b.Size || 0), 0),
|
||||||
|
reclaimable: (df.BuildCache || []).filter(b => !b.InUse).reduce((sum, b) => sum + (b.Size || 0), 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
summary.totalSize = summary.images.size + summary.containers.size + summary.volumes.size + summary.buildCache.size;
|
||||||
|
success(res, summary);
|
||||||
|
}, 'docker-disk-usage'));
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
111
dashcaddy-api/routes/events.js
Normal file
111
dashcaddy-api/routes/events.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-Sent Events route factory
|
||||||
|
* Pushes real-time updates to connected dashboard clients
|
||||||
|
* @param {Object} deps - Dependencies
|
||||||
|
* @param {Object} deps.resourceMonitor - Container resource monitor
|
||||||
|
* @param {Object} deps.healthChecker - Health checker
|
||||||
|
* @param {Object} deps.updateManager - Update manager
|
||||||
|
* @param {Function} deps.logError - Error logging function
|
||||||
|
* @returns {express.Router}
|
||||||
|
*/
|
||||||
|
module.exports = function({ resourceMonitor, healthChecker, updateManager, logError }) {
|
||||||
|
const router = express.Router();
|
||||||
|
const clients = new Set();
|
||||||
|
|
||||||
|
function broadcast(event, data) {
|
||||||
|
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
|
for (const res of clients) {
|
||||||
|
try { res.write(msg); } catch (_) { clients.delete(res); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wire up EventEmitter listeners ---
|
||||||
|
|
||||||
|
// Resource monitor events
|
||||||
|
if (resourceMonitor) {
|
||||||
|
resourceMonitor.on('alert', (data) => {
|
||||||
|
broadcast('resource-alert', data);
|
||||||
|
});
|
||||||
|
resourceMonitor.on('auto-restart', (data) => {
|
||||||
|
broadcast('auto-restart', data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health checker events
|
||||||
|
if (healthChecker) {
|
||||||
|
healthChecker.on('status-check', (data) => {
|
||||||
|
broadcast('status-change', {
|
||||||
|
serviceId: data.serviceId,
|
||||||
|
name: data.name,
|
||||||
|
status: data.status,
|
||||||
|
responseTime: data.responseTime,
|
||||||
|
timestamp: data.timestamp
|
||||||
|
});
|
||||||
|
});
|
||||||
|
healthChecker.on('incident-created', (data) => {
|
||||||
|
broadcast('incident', { type: 'created', ...data });
|
||||||
|
});
|
||||||
|
healthChecker.on('incident-resolved', (data) => {
|
||||||
|
broadcast('incident', { type: 'resolved', ...data });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update manager events
|
||||||
|
if (updateManager) {
|
||||||
|
updateManager.on('update-available', (data) => {
|
||||||
|
broadcast('update-available', data);
|
||||||
|
});
|
||||||
|
updateManager.on('update-start', (data) => {
|
||||||
|
broadcast('update-start', data);
|
||||||
|
});
|
||||||
|
updateManager.on('update-complete', (data) => {
|
||||||
|
broadcast('update-complete', data);
|
||||||
|
});
|
||||||
|
updateManager.on('update-failed', (data) => {
|
||||||
|
broadcast('update-failed', data);
|
||||||
|
});
|
||||||
|
updateManager.on('auto-update-start', (data) => {
|
||||||
|
broadcast('auto-update-start', data);
|
||||||
|
});
|
||||||
|
updateManager.on('auto-update-complete', (data) => {
|
||||||
|
broadcast('auto-update-complete', data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE endpoint
|
||||||
|
router.get('/stream', (req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send initial connected event
|
||||||
|
res.write(`event: connected\ndata: ${JSON.stringify({ clients: clients.size + 1 })}\n\n`);
|
||||||
|
|
||||||
|
clients.add(res);
|
||||||
|
|
||||||
|
// Heartbeat every 30s
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
try { res.write(': heartbeat\n\n'); } catch (_) { cleanup(); }
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
clients.delete(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.on('close', cleanup);
|
||||||
|
req.on('error', cleanup);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client count (useful for debugging)
|
||||||
|
router.get('/clients', (req, res) => {
|
||||||
|
res.json({ success: true, count: clients.size });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
124
dashcaddy-api/routes/exec.js
Normal file
124
dashcaddy-api/routes/exec.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
const Docker = require('dockerode');
|
||||||
|
const url = require('url');
|
||||||
|
|
||||||
|
const docker = new Docker();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach WebSocket server for container exec/shell
|
||||||
|
* Route: ws://host/ws/exec/:containerId
|
||||||
|
* @param {http.Server} server - The HTTP server instance
|
||||||
|
* @param {Object} log - Logger
|
||||||
|
*/
|
||||||
|
module.exports = function attachExecWS(server, log) {
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
server.on('upgrade', (req, socket, head) => {
|
||||||
|
const parsed = url.parse(req.url, true);
|
||||||
|
const match = parsed.pathname.match(/^\/ws\/exec\/([a-zA-Z0-9_.-]+)$/);
|
||||||
|
if (!match) return; // Not our route — let other handlers deal with it
|
||||||
|
|
||||||
|
const containerId = decodeURIComponent(match[1]);
|
||||||
|
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
handleExec(ws, containerId, log);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return wss;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleExec(ws, containerId, log) {
|
||||||
|
let execStream = null;
|
||||||
|
let execInstance = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const container = docker.getContainer(containerId);
|
||||||
|
// Verify container exists and is running
|
||||||
|
const info = await container.inspect();
|
||||||
|
if (!info.State.Running) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'Container is not running' }));
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect available shell
|
||||||
|
let shell = '/bin/sh';
|
||||||
|
try {
|
||||||
|
const bashCheck = await container.exec({ Cmd: ['which', 'bash'], AttachStdout: true });
|
||||||
|
const bashStream = await bashCheck.start();
|
||||||
|
const chunks = [];
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
bashStream.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
bashStream.on('end', resolve);
|
||||||
|
});
|
||||||
|
if (chunks.length > 0 && Buffer.concat(chunks).toString().includes('/bash')) {
|
||||||
|
shell = '/bin/bash';
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
execInstance = await container.exec({
|
||||||
|
Cmd: [shell],
|
||||||
|
AttachStdin: true,
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
Tty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
execStream = await execInstance.start({ hijack: true, stdin: true, Tty: true });
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'connected', shell, containerId }));
|
||||||
|
|
||||||
|
// Docker → WebSocket
|
||||||
|
execStream.on('data', (chunk) => {
|
||||||
|
if (ws.readyState === ws.OPEN) {
|
||||||
|
ws.send(chunk);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
execStream.on('end', () => {
|
||||||
|
if (ws.readyState === ws.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'exit' }));
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// WebSocket → Docker
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
if (!execStream.writable) return;
|
||||||
|
try {
|
||||||
|
// Check for control messages (JSON)
|
||||||
|
const str = data.toString();
|
||||||
|
if (str.startsWith('{"type":')) {
|
||||||
|
const msg = JSON.parse(str);
|
||||||
|
if (msg.type === 'resize' && execInstance && msg.cols && msg.rows) {
|
||||||
|
execInstance.resize({ h: msg.rows, w: msg.cols }).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
// Regular terminal input
|
||||||
|
execStream.write(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
if (execStream) {
|
||||||
|
try { execStream.destroy(); } catch (_) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
log.warn('exec', 'WebSocket error', { containerId, error: err.message });
|
||||||
|
if (execStream) {
|
||||||
|
try { execStream.destroy(); } catch (_) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
log.error('exec', 'Failed to start exec session', { containerId, error: err.message });
|
||||||
|
if (ws.readyState === ws.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: err.message }));
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { validateURL, validateToken } = require('../input-validator');
|
const { validateURL, validateToken } = require('../input-validator');
|
||||||
|
const validatorLib = require('validator');
|
||||||
const { paginate, parsePaginationParams } = require('../pagination');
|
const { paginate, parsePaginationParams } = require('../pagination');
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../errors');
|
||||||
|
|
||||||
@@ -32,6 +33,12 @@ module.exports = function({ notification, asyncHandler }) {
|
|||||||
enabled: notificationConfig.providers.ntfy?.enabled || false,
|
enabled: notificationConfig.providers.ntfy?.enabled || false,
|
||||||
configured: !!notificationConfig.providers.ntfy?.topic,
|
configured: !!notificationConfig.providers.ntfy?.topic,
|
||||||
serverUrl: notificationConfig.providers.ntfy?.serverUrl || 'https://ntfy.sh'
|
serverUrl: notificationConfig.providers.ntfy?.serverUrl || 'https://ntfy.sh'
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
enabled: notificationConfig.providers.email?.enabled || false,
|
||||||
|
configured: !!(notificationConfig.providers.email?.host && notificationConfig.providers.email?.to),
|
||||||
|
host: notificationConfig.providers.email?.host || '',
|
||||||
|
from: notificationConfig.providers.email?.from || ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
events: notificationConfig.events,
|
events: notificationConfig.events,
|
||||||
@@ -74,6 +81,19 @@ module.exports = function({ notification, asyncHandler }) {
|
|||||||
throw new ValidationError('Invalid ntfy topic (alphanumeric, hyphens, underscores only, max 64 chars)');
|
throw new ValidationError('Invalid ntfy topic (alphanumeric, hyphens, underscores only, max 64 chars)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (providers.email?.to) {
|
||||||
|
const emails = providers.email.to.split(',').map(e => e.trim());
|
||||||
|
for (const email of emails) {
|
||||||
|
if (!validatorLib.isEmail(email)) {
|
||||||
|
throw new ValidationError(`Invalid email address: ${email}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (providers.email?.host && typeof providers.email.host === 'string') {
|
||||||
|
if (!validatorLib.isFQDN(providers.email.host) && !validatorLib.isIP(providers.email.host)) {
|
||||||
|
throw new ValidationError('Invalid SMTP host');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update enabled state
|
// Update enabled state
|
||||||
@@ -101,6 +121,12 @@ module.exports = function({ notification, asyncHandler }) {
|
|||||||
...providers.ntfy
|
...providers.ntfy
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (providers.email) {
|
||||||
|
notificationConfig.providers.email = {
|
||||||
|
...notificationConfig.providers.email,
|
||||||
|
...providers.email
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update events
|
// Update events
|
||||||
@@ -144,6 +170,9 @@ module.exports = function({ notification, asyncHandler }) {
|
|||||||
case 'ntfy':
|
case 'ntfy':
|
||||||
result = await notification.sendNtfy('Test Notification', 'This is a test notification from DashCaddy.', 'info');
|
result = await notification.sendNtfy('Test Notification', 'This is a test notification from DashCaddy.', 'info');
|
||||||
break;
|
break;
|
||||||
|
case 'email':
|
||||||
|
result = await notification.sendEmail('Test Notification', 'This is a test notification from DashCaddy.', 'info');
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new ValidationError('Unknown provider');
|
throw new ValidationError('Unknown provider');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
|
|||||||
res.json({ success: true, message: 'Auto-update configured' });
|
res.json({ success: true, message: 'Auto-update configured' });
|
||||||
}, 'updates-auto-update'));
|
}, 'updates-auto-update'));
|
||||||
|
|
||||||
|
// Get auto-update configuration
|
||||||
|
router.get('/updates/auto-update', asyncHandler(async (req, res) => {
|
||||||
|
const config = updateManager.getAutoUpdateConfig();
|
||||||
|
res.json({ success: true, config });
|
||||||
|
}, 'updates-auto-update-config'));
|
||||||
|
|
||||||
// Schedule update
|
// Schedule update
|
||||||
router.post('/updates/schedule/:containerId', asyncHandler(async (req, res) => {
|
router.post('/updates/schedule/:containerId', asyncHandler(async (req, res) => {
|
||||||
const { scheduledTime } = req.body;
|
const { scheduledTime } = req.body;
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ process.on('uncaughtException', (error) => {
|
|||||||
environment: process.env.NODE_ENV || 'production'
|
environment: process.env.NODE_ENV || 'production'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Attach WebSocket exec handler
|
||||||
|
const attachExecWS = require('./routes/exec');
|
||||||
|
attachExecWS(server, log);
|
||||||
|
log.info('server', 'WebSocket exec handler attached');
|
||||||
|
|
||||||
// Start feature modules
|
// Start feature modules
|
||||||
const resourceMonitor = require('./resource-monitor');
|
const resourceMonitor = require('./resource-monitor');
|
||||||
const backupManager = require('./backup-manager');
|
const backupManager = require('./backup-manager');
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ const errorLogsRoutes = require('../routes/errorlogs');
|
|||||||
const licenseRoutes = require('../routes/license');
|
const licenseRoutes = require('../routes/license');
|
||||||
const recipesRoutes = require('../routes/recipes');
|
const recipesRoutes = require('../routes/recipes');
|
||||||
const themesRoutes = require('../routes/themes');
|
const themesRoutes = require('../routes/themes');
|
||||||
|
const dockerResourcesRoutes = require('../routes/docker-resources');
|
||||||
|
const eventsRoutes = require('../routes/events');
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const { APP } = require('../constants');
|
const { APP } = require('../constants');
|
||||||
@@ -419,6 +421,16 @@ async function createApp() {
|
|||||||
}));
|
}));
|
||||||
apiRouter.use('/recipes', recipesRoutes(ctx));
|
apiRouter.use('/recipes', recipesRoutes(ctx));
|
||||||
apiRouter.use(themesRoutes({ asyncHandler: ctx.asyncHandler }));
|
apiRouter.use(themesRoutes({ asyncHandler: ctx.asyncHandler }));
|
||||||
|
apiRouter.use('/docker', dockerResourcesRoutes({
|
||||||
|
docker: ctx.docker,
|
||||||
|
asyncHandler: ctx.asyncHandler
|
||||||
|
}));
|
||||||
|
apiRouter.use('/events', eventsRoutes({
|
||||||
|
resourceMonitor: ctx.resourceMonitor,
|
||||||
|
healthChecker: ctx.healthChecker,
|
||||||
|
updateManager: ctx.updateManager,
|
||||||
|
logError: ctx.logError
|
||||||
|
}));
|
||||||
|
|
||||||
// Inline API routes
|
// Inline API routes
|
||||||
apiRouter.get('/health', (req, res) => {
|
apiRouter.get('/health', (req, res) => {
|
||||||
|
|||||||
@@ -27,19 +27,22 @@ class UpdateManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start update checking
|
* Start update checking and auto-update scheduler
|
||||||
*/
|
*/
|
||||||
start() {
|
start() {
|
||||||
if (this.checking) return;
|
if (this.checking) return;
|
||||||
|
|
||||||
console.log('[UpdateManager] Starting update checks');
|
console.log('[UpdateManager] Starting update checks');
|
||||||
this.checking = true;
|
this.checking = true;
|
||||||
|
|
||||||
// Initial check
|
// Initial check
|
||||||
this.checkForUpdates();
|
this.checkForUpdates();
|
||||||
|
|
||||||
// Schedule periodic checks
|
// Schedule periodic checks
|
||||||
this.checkInterval = setInterval(() => this.checkForUpdates(), CHECK_INTERVAL);
|
this.checkInterval = setInterval(() => this.checkForUpdates(), CHECK_INTERVAL);
|
||||||
|
|
||||||
|
// Start auto-update scheduler (checks every hour)
|
||||||
|
this.startAutoUpdateScheduler();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,14 +50,18 @@ class UpdateManager extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
stop() {
|
stop() {
|
||||||
if (!this.checking) return;
|
if (!this.checking) return;
|
||||||
|
|
||||||
console.log('[UpdateManager] Stopping update checks');
|
console.log('[UpdateManager] Stopping update checks');
|
||||||
this.checking = false;
|
this.checking = false;
|
||||||
|
|
||||||
if (this.checkInterval) {
|
if (this.checkInterval) {
|
||||||
clearInterval(this.checkInterval);
|
clearInterval(this.checkInterval);
|
||||||
this.checkInterval = null;
|
this.checkInterval = null;
|
||||||
}
|
}
|
||||||
|
if (this.autoUpdateInterval) {
|
||||||
|
clearInterval(this.autoUpdateInterval);
|
||||||
|
this.autoUpdateInterval = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -823,6 +830,92 @@ class UpdateManager extends EventEmitter {
|
|||||||
return lines.join('\n') || 'No changelog available';
|
return lines.join('\n') || 'No changelog available';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the auto-update scheduler — runs hourly, applies updates in maintenance windows
|
||||||
|
*/
|
||||||
|
startAutoUpdateScheduler() {
|
||||||
|
const AUTO_CHECK_INTERVAL = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
|
// Delay first run by 10 minutes to let containers start
|
||||||
|
setTimeout(() => this.runAutoUpdates(), 10 * 60 * 1000);
|
||||||
|
this.autoUpdateInterval = setInterval(() => this.runAutoUpdates(), AUTO_CHECK_INTERVAL);
|
||||||
|
|
||||||
|
const count = Object.values(this.config.autoUpdate || {}).filter(c => c.enabled).length;
|
||||||
|
if (count > 0) {
|
||||||
|
console.log(`[UpdateManager] Auto-update scheduler started (${count} container(s) configured)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute auto-updates for all configured containers
|
||||||
|
*/
|
||||||
|
async runAutoUpdates() {
|
||||||
|
const autoConfig = this.config.autoUpdate || {};
|
||||||
|
const now = new Date();
|
||||||
|
const hour = now.getHours();
|
||||||
|
const dayOfWeek = now.getDay(); // 0 = Sunday
|
||||||
|
const dayOfMonth = now.getDate();
|
||||||
|
|
||||||
|
for (const [containerId, cfg] of Object.entries(autoConfig)) {
|
||||||
|
if (!cfg.enabled) continue;
|
||||||
|
|
||||||
|
// Check maintenance window (e.g., "02:00-05:00")
|
||||||
|
if (cfg.maintenanceWindow) {
|
||||||
|
const [startStr, endStr] = cfg.maintenanceWindow.split('-').map(s => s.trim());
|
||||||
|
const startHour = parseInt(startStr);
|
||||||
|
const endHour = parseInt(endStr);
|
||||||
|
if (startHour <= endHour) {
|
||||||
|
if (hour < startHour || hour >= endHour) continue;
|
||||||
|
} else {
|
||||||
|
// Wraps midnight (e.g., "22:00-04:00")
|
||||||
|
if (hour < startHour && hour >= endHour) continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default: only run between 2AM and 4AM
|
||||||
|
if (hour < 2 || hour >= 4) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check schedule
|
||||||
|
const shouldRun =
|
||||||
|
cfg.schedule === 'daily' ||
|
||||||
|
(cfg.schedule === 'weekly' && dayOfWeek === 0) || // Sunday
|
||||||
|
(cfg.schedule === 'monthly' && dayOfMonth === 1);
|
||||||
|
|
||||||
|
if (!shouldRun) continue;
|
||||||
|
|
||||||
|
// Check if already ran today
|
||||||
|
const lastRun = cfg.lastAutoUpdate ? new Date(cfg.lastAutoUpdate) : null;
|
||||||
|
if (lastRun && lastRun.toDateString() === now.toDateString()) continue;
|
||||||
|
|
||||||
|
// Check if this container has an available update
|
||||||
|
const update = this.availableUpdates.get(containerId);
|
||||||
|
if (!update) continue;
|
||||||
|
|
||||||
|
console.log(`[UpdateManager] Auto-updating ${update.containerName} (schedule: ${cfg.schedule})`);
|
||||||
|
this.emit('auto-update-start', { containerId, containerName: update.containerName, schedule: cfg.schedule });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.updateContainer(containerId, { autoRollback: cfg.autoRollback !== false });
|
||||||
|
cfg.lastAutoUpdate = now.toISOString();
|
||||||
|
this.saveConfig();
|
||||||
|
console.log(`[UpdateManager] Auto-update completed for ${update.containerName}`);
|
||||||
|
this.emit('auto-update-complete', { containerId, containerName: update.containerName, result });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[UpdateManager] Auto-update failed for ${update.containerName}:`, error.message);
|
||||||
|
cfg.lastAutoUpdate = now.toISOString(); // Don't retry same day
|
||||||
|
this.saveConfig();
|
||||||
|
this.emit('auto-update-failed', { containerId, containerName: update.containerName, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get auto-update configuration for all containers
|
||||||
|
*/
|
||||||
|
getAutoUpdateConfig() {
|
||||||
|
return this.config.autoUpdate || {};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure auto-update for a container
|
* Configure auto-update for a container
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const bundles = {
|
|||||||
JS('core', 'service-infrastructure.js'),
|
JS('core', 'service-infrastructure.js'),
|
||||||
JS('core', 'service-crud.js'),
|
JS('core', 'service-crud.js'),
|
||||||
JS('core', 'service-create.js'),
|
JS('core', 'service-create.js'),
|
||||||
|
JS('live-events.js'),
|
||||||
],
|
],
|
||||||
'features.js': [
|
'features.js': [
|
||||||
JS('logo-customization.js'),
|
JS('logo-customization.js'),
|
||||||
@@ -37,6 +38,9 @@ const bundles = {
|
|||||||
JS('resource-monitor.js'),
|
JS('resource-monitor.js'),
|
||||||
JS('health-check.js'),
|
JS('health-check.js'),
|
||||||
JS('update-management.js'),
|
JS('update-management.js'),
|
||||||
|
JS('docker-resources.js'),
|
||||||
|
JS('compose-import.js'),
|
||||||
|
JS('container-exec.js'),
|
||||||
JS('audit-log.js'),
|
JS('audit-log.js'),
|
||||||
JS('weather.js'),
|
JS('weather.js'),
|
||||||
JS('clock.js'),
|
JS('clock.js'),
|
||||||
|
|||||||
@@ -1964,6 +1964,22 @@ button:focus-visible {
|
|||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Exec/terminal button styling — subtle, hover-only */
|
||||||
|
.exec-btn {
|
||||||
|
margin-left: 4px !important;
|
||||||
|
font-size: .7rem !important;
|
||||||
|
font-family: monospace !important;
|
||||||
|
padding: .2rem .45rem !important;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.card:hover .exec-btn {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.exec-btn:hover {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Credentials (key) button styling */
|
/* Credentials (key) button styling */
|
||||||
.creds-btn {
|
.creds-btn {
|
||||||
margin-right: 8px !important;
|
margin-right: 8px !important;
|
||||||
|
|||||||
218
status/css/xterm.css
Normal file
218
status/css/xterm.css
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||||
|
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||||
|
* https://github.com/chjj/term.js
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*
|
||||||
|
* Originally forked from (with the author's permission):
|
||||||
|
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||||
|
* http://bellard.org/jslinux/
|
||||||
|
* Copyright (c) 2011 Fabrice Bellard
|
||||||
|
* The original design remains. The terminal itself
|
||||||
|
* has been extended to include xterm CSI codes, among
|
||||||
|
* other features.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default styles for xterm.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
.xterm {
|
||||||
|
cursor: text;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.focus,
|
||||||
|
.xterm:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helpers {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
/**
|
||||||
|
* The z-index of the helpers must be higher than the canvases in order for
|
||||||
|
* IMEs to appear on top.
|
||||||
|
*/
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helper-textarea {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
left: -9999em;
|
||||||
|
top: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
z-index: -5;
|
||||||
|
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view {
|
||||||
|
/* TODO: Composition position got messed up somewhere */
|
||||||
|
background: #000;
|
||||||
|
color: #FFF;
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-viewport {
|
||||||
|
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||||
|
background-color: #000;
|
||||||
|
overflow-y: scroll;
|
||||||
|
cursor: default;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen canvas {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-scroll-area {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-char-measure-element {
|
||||||
|
display: inline-block;
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -9999em;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.enable-mouse-events {
|
||||||
|
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.xterm-cursor-pointer,
|
||||||
|
.xterm .xterm-cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.column-select.focus {
|
||||||
|
/* Column selection mode */
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility:not(.debug),
|
||||||
|
.xterm .xterm-message {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
color: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility-tree {
|
||||||
|
user-select: text;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .live-region {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-dim {
|
||||||
|
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||||
|
* explicitly in the generated class and reset to 1 here */
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-underline-1 { text-decoration: underline; }
|
||||||
|
.xterm-underline-2 { text-decoration: double underline; }
|
||||||
|
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||||
|
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||||
|
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||||
|
|
||||||
|
.xterm-overline {
|
||||||
|
text-decoration: overline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||||
|
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||||
|
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||||
|
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||||
|
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||||
|
|
||||||
|
.xterm-strikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||||
|
z-index: 6;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||||
|
z-index: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-overview-ruler {
|
||||||
|
z-index: 8;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-top {
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
194
status/dist/core.js
vendored
194
status/dist/core.js
vendored
File diff suppressed because one or more lines are too long
445
status/dist/features.js
vendored
445
status/dist/features.js
vendored
File diff suppressed because one or more lines are too long
@@ -24,6 +24,7 @@
|
|||||||
|
|
||||||
<link rel="stylesheet" href="/css/themes.css">
|
<link rel="stylesheet" href="/css/themes.css">
|
||||||
<link rel="stylesheet" href="/css/dashboard.css">
|
<link rel="stylesheet" href="/css/dashboard.css">
|
||||||
|
<link rel="stylesheet" href="/css/xterm.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -130,6 +131,7 @@
|
|||||||
<button id="view-error-logs" aria-label="View error logs">📋 Logs</button>
|
<button id="view-error-logs" aria-label="View error logs">📋 Logs</button>
|
||||||
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
|
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
|
||||||
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
|
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
|
||||||
|
<button id="docker-resources-btn" aria-label="Docker resources">🐳 Docker</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -231,6 +233,7 @@
|
|||||||
<div style="text-align: center; margin: 32px 0; display: flex; justify-content: center; gap: 16px; flex-wrap: wrap;">
|
<div style="text-align: center; margin: 32px 0; display: flex; justify-content: center; gap: 16px; flex-wrap: wrap;">
|
||||||
<button id="add-service-btn" style="padding: 12px 32px; font-size: 1.1rem; font-weight: 600;">📱 App Selector</button>
|
<button id="add-service-btn" style="padding: 12px 32px; font-size: 1.1rem; font-weight: 600;">📱 App Selector</button>
|
||||||
<button id="add-service" style="padding: 12px 32px; font-size: 1.1rem; font-weight: 600;" aria-label="Add new app manually">+ Add App Manually</button>
|
<button id="add-service" style="padding: 12px 32px; font-size: 1.1rem; font-weight: 600;" aria-label="Add new app manually">+ Add App Manually</button>
|
||||||
|
<button id="compose-import-btn" style="padding: 12px 32px; font-size: 1.1rem; font-weight: 600;">📦 Import Compose</button>
|
||||||
<button id="arr-setup-btn" style="padding: 12px 32px; font-size: 1.1rem; font-weight: 600; background: linear-gradient(135deg, #e74c3c 0%, #9b59b6 100%); border: none;">🎬 Smart Arr Connect</button>
|
<button id="arr-setup-btn" style="padding: 12px 32px; font-size: 1.1rem; font-weight: 600; background: linear-gradient(135deg, #e74c3c 0%, #9b59b6 100%); border: none;">🎬 Smart Arr Connect</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -545,6 +548,10 @@
|
|||||||
<img src="/assets/sami7777-logo.png" alt="samiahmed7777" class="footer-logo">
|
<img src="/assets/sami7777-logo.png" alt="samiahmed7777" class="footer-logo">
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<!-- xterm.js for container exec/shell -->
|
||||||
|
<script src="/js/xterm.min.js" defer></script>
|
||||||
|
<script src="/js/xterm-fit.min.js" defer></script>
|
||||||
|
|
||||||
<!-- Bundled JS (built with: npm run build) -->
|
<!-- Bundled JS (built with: npm run build) -->
|
||||||
<script src="/dist/core.js" defer></script>
|
<script src="/dist/core.js" defer></script>
|
||||||
<script src="/dist/features.js" defer></script>
|
<script src="/dist/features.js" defer></script>
|
||||||
|
|||||||
@@ -181,6 +181,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="volume-mounts-list" style="display: grid; gap: 8px;"></div>
|
<div id="volume-mounts-list" style="display: grid; gap: 8px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="margin-top: 12px;">
|
||||||
|
<label class="form-label-accent-sm">⚙️ Resource Limits</label>
|
||||||
|
<div style="font-size: 0.8rem; color: var(--muted); margin-bottom: 8px;">
|
||||||
|
Optional CPU and memory constraints. Leave at 0 for unlimited.
|
||||||
|
</div>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
||||||
|
<div>
|
||||||
|
<label for="deploy-cpu-limit" style="display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 4px;">CPU Cores</label>
|
||||||
|
<input type="number" id="deploy-cpu-limit" value="0" min="0" max="64" step="0.25" class="form-input-card" style="width: 100%;" placeholder="0 = unlimited" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="deploy-memory-limit" style="display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 4px;">Memory (MB)</label>
|
||||||
|
<input type="number" id="deploy-memory-limit" value="0" min="0" max="131072" step="64" class="form-input-card" style="width: 100%;" placeholder="0 = unlimited" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@@ -920,7 +936,11 @@
|
|||||||
tailscaleOnly: document.getElementById('deploy-tailscale-only').checked,
|
tailscaleOnly: document.getElementById('deploy-tailscale-only').checked,
|
||||||
mediaPath: mediaPath || null,
|
mediaPath: mediaPath || null,
|
||||||
plexClaimToken: document.getElementById('deploy-plex-claim')?.value.trim() || null,
|
plexClaimToken: document.getElementById('deploy-plex-claim')?.value.trim() || null,
|
||||||
customVolumes: customVolumes.length > 0 ? customVolumes : null
|
customVolumes: customVolumes.length > 0 ? customVolumes : null,
|
||||||
|
resources: {
|
||||||
|
cpus: parseFloat(document.getElementById('deploy-cpu-limit').value) || 0,
|
||||||
|
memory: parseFloat(document.getElementById('deploy-memory-limit').value) || 0,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate subdomain
|
// Validate subdomain
|
||||||
|
|||||||
196
status/js/compose-import.js
Normal file
196
status/js/compose-import.js
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
// ========== DOCKER COMPOSE IMPORT ==========
|
||||||
|
(function() {
|
||||||
|
injectModal('compose-import-modal', `<div id="compose-import-modal" class="weather-modal">
|
||||||
|
<div class="weather-modal-content" style="min-width: 650px; max-width: 800px;">
|
||||||
|
<h3>📦 Import Docker Compose</h3>
|
||||||
|
<p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p>
|
||||||
|
|
||||||
|
<!-- Step 1: Paste YAML -->
|
||||||
|
<div id="compose-step-paste">
|
||||||
|
<div style="margin-bottom: 12px;">
|
||||||
|
<label class="form-label-accent-sm">Stack Name</label>
|
||||||
|
<input type="text" id="compose-stack-name" placeholder="my-stack" value="" style="width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);" />
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 12px;">
|
||||||
|
<label class="form-label-accent-sm">docker-compose.yml</label>
|
||||||
|
<textarea id="compose-yaml" rows="14" placeholder="version: '3' services: web: image: nginx:latest ports: - '8080:80'" style="width: 100%; padding: 10px; font-family: monospace; font-size: 0.82rem; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); resize: vertical;"></textarea>
|
||||||
|
<div style="margin-top: 6px;">
|
||||||
|
<label style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.82rem; color: var(--muted);">
|
||||||
|
<input type="file" id="compose-file-upload" accept=".yml,.yaml" style="display: none;" />
|
||||||
|
<span style="text-decoration: underline;">or upload a file</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="weather-modal-buttons modal-footer-bar">
|
||||||
|
<button id="compose-parse-btn" class="btn-accent-solid" style="padding: 8px 20px;">Parse & Preview</button>
|
||||||
|
<button id="compose-cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Preview -->
|
||||||
|
<div id="compose-step-preview" style="display: none;">
|
||||||
|
<div id="compose-preview-content"></div>
|
||||||
|
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;">
|
||||||
|
<button id="compose-deploy-btn" class="btn-accent-solid" style="padding: 8px 20px;">Deploy All</button>
|
||||||
|
<button id="compose-back-btn">Back</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Progress -->
|
||||||
|
<div id="compose-step-progress" style="display: none;">
|
||||||
|
<div id="compose-progress-content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
const modal = document.getElementById('compose-import-modal');
|
||||||
|
const openBtn = document.getElementById('compose-import-btn');
|
||||||
|
const cancelBtn = document.getElementById('compose-cancel');
|
||||||
|
|
||||||
|
wireModal(modal, cancelBtn);
|
||||||
|
|
||||||
|
let parsedData = null;
|
||||||
|
|
||||||
|
function showStep(step) {
|
||||||
|
document.getElementById('compose-step-paste').style.display = step === 'paste' ? '' : 'none';
|
||||||
|
document.getElementById('compose-step-preview').style.display = step === 'preview' ? '' : 'none';
|
||||||
|
document.getElementById('compose-step-progress').style.display = step === 'progress' ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
openBtn?.addEventListener('click', () => {
|
||||||
|
showStep('paste');
|
||||||
|
parsedData = null;
|
||||||
|
document.getElementById('compose-yaml').value = '';
|
||||||
|
document.getElementById('compose-stack-name').value = '';
|
||||||
|
modal?.classList.add('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
// File upload
|
||||||
|
document.getElementById('compose-file-upload')?.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => { document.getElementById('compose-yaml').value = reader.result; };
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse
|
||||||
|
document.getElementById('compose-parse-btn')?.addEventListener('click', async () => {
|
||||||
|
const yamlStr = document.getElementById('compose-yaml').value.trim();
|
||||||
|
const stackName = document.getElementById('compose-stack-name').value.trim() || 'stack';
|
||||||
|
if (!yamlStr) { showNotification('Paste a docker-compose.yml', 'warning'); return; }
|
||||||
|
|
||||||
|
const btn = document.getElementById('compose-parse-btn');
|
||||||
|
const origText = btn.textContent;
|
||||||
|
btn.textContent = 'Parsing...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await postJSON('/api/v1/apps/import-compose', { yaml: yamlStr, stackName });
|
||||||
|
parsedData = data;
|
||||||
|
parsedData.stackName = stackName;
|
||||||
|
renderPreview(data);
|
||||||
|
showStep('preview');
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Parse failed: ' + e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.textContent = origText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPreview(data) {
|
||||||
|
const container = document.getElementById('compose-preview-content');
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
if (data.networks && data.networks.length > 0) {
|
||||||
|
html += `<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${data.networks.map(n => `<code>${escapeHtml(n)}</code>`).join(', ')}</div>`;
|
||||||
|
}
|
||||||
|
if (data.volumes && data.volumes.length > 0) {
|
||||||
|
html += `<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${data.volumes.map(v => `<code>${escapeHtml(v)}</code>`).join(', ')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<div style="font-weight: 600; margin-bottom: 8px;">${data.services.length} service(s)</div>`;
|
||||||
|
html += '<div class="scroll-container" style="max-height: 350px;">';
|
||||||
|
|
||||||
|
for (const svc of data.services) {
|
||||||
|
const borderColor = svc.skip ? 'var(--bad-fg)' : 'var(--border)';
|
||||||
|
html += `<div style="padding: 10px 14px; border: 1px solid ${borderColor}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`;
|
||||||
|
html += `<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(svc.name)}`;
|
||||||
|
if (svc.skip) html += ` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">— skipped: ${escapeHtml(svc.reason)}</span>`;
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
if (!svc.skip) {
|
||||||
|
html += `<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(svc.image)}</code></div>`;
|
||||||
|
if (svc.ports?.length) html += `<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${svc.ports.map(p => `${p.host}:${p.container}`).join(', ')}</div>`;
|
||||||
|
if (svc.volumes?.length) html += `<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${svc.volumes.length}</div>`;
|
||||||
|
if (Object.keys(svc.environment || {}).length) html += `<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(svc.environment).length}</div>`;
|
||||||
|
if (svc.envFileWarning) html += `<div style="font-size: 0.78rem; color: var(--bad-fg);">⚠ ${escapeHtml(svc.envFileWarning)}</div>`;
|
||||||
|
if (svc.resources?.cpus || svc.resources?.memory) {
|
||||||
|
const parts = [];
|
||||||
|
if (svc.resources.cpus) parts.push(`CPU: ${svc.resources.cpus}`);
|
||||||
|
if (svc.resources.memory) parts.push(`Mem: ${svc.resources.memory}MB`);
|
||||||
|
html += `<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${parts.join(', ')}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back button
|
||||||
|
document.getElementById('compose-back-btn')?.addEventListener('click', () => showStep('paste'));
|
||||||
|
|
||||||
|
// Deploy
|
||||||
|
document.getElementById('compose-deploy-btn')?.addEventListener('click', async () => {
|
||||||
|
if (!parsedData) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('compose-deploy-btn');
|
||||||
|
btn.textContent = 'Deploying...';
|
||||||
|
btn.disabled = true;
|
||||||
|
showStep('progress');
|
||||||
|
|
||||||
|
const progressEl = document.getElementById('compose-progress-content');
|
||||||
|
progressEl.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await postJSON('/api/v1/apps/deploy-compose', {
|
||||||
|
services: parsedData.services,
|
||||||
|
networks: parsedData.networks,
|
||||||
|
stackName: parsedData.stackName
|
||||||
|
});
|
||||||
|
|
||||||
|
let html = `<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(result.stackName)}" — Deployment Complete</div>`;
|
||||||
|
html += '<div class="scroll-container" style="max-height: 350px;">';
|
||||||
|
|
||||||
|
for (const r of result.results) {
|
||||||
|
const icon = r.status === 'deployed' || r.status === 'created' ? '✅' : r.status === 'exists' ? '⚡' : r.status === 'skipped' ? '⏭' : '❌';
|
||||||
|
html += `<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">`;
|
||||||
|
html += `${icon} <strong>${escapeHtml(r.name)}</strong> (${r.type}) — ${escapeHtml(r.status)}`;
|
||||||
|
if (r.error) html += ` <span style="color: var(--bad-fg);">${escapeHtml(r.error)}</span>`;
|
||||||
|
if (r.subdomain) html += ` → <code>${escapeHtml(r.subdomain)}</code>`;
|
||||||
|
if (r.reason) html += ` <span style="color: var(--muted);">(${escapeHtml(r.reason)})</span>`;
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
html += '<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>';
|
||||||
|
|
||||||
|
progressEl.innerHTML = html;
|
||||||
|
document.getElementById('compose-done-btn')?.addEventListener('click', () => {
|
||||||
|
modal?.classList.remove('show');
|
||||||
|
if (typeof window.loadServices === 'function') window.loadServices().then(() => { if (typeof window.buildGrid === 'function') window.buildGrid(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
showNotification(`Stack "${result.stackName}" deployed`, 'success');
|
||||||
|
} catch (e) {
|
||||||
|
progressEl.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Deployment failed: ${escapeHtml(e.message)}</div>
|
||||||
|
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`;
|
||||||
|
document.getElementById('compose-retry-btn')?.addEventListener('click', () => showStep('paste'));
|
||||||
|
} finally {
|
||||||
|
btn.textContent = 'Deploy All';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
157
status/js/container-exec.js
Normal file
157
status/js/container-exec.js
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
// ========== CONTAINER EXEC / SHELL (WebSocket + xterm.js) ==========
|
||||||
|
(function() {
|
||||||
|
injectModal('exec-modal', `<div id="exec-modal" class="weather-modal">
|
||||||
|
<div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
|
||||||
|
<h3 id="exec-title">Terminal</h3>
|
||||||
|
<div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div>
|
||||||
|
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 8px; padding-bottom: 12px;">
|
||||||
|
<button id="exec-close">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
const modal = document.getElementById('exec-modal');
|
||||||
|
const termEl = document.getElementById('exec-terminal');
|
||||||
|
const closeBtn = document.getElementById('exec-close');
|
||||||
|
|
||||||
|
let term = null;
|
||||||
|
let ws = null;
|
||||||
|
let fitAddon = null;
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (ws) { try { ws.close(); } catch (_) {} ws = null; }
|
||||||
|
if (term) { try { term.dispose(); } catch (_) {} term = null; }
|
||||||
|
fitAddon = null;
|
||||||
|
termEl.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openExec(containerId, containerName) {
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
document.getElementById('exec-title').textContent = `Terminal — ${containerName || containerId}`;
|
||||||
|
modal?.classList.add('show');
|
||||||
|
|
||||||
|
// Ensure xterm is available
|
||||||
|
if (typeof Terminal === 'undefined') {
|
||||||
|
termEl.innerHTML = '<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "'Cascadia Code', 'Fira Code', 'Consolas', monospace",
|
||||||
|
theme: {
|
||||||
|
background: '#1e1e1e',
|
||||||
|
foreground: '#d4d4d4',
|
||||||
|
cursor: '#aeafad',
|
||||||
|
selectionBackground: '#264f78',
|
||||||
|
},
|
||||||
|
scrollback: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fit addon
|
||||||
|
if (typeof FitAddon !== 'undefined') {
|
||||||
|
fitAddon = new FitAddon.FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
}
|
||||||
|
|
||||||
|
term.open(termEl);
|
||||||
|
if (fitAddon) {
|
||||||
|
// Small delay for DOM to settle
|
||||||
|
setTimeout(() => fitAddon.fit(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect WebSocket
|
||||||
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
ws = new WebSocket(`${protocol}//${location.host}/ws/exec/${encodeURIComponent(containerId)}`);
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
term.writeln('\x1b[32mConnecting...\x1b[0m');
|
||||||
|
// Send initial resize
|
||||||
|
if (fitAddon) {
|
||||||
|
const dims = fitAddon.proposeDimensions();
|
||||||
|
if (dims) {
|
||||||
|
ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
if (typeof e.data === 'string') {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.type === 'connected') {
|
||||||
|
term.writeln(`\x1b[32mConnected (${msg.shell})\x1b[0m\r\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === 'error') {
|
||||||
|
term.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === 'exit') {
|
||||||
|
term.writeln('\r\n\x1b[33mSession ended.\x1b[0m');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
// Plain text
|
||||||
|
term.write(e.data);
|
||||||
|
} else {
|
||||||
|
// Binary data
|
||||||
|
term.write(new Uint8Array(e.data));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (term) term.writeln('\r\n\x1b[33mDisconnected.\x1b[0m');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
if (term) term.writeln('\r\n\x1b[31mConnection error.\x1b[0m');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Terminal input → WebSocket
|
||||||
|
term.onData((data) => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle resize
|
||||||
|
term.onResize(({ cols, rows }) => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-fit on window resize
|
||||||
|
const resizeHandler = () => { if (fitAddon) fitAddon.fit(); };
|
||||||
|
window.addEventListener('resize', resizeHandler);
|
||||||
|
|
||||||
|
// Store handler for cleanup
|
||||||
|
modal._resizeHandler = resizeHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeBtn?.addEventListener('click', () => {
|
||||||
|
cleanup();
|
||||||
|
if (modal._resizeHandler) {
|
||||||
|
window.removeEventListener('resize', modal._resizeHandler);
|
||||||
|
}
|
||||||
|
modal?.classList.remove('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also close on backdrop click
|
||||||
|
modal?.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
cleanup();
|
||||||
|
if (modal._resizeHandler) {
|
||||||
|
window.removeEventListener('resize', modal._resizeHandler);
|
||||||
|
}
|
||||||
|
modal?.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export
|
||||||
|
window.openExecModal = openExec;
|
||||||
|
})();
|
||||||
@@ -211,6 +211,15 @@
|
|||||||
window.updateContainer(s.containerId, s.name, s.id);
|
window.updateContainer(s.containerId, s.name, s.id);
|
||||||
};
|
};
|
||||||
btnRow.appendChild(updateBtn);
|
btnRow.appendChild(updateBtn);
|
||||||
|
|
||||||
|
// Terminal exec button (subtle — visible on hover)
|
||||||
|
const execBtn = el('button', 'exec-btn', '>_');
|
||||||
|
execBtn.title = 'Open terminal';
|
||||||
|
execBtn.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (window.openExecModal) window.openExecModal(s.containerId, s.name);
|
||||||
|
};
|
||||||
|
btnRow.appendChild(execBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add logs button for services with logPath (native apps)
|
// Add logs button for services with logPath (native apps)
|
||||||
|
|||||||
228
status/js/docker-resources.js
Normal file
228
status/js/docker-resources.js
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
// ========== DOCKER RESOURCES (Volumes, Networks, Disk Usage) ==========
|
||||||
|
(function() {
|
||||||
|
injectModal('docker-resources-modal', `<div id="docker-resources-modal" class="weather-modal">
|
||||||
|
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
||||||
|
<h3>🐳 Docker Resources</h3>
|
||||||
|
<p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p>
|
||||||
|
|
||||||
|
<div class="panel-tabs">
|
||||||
|
<button class="panel-tab active" data-panel="dr-volumes">Volumes</button>
|
||||||
|
<button class="panel-tab" data-panel="dr-networks">Networks</button>
|
||||||
|
<button class="panel-tab" data-panel="dr-disk">Disk Usage</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Volumes -->
|
||||||
|
<div id="dr-volumes" class="panel-section active">
|
||||||
|
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
|
||||||
|
<input type="text" id="dr-vol-name" placeholder="Volume name" style="flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);" />
|
||||||
|
<button id="dr-vol-create" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.82rem;">Create</button>
|
||||||
|
</div>
|
||||||
|
<div id="dr-vol-list" class="scroll-container" style="max-height: 400px;">
|
||||||
|
<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Networks -->
|
||||||
|
<div id="dr-networks" class="panel-section">
|
||||||
|
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
|
||||||
|
<input type="text" id="dr-net-name" placeholder="Network name" style="flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);" />
|
||||||
|
<select id="dr-net-driver" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);">
|
||||||
|
<option value="bridge">bridge</option>
|
||||||
|
<option value="overlay">overlay</option>
|
||||||
|
<option value="host">host</option>
|
||||||
|
</select>
|
||||||
|
<button id="dr-net-create" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.82rem;">Create</button>
|
||||||
|
</div>
|
||||||
|
<div id="dr-net-list" class="scroll-container" style="max-height: 400px;">
|
||||||
|
<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Disk Usage -->
|
||||||
|
<div id="dr-disk" class="panel-section">
|
||||||
|
<div id="dr-disk-content">
|
||||||
|
<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="weather-modal-buttons modal-footer-bar">
|
||||||
|
<button id="dr-close">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
const modal = document.getElementById('docker-resources-modal');
|
||||||
|
const openBtn = document.getElementById('docker-resources-btn');
|
||||||
|
const closeBtn = document.getElementById('dr-close');
|
||||||
|
|
||||||
|
function fmtBytes(bytes) {
|
||||||
|
if (!bytes || bytes === 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024));
|
||||||
|
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== VOLUMES =====
|
||||||
|
async function loadVolumes() {
|
||||||
|
const container = document.getElementById('dr-vol-list');
|
||||||
|
try {
|
||||||
|
const data = await getJSON('/api/v1/docker/volumes');
|
||||||
|
const vols = data.volumes || [];
|
||||||
|
if (vols.length === 0) {
|
||||||
|
container.innerHTML = '<div class="panel-empty"><span class="empty-icon">📦</span>No volumes found.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';
|
||||||
|
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';
|
||||||
|
for (const v of vols) {
|
||||||
|
const isSystem = v.name === 'buildkit' || v.name.length === 64;
|
||||||
|
html += `<tr style="border-bottom: 1px solid var(--border);">`;
|
||||||
|
html += `<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(v.name)}">${escapeHtml(v.name.length > 40 ? v.name.substring(0, 37) + '...' : v.name)}</td>`;
|
||||||
|
html += `<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(v.driver)}</td>`;
|
||||||
|
html += `<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(v.scope)}</td>`;
|
||||||
|
html += `<td style="padding: 6px; text-align: right;">`;
|
||||||
|
if (!isSystem) {
|
||||||
|
html += `<button class="dr-vol-del" data-name="${escapeHtml(v.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`;
|
||||||
|
}
|
||||||
|
html += `</td></tr>`;
|
||||||
|
}
|
||||||
|
html += '</table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
container.querySelectorAll('.dr-vol-del').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
if (!confirm(`Delete volume "${btn.dataset.name}"? Data will be lost.`)) return;
|
||||||
|
btn.textContent = '...';
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(btn.dataset.name)}?force=true`);
|
||||||
|
loadVolumes();
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Delete failed: ' + e.message, 'error');
|
||||||
|
btn.textContent = 'Delete';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('dr-vol-create')?.addEventListener('click', async () => {
|
||||||
|
const nameInput = document.getElementById('dr-vol-name');
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
if (!name) { showNotification('Enter a volume name', 'warning'); return; }
|
||||||
|
try {
|
||||||
|
await postJSON('/api/v1/docker/volumes', { name });
|
||||||
|
nameInput.value = '';
|
||||||
|
showNotification(`Volume "${name}" created`, 'success');
|
||||||
|
loadVolumes();
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Create failed: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== NETWORKS =====
|
||||||
|
async function loadNetworks() {
|
||||||
|
const container = document.getElementById('dr-net-list');
|
||||||
|
try {
|
||||||
|
const data = await getJSON('/api/v1/docker/networks');
|
||||||
|
const nets = data.networks || [];
|
||||||
|
if (nets.length === 0) {
|
||||||
|
container.innerHTML = '<div class="panel-empty"><span class="empty-icon">🌐</span>No networks found.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';
|
||||||
|
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';
|
||||||
|
for (const n of nets) {
|
||||||
|
const isSystem = ['bridge', 'host', 'none'].includes(n.name);
|
||||||
|
html += `<tr style="border-bottom: 1px solid var(--border);">`;
|
||||||
|
html += `<td style="padding: 6px; font-weight: 500;">${escapeHtml(n.name)}</td>`;
|
||||||
|
html += `<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(n.driver)}</td>`;
|
||||||
|
html += `<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(n.scope)}</td>`;
|
||||||
|
html += `<td style="padding: 6px; text-align: center;">${n.containers}</td>`;
|
||||||
|
html += `<td style="padding: 6px; text-align: right;">`;
|
||||||
|
if (!isSystem) {
|
||||||
|
html += `<button class="dr-net-del" data-id="${escapeHtml(n.id)}" data-name="${escapeHtml(n.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`;
|
||||||
|
}
|
||||||
|
html += `</td></tr>`;
|
||||||
|
}
|
||||||
|
html += '</table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
container.querySelectorAll('.dr-net-del').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
if (!confirm(`Delete network "${btn.dataset.name}"?`)) return;
|
||||||
|
btn.textContent = '...';
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(btn.dataset.id)}`);
|
||||||
|
loadNetworks();
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Delete failed: ' + e.message, 'error');
|
||||||
|
btn.textContent = 'Delete';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('dr-net-create')?.addEventListener('click', async () => {
|
||||||
|
const nameInput = document.getElementById('dr-net-name');
|
||||||
|
const driverSelect = document.getElementById('dr-net-driver');
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
if (!name) { showNotification('Enter a network name', 'warning'); return; }
|
||||||
|
try {
|
||||||
|
await postJSON('/api/v1/docker/networks', { name, driver: driverSelect.value });
|
||||||
|
nameInput.value = '';
|
||||||
|
showNotification(`Network "${name}" created`, 'success');
|
||||||
|
loadNetworks();
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Create failed: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== DISK USAGE =====
|
||||||
|
async function loadDiskUsage() {
|
||||||
|
const container = document.getElementById('dr-disk-content');
|
||||||
|
try {
|
||||||
|
const data = await getJSON('/api/v1/docker/disk-usage');
|
||||||
|
const sections = [
|
||||||
|
{ label: 'Images', icon: '📀', count: data.images.count, size: data.images.size, reclaimable: data.images.reclaimable },
|
||||||
|
{ label: 'Containers', icon: '📦', count: data.containers.count, size: data.containers.size, extra: `${data.containers.running} running` },
|
||||||
|
{ label: 'Volumes', icon: '💾', count: data.volumes.count, size: data.volumes.size, reclaimable: data.volumes.reclaimable },
|
||||||
|
{ label: 'Build Cache', icon: '🔧', count: data.buildCache.count, size: data.buildCache.size, reclaimable: data.buildCache.reclaimable },
|
||||||
|
];
|
||||||
|
|
||||||
|
let html = `<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 16px;">Total: ${fmtBytes(data.totalSize)}</div>`;
|
||||||
|
html += '<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">';
|
||||||
|
for (const s of sections) {
|
||||||
|
html += `<div style="padding: 14px; background: var(--bg); border-radius: 8px; border: 1px solid var(--border);">`;
|
||||||
|
html += `<div style="font-weight: 600; margin-bottom: 6px;">${s.icon} ${s.label} <span style="color: var(--muted); font-weight: 400; font-size: 0.82rem;">(${s.count})</span></div>`;
|
||||||
|
html += `<div style="font-size: 1.1rem; font-weight: 600; color: var(--accent);">${fmtBytes(s.size)}</div>`;
|
||||||
|
if (s.reclaimable > 0) html += `<div style="font-size: 0.78rem; color: var(--muted);">Reclaimable: ${fmtBytes(s.reclaimable)}</div>`;
|
||||||
|
if (s.extra) html += `<div style="font-size: 0.78rem; color: var(--muted);">${s.extra}</div>`;
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal events
|
||||||
|
openBtn?.addEventListener('click', () => {
|
||||||
|
modal?.classList.add('show');
|
||||||
|
loadVolumes();
|
||||||
|
});
|
||||||
|
wireModal(modal, closeBtn);
|
||||||
|
|
||||||
|
// Lazy-load tabs
|
||||||
|
document.querySelector('[data-panel="dr-networks"]')?.addEventListener('click', loadNetworks);
|
||||||
|
document.querySelector('[data-panel="dr-disk"]')?.addEventListener('click', loadDiskUsage);
|
||||||
|
})();
|
||||||
115
status/js/live-events.js
Normal file
115
status/js/live-events.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// ========== LIVE DASHBOARD EVENTS (SSE) ==========
|
||||||
|
(function() {
|
||||||
|
let es = null;
|
||||||
|
let reconnectDelay = 1000;
|
||||||
|
const MAX_RECONNECT = 30000;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (es) { try { es.close(); } catch (_) {} }
|
||||||
|
|
||||||
|
es = new EventSource('/api/v1/events/stream');
|
||||||
|
|
||||||
|
es.addEventListener('connected', () => {
|
||||||
|
reconnectDelay = 1000; // reset backoff
|
||||||
|
console.log('[SSE] Connected to event stream');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health status changes → update card dots/badges in real time
|
||||||
|
es.addEventListener('status-change', (e) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
if (d.serviceId && typeof window.setBadge === 'function') {
|
||||||
|
const up = d.status === 'up' || d.status === 'healthy';
|
||||||
|
window.setBadge(d.serviceId, up, d.responseTime || null);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resource alerts → toast notification
|
||||||
|
es.addEventListener('resource-alert', (e) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
const msg = `${d.containerName || d.containerId}: ${d.metric} at ${d.value}% (threshold: ${d.threshold}%)`;
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification(msg, 'warning');
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Container auto-restart
|
||||||
|
es.addEventListener('auto-restart', (e) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification(`Container "${d.containerName}" was auto-restarted`, 'info');
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update available → show notification dot on Updates button
|
||||||
|
es.addEventListener('update-available', (e) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
const updatesBtn = document.getElementById('updates-btn');
|
||||||
|
if (updatesBtn && !updatesBtn.querySelector('.sse-dot')) {
|
||||||
|
const dot = document.createElement('span');
|
||||||
|
dot.className = 'sse-dot';
|
||||||
|
dot.style.cssText = 'display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--accent);margin-left:6px;vertical-align:middle;';
|
||||||
|
updatesBtn.appendChild(dot);
|
||||||
|
}
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification(`Update available for ${d.containerName || d.containerId}`, 'info');
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update start/complete/failed
|
||||||
|
es.addEventListener('update-complete', (e) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification(`Update completed: ${d.containerName || d.containerId}`, 'success');
|
||||||
|
}
|
||||||
|
// Trigger a dashboard refresh
|
||||||
|
if (typeof window.refreshAll === 'function') window.refreshAll();
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('update-failed', (e) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification(`Update failed: ${d.containerName || d.containerId} — ${d.error || 'unknown error'}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Incidents
|
||||||
|
es.addEventListener('incident', (e) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
if (d.type === 'created') {
|
||||||
|
showNotification(`Incident: ${d.message || d.serviceId}`, 'error');
|
||||||
|
} else if (d.type === 'resolved') {
|
||||||
|
showNotification(`Resolved: ${d.serviceId || 'incident'}`, 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reconnect on error
|
||||||
|
es.onerror = () => {
|
||||||
|
es.close();
|
||||||
|
console.warn(`[SSE] Disconnected, reconnecting in ${reconnectDelay / 1000}s...`);
|
||||||
|
setTimeout(connect, reconnectDelay);
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start on page load
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// Expose for debugging
|
||||||
|
window._sseReconnect = connect;
|
||||||
|
})();
|
||||||
@@ -80,6 +80,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div class="notification-provider provider-card">
|
||||||
|
<div class="provider-header">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="email-enabled" />
|
||||||
|
<span class="fw-500">Email (SMTP)</span>
|
||||||
|
</label>
|
||||||
|
<button id="email-test" class="test-btn btn-xs">Test</button>
|
||||||
|
</div>
|
||||||
|
<div id="email-config" style="display: none;">
|
||||||
|
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px; margin-bottom: 8px;">
|
||||||
|
<div>
|
||||||
|
<label class="field-label-sm">SMTP Host:</label>
|
||||||
|
<input type="text" id="email-host" placeholder="smtp.gmail.com" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="field-label-sm">Port:</label>
|
||||||
|
<input type="number" id="email-port" value="587" placeholder="587" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px;">
|
||||||
|
<div>
|
||||||
|
<label class="field-label-sm">Username:</label>
|
||||||
|
<input type="text" id="email-user" placeholder="user@gmail.com" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="field-label-sm">Password:</label>
|
||||||
|
<input type="password" id="email-pass" placeholder="app password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||||||
|
<div>
|
||||||
|
<label class="field-label-sm">From:</label>
|
||||||
|
<input type="text" id="email-from" placeholder="DashCaddy <noreply@example.com>" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="field-label-sm">To:</label>
|
||||||
|
<input type="text" id="email-to" placeholder="admin@example.com" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label style="display: flex; align-items: center; gap: 6px; margin-top: 8px; font-size: 0.8rem;">
|
||||||
|
<input type="checkbox" id="email-secure" /> Use TLS (port 465)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Health Check Settings -->
|
<!-- Health Check Settings -->
|
||||||
<h4 class="section-heading">Health Monitoring</h4>
|
<h4 class="section-heading">Health Monitoring</h4>
|
||||||
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||||
@@ -143,7 +189,7 @@
|
|||||||
const cancelBtn = document.getElementById('notifications-cancel');
|
const cancelBtn = document.getElementById('notifications-cancel');
|
||||||
|
|
||||||
// Provider toggle handlers
|
// Provider toggle handlers
|
||||||
['discord', 'telegram', 'ntfy'].forEach(provider => {
|
['discord', 'telegram', 'ntfy', 'email'].forEach(provider => {
|
||||||
const checkbox = document.getElementById(`${provider}-enabled`);
|
const checkbox = document.getElementById(`${provider}-enabled`);
|
||||||
const config = document.getElementById(`${provider}-config`);
|
const config = document.getElementById(`${provider}-config`);
|
||||||
|
|
||||||
@@ -175,17 +221,23 @@
|
|||||||
document.getElementById('discord-enabled').checked = config.providers?.discord?.enabled || false;
|
document.getElementById('discord-enabled').checked = config.providers?.discord?.enabled || false;
|
||||||
document.getElementById('telegram-enabled').checked = config.providers?.telegram?.enabled || false;
|
document.getElementById('telegram-enabled').checked = config.providers?.telegram?.enabled || false;
|
||||||
document.getElementById('ntfy-enabled').checked = config.providers?.ntfy?.enabled || false;
|
document.getElementById('ntfy-enabled').checked = config.providers?.ntfy?.enabled || false;
|
||||||
|
document.getElementById('email-enabled').checked = config.providers?.email?.enabled || false;
|
||||||
|
|
||||||
// Show/hide config sections
|
// Show/hide config sections
|
||||||
document.getElementById('discord-config').style.display = config.providers?.discord?.enabled ? 'block' : 'none';
|
document.getElementById('discord-config').style.display = config.providers?.discord?.enabled ? 'block' : 'none';
|
||||||
document.getElementById('telegram-config').style.display = config.providers?.telegram?.enabled ? 'block' : 'none';
|
document.getElementById('telegram-config').style.display = config.providers?.telegram?.enabled ? 'block' : 'none';
|
||||||
document.getElementById('ntfy-config').style.display = config.providers?.ntfy?.enabled ? 'block' : 'none';
|
document.getElementById('ntfy-config').style.display = config.providers?.ntfy?.enabled ? 'block' : 'none';
|
||||||
|
document.getElementById('email-config').style.display = config.providers?.email?.enabled ? 'block' : 'none';
|
||||||
|
|
||||||
// ntfy server URL
|
// ntfy server URL
|
||||||
if (config.providers?.ntfy?.serverUrl) {
|
if (config.providers?.ntfy?.serverUrl) {
|
||||||
document.getElementById('ntfy-server').value = config.providers.ntfy.serverUrl;
|
document.getElementById('ntfy-server').value = config.providers.ntfy.serverUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// email fields
|
||||||
|
if (config.providers?.email?.host) document.getElementById('email-host').value = config.providers.email.host;
|
||||||
|
if (config.providers?.email?.from) document.getElementById('email-from').value = config.providers.email.from;
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
document.getElementById('health-check-enabled').checked = config.healthCheck?.enabled || false;
|
document.getElementById('health-check-enabled').checked = config.healthCheck?.enabled || false;
|
||||||
if (config.healthCheck?.intervalMinutes) {
|
if (config.healthCheck?.intervalMinutes) {
|
||||||
@@ -260,6 +312,16 @@
|
|||||||
enabled: document.getElementById('ntfy-enabled').checked,
|
enabled: document.getElementById('ntfy-enabled').checked,
|
||||||
serverUrl: document.getElementById('ntfy-server').value.trim() || 'https://ntfy.sh',
|
serverUrl: document.getElementById('ntfy-server').value.trim() || 'https://ntfy.sh',
|
||||||
topic: document.getElementById('ntfy-topic').value.trim()
|
topic: document.getElementById('ntfy-topic').value.trim()
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
enabled: document.getElementById('email-enabled').checked,
|
||||||
|
host: document.getElementById('email-host').value.trim(),
|
||||||
|
port: parseInt(document.getElementById('email-port').value) || 587,
|
||||||
|
secure: document.getElementById('email-secure').checked,
|
||||||
|
user: document.getElementById('email-user').value.trim(),
|
||||||
|
pass: document.getElementById('email-pass').value.trim(),
|
||||||
|
from: document.getElementById('email-from').value.trim(),
|
||||||
|
to: document.getElementById('email-to').value.trim()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
@@ -315,6 +377,7 @@
|
|||||||
document.getElementById('discord-test')?.addEventListener('click', () => testProvider('discord'));
|
document.getElementById('discord-test')?.addEventListener('click', () => testProvider('discord'));
|
||||||
document.getElementById('telegram-test')?.addEventListener('click', () => testProvider('telegram'));
|
document.getElementById('telegram-test')?.addEventListener('click', () => testProvider('telegram'));
|
||||||
document.getElementById('ntfy-test')?.addEventListener('click', () => testProvider('ntfy'));
|
document.getElementById('ntfy-test')?.addEventListener('click', () => testProvider('ntfy'));
|
||||||
|
document.getElementById('email-test')?.addEventListener('click', () => testProvider('email'));
|
||||||
|
|
||||||
// Health check now button
|
// Health check now button
|
||||||
document.getElementById('health-check-now')?.addEventListener('click', async () => {
|
document.getElementById('health-check-now')?.addEventListener('click', async () => {
|
||||||
|
|||||||
@@ -226,29 +226,47 @@
|
|||||||
async function loadAutoConfig() {
|
async function loadAutoConfig() {
|
||||||
try {
|
try {
|
||||||
autoContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
|
autoContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
|
||||||
// Get running containers to show auto-update toggles
|
|
||||||
const res = await fetch('/api/v1/stats/containers');
|
// Fetch containers and saved auto-update config in parallel
|
||||||
const data = await res.json();
|
const [containersRes, configRes] = await Promise.all([
|
||||||
const containers = data.success && data.stats ? data.stats : [];
|
fetch('/api/v1/stats/containers'),
|
||||||
|
fetch('/api/v1/updates/auto-update')
|
||||||
|
]);
|
||||||
|
const containersData = await containersRes.json();
|
||||||
|
const configData = await configRes.json();
|
||||||
|
|
||||||
|
const containers = containersData.success && containersData.stats ? containersData.stats : [];
|
||||||
|
const savedConfig = configData.success && configData.config ? configData.config : {};
|
||||||
|
|
||||||
if (containers.length === 0) {
|
if (containers.length === 0) {
|
||||||
autoContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">🤖</span>No running containers found.</div>';
|
autoContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">🤖</span>No running containers found.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
|
|
||||||
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Schedule</th><th style="padding: 8px; text-align: left;">Auto-Rollback</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';
|
let html = '<div style="margin-bottom: 12px; font-size: 0.8rem; color: var(--muted);">Auto-updates run during maintenance window (default 2AM-4AM). Daily = every day, Weekly = Sundays, Monthly = 1st of month.</div>';
|
||||||
|
html += '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
|
||||||
|
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Schedule</th><th style="padding: 8px; text-align: left;">Window</th><th style="padding: 8px; text-align: left;">Rollback</th><th style="padding: 8px; text-align: left;">Last Run</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';
|
||||||
for (const c of containers) {
|
for (const c of containers) {
|
||||||
const name = c.name || c.Names?.[0]?.replace(/^\//, '') || c.Id?.substring(0, 12);
|
const name = c.name || c.Names?.[0]?.replace(/^\//, '') || c.Id?.substring(0, 12);
|
||||||
const cid = c.containerId || c.Id;
|
const cid = c.containerId || c.Id;
|
||||||
|
const saved = savedConfig[cid] || {};
|
||||||
|
const scheduleVal = saved.enabled ? (saved.schedule || 'weekly') : '';
|
||||||
|
const rollbackVal = saved.autoRollback !== false;
|
||||||
|
const windowVal = saved.maintenanceWindow || '';
|
||||||
|
const lastRun = saved.lastAutoUpdate ? timeAgo(saved.lastAutoUpdate) : 'Never';
|
||||||
|
|
||||||
html += `<tr style="border-bottom: 1px solid var(--border);" data-container-id="${escapeHtml(cid)}">`;
|
html += `<tr style="border-bottom: 1px solid var(--border);" data-container-id="${escapeHtml(cid)}">`;
|
||||||
html += `<td style="padding: 8px; font-weight: 500;">${escapeHtml(name)}</td>`;
|
html += `<td style="padding: 8px; font-weight: 500;">${escapeHtml(name)}</td>`;
|
||||||
html += `<td style="padding: 8px;">
|
html += `<td style="padding: 8px;">
|
||||||
<select class="auto-schedule" data-id="${escapeHtml(cid)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
<select class="auto-schedule" data-id="${escapeHtml(cid)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
||||||
<option value="">Disabled</option>
|
<option value=""${!scheduleVal ? ' selected' : ''}>Disabled</option>
|
||||||
<option value="daily">Daily</option>
|
<option value="daily"${scheduleVal === 'daily' ? ' selected' : ''}>Daily</option>
|
||||||
<option value="weekly">Weekly</option>
|
<option value="weekly"${scheduleVal === 'weekly' ? ' selected' : ''}>Weekly</option>
|
||||||
<option value="monthly">Monthly</option>
|
<option value="monthly"${scheduleVal === 'monthly' ? ' selected' : ''}>Monthly</option>
|
||||||
</select></td>`;
|
</select></td>`;
|
||||||
html += `<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(cid)}" checked /></td>`;
|
html += `<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(cid)}" value="${escapeHtml(windowVal)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`;
|
||||||
|
html += `<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(cid)}"${rollbackVal ? ' checked' : ''} /></td>`;
|
||||||
|
html += `<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${lastRun}</td>`;
|
||||||
html += `<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(cid)}" data-name="${escapeHtml(name)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`;
|
html += `<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(cid)}" data-name="${escapeHtml(name)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`;
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
}
|
}
|
||||||
@@ -262,13 +280,14 @@
|
|||||||
const row = btn.closest('tr');
|
const row = btn.closest('tr');
|
||||||
const schedule = row.querySelector('.auto-schedule').value;
|
const schedule = row.querySelector('.auto-schedule').value;
|
||||||
const rollback = row.querySelector('.auto-rollback').checked;
|
const rollback = row.querySelector('.auto-rollback').checked;
|
||||||
|
const window = row.querySelector('.auto-window').value.trim();
|
||||||
btn.textContent = 'Saving...';
|
btn.textContent = 'Saving...';
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const r = await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(id)}`, {
|
const r = await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(id)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ enabled: !!schedule, schedule: schedule || 'weekly', autoRollback: rollback })
|
body: JSON.stringify({ enabled: !!schedule, schedule: schedule || 'weekly', autoRollback: rollback, maintenanceWindow: window || undefined })
|
||||||
});
|
});
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.success) {
|
if (d.success) {
|
||||||
|
|||||||
2
status/js/xterm-fit.min.js
vendored
Normal file
2
status/js/xterm-fit.min.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||||
|
//# sourceMappingURL=addon-fit.js.map
|
||||||
2
status/js/xterm.min.js
vendored
Normal file
2
status/js/xterm.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user