Compare commits
3
Commits
7f0d43943c
..
v1.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afcccf811e | ||
|
|
0aa7244cf4 | ||
|
|
1d8919532b |
@@ -1 +1 @@
|
|||||||
dev
|
1.8.0
|
||||||
|
|||||||
@@ -2459,74 +2459,6 @@ const APP_TEMPLATES = {
|
|||||||
"World data is persisted in the data volume",
|
"World data is persisted in the data volume",
|
||||||
"Requires at least 4GB RAM for smooth operation"
|
"Requires at least 4GB RAM for smooth operation"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
// === FILE MANAGEMENT — HOST-SERVICE TEMPLATES ===
|
|
||||||
// Sami Files is a host-systemd service, NOT a Docker container. The
|
|
||||||
// template exists so users get the right metadata + category in the App
|
|
||||||
// Selector, but the actual deployment is via `deploy/sami-files.service`
|
|
||||||
// unit + a Caddy reverse_proxy (see README in /opt/sami-files/deploy/).
|
|
||||||
// Service health is checked by probing the FastAPI /api/health endpoint
|
|
||||||
// on 127.0.0.1:8765; Caddy proxies the public URL.
|
|
||||||
"sami-files": {
|
|
||||||
name: "Sami Files",
|
|
||||||
description: "Multi-server SSH file manager — browse, edit, upload, and exec across all your machines from one browser tab",
|
|
||||||
icon: "📂",
|
|
||||||
logo: "/assets/sami-files.png",
|
|
||||||
category: "Files",
|
|
||||||
popularity: 80,
|
|
||||||
difficulty: "Intermediate",
|
|
||||||
isSystemdService: true,
|
|
||||||
systemdUnit: "sami-files.service",
|
|
||||||
healthCheck: "http://127.0.0.1:8765/api/health",
|
|
||||||
healthCheckExpect: "ok",
|
|
||||||
defaultPort: 8765,
|
|
||||||
subdomain: "files",
|
|
||||||
proxyPass: "http://127.0.0.1:8765",
|
|
||||||
subpathSupport: 'none',
|
|
||||||
externalConfig: {
|
|
||||||
// Where the source code / config lives on the host. DashCaddy reads
|
|
||||||
// these paths when generating a fresh setup via "Deploy" in the App
|
|
||||||
// Selector — they are informational for the systemd variant.
|
|
||||||
installDir: "/opt/sami-files",
|
|
||||||
configFile: "/opt/sami-files/config/servers.yaml",
|
|
||||||
serviceFile: "/opt/sami-files/deploy/sami-files.service",
|
|
||||||
logFile: "/opt/sami-files/logs/backend.log",
|
|
||||||
pythonVenv: "/usr/local/lib/hermes-agent/venv",
|
|
||||||
repo: "git.sami/sami7777/sami-files",
|
|
||||||
dependencies: [
|
|
||||||
"python3 >= 3.11 with uvicorn + asyncssh + pyyaml + fastapi",
|
|
||||||
"systemd >= 245 (for StandardOutput=append: journal syntax)"
|
|
||||||
],
|
|
||||||
caddySnippet: [
|
|
||||||
"files.sami {",
|
|
||||||
" reverse_proxy 127.0.0.1:8765",
|
|
||||||
" import dashcaddy_auth",
|
|
||||||
"}"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
setupInstructions: [
|
|
||||||
"Clone the repo: git clone http://100.81.59.99:3030/sami7777/sami-files.git /opt/sami-files",
|
|
||||||
"Create venv and install deps: /usr/local/lib/hermes-agent/venv/bin/pip install fastapi uvicorn asyncssh pyyaml python-multipart",
|
|
||||||
"Copy deploy/sami-files.service to /etc/systemd/system/ and `systemctl daemon-reload`",
|
|
||||||
"Enable + start: systemctl enable --now sami-files.service",
|
|
||||||
"Edit /opt/sami-files/config/servers.yaml to add your SSH targets",
|
|
||||||
"Add the Caddy snippet (above) to your Caddyfile and reload Caddy",
|
|
||||||
"Browse to https://files.sami — log in via DashCaddy SSO"
|
|
||||||
],
|
|
||||||
troubleshooting: [
|
|
||||||
{
|
|
||||||
symptom: "Service fails to start with 'No such file or directory'",
|
|
||||||
fix: "Verify the python venv path in the .service file matches your installation (use `which python3` and update ExecStart accordingly)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
symptom: "Backend logs show 'Permission denied' on key file",
|
|
||||||
fix: "Run `chmod 600 /root/.ssh/<key>` for each key_file listed in servers.yaml — backend refuses to load keys with looser permissions."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
symptom: "Browser shows 'Cannot connect' but systemctl says running",
|
|
||||||
fix: "Check that uvicorn is binding 127.0.0.1:8765 (not 0.0.0.0). Use `ss -tlnp | grep 8765` to confirm."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+328
-82
@@ -9,15 +9,6 @@ const { execSync } = require('child_process');
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
|
|
||||||
// Format bytes to human readable string
|
|
||||||
function formatBytes(bytes) {
|
|
||||||
if (bytes === 0 || bytes === undefined || bytes === null) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
const BACKUP_CONFIG_FILE = process.env.BACKUP_CONFIG_FILE || path.join(__dirname, 'backup-config.json');
|
const BACKUP_CONFIG_FILE = process.env.BACKUP_CONFIG_FILE || path.join(__dirname, 'backup-config.json');
|
||||||
const BACKUP_HISTORY_FILE = process.env.BACKUP_HISTORY_FILE || path.join(__dirname, 'backup-history.json');
|
const BACKUP_HISTORY_FILE = process.env.BACKUP_HISTORY_FILE || path.join(__dirname, 'backup-history.json');
|
||||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups');
|
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups');
|
||||||
@@ -29,6 +20,14 @@ class BackupManager extends EventEmitter {
|
|||||||
this.history = this.loadHistory();
|
this.history = this.loadHistory();
|
||||||
this.scheduledJobs = new Map();
|
this.scheduledJobs = new Map();
|
||||||
this.running = false;
|
this.running = false;
|
||||||
|
this.notificationManager = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the notification manager for sending backup notifications
|
||||||
|
*/
|
||||||
|
setNotificationManager(nm) {
|
||||||
|
this.notificationManager = nm;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,7 +83,7 @@ class BackupManager extends EventEmitter {
|
|||||||
case 'monthly':
|
case 'monthly':
|
||||||
intervalMs = 30 * 24 * 60 * 60 * 1000;
|
intervalMs = 30 * 24 * 60 * 60 * 1000;
|
||||||
break;
|
break;
|
||||||
default:
|
default: {
|
||||||
// Custom interval in minutes
|
// Custom interval in minutes
|
||||||
const minutes = parseInt(backup.schedule, 10);
|
const minutes = parseInt(backup.schedule, 10);
|
||||||
if (!isNaN(minutes) && minutes > 0) {
|
if (!isNaN(minutes) && minutes > 0) {
|
||||||
@@ -93,6 +92,7 @@ class BackupManager extends EventEmitter {
|
|||||||
console.error(`[BackupManager] Invalid schedule for ${name}: ${backup.schedule}`);
|
console.error(`[BackupManager] Invalid schedule for ${name}: ${backup.schedule}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule the job
|
// Schedule the job
|
||||||
@@ -184,12 +184,15 @@ class BackupManager extends EventEmitter {
|
|||||||
await this.cleanupOldBackups(name, backup.retention);
|
await this.cleanupOldBackups(name, backup.retention);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce storage limit (delete oldest until within maxStorageBytes)
|
this.emit('backup-complete', historyEntry);
|
||||||
if (backup.maxStorageBytes) {
|
|
||||||
await this.enforceStorageLimit(name, backup.maxStorageBytes);
|
// Send notification if manager is configured
|
||||||
|
if (this.notificationManager) {
|
||||||
|
this.notificationManager.sendBackupComplete(historyEntry).catch(err => {
|
||||||
|
console.error('[BackupManager] Failed to send backup-complete notification:', err.message);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('backup-complete', historyEntry);
|
|
||||||
console.log(`[BackupManager] Backup ${name} completed in ${duration}ms`);
|
console.log(`[BackupManager] Backup ${name} completed in ${duration}ms`);
|
||||||
|
|
||||||
return historyEntry;
|
return historyEntry;
|
||||||
@@ -206,7 +209,14 @@ class BackupManager extends EventEmitter {
|
|||||||
|
|
||||||
this.addToHistory(historyEntry);
|
this.addToHistory(historyEntry);
|
||||||
this.emit('backup-failed', historyEntry);
|
this.emit('backup-failed', historyEntry);
|
||||||
|
|
||||||
|
// Send notification if manager is configured
|
||||||
|
if (this.notificationManager) {
|
||||||
|
this.notificationManager.sendBackupFailed(historyEntry).catch(err => {
|
||||||
|
console.error('[BackupManager] Failed to send backup-failed notification:', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -556,17 +566,42 @@ class BackupManager extends EventEmitter {
|
|||||||
switch (destination.type) {
|
switch (destination.type) {
|
||||||
case 'local':
|
case 'local':
|
||||||
return await this.saveToLocal(data, destination, backupId);
|
return await this.saveToLocal(data, destination, backupId);
|
||||||
|
case 'dropbox':
|
||||||
|
return await this.saveToDropbox(data, destination, backupId);
|
||||||
|
case 'webdav':
|
||||||
|
return await this.saveToWebDAV(data, destination, backupId);
|
||||||
|
case 'sftp':
|
||||||
|
return await this.saveToSFTP(data, destination, backupId);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported destination type: ${destination.type}`);
|
throw new Error(`Unsupported destination type: ${destination.type}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load encrypted backup blob from a destination location.
|
||||||
|
* Returns a Buffer that can be passed to decryptBackup/decompressBackup.
|
||||||
|
*/
|
||||||
|
async loadFromDestination(location) {
|
||||||
|
switch (location.type) {
|
||||||
|
case 'local':
|
||||||
|
return fs.readFileSync(location.path);
|
||||||
|
case 'dropbox':
|
||||||
|
return await this.loadFromDropbox(location);
|
||||||
|
case 'webdav':
|
||||||
|
return await this.loadFromWebDAV(location);
|
||||||
|
case 'sftp':
|
||||||
|
return await this.loadFromSFTP(location);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported destination type: ${location.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save to local filesystem
|
* Save to local filesystem
|
||||||
*/
|
*/
|
||||||
async saveToLocal(data, destination, backupId) {
|
async saveToLocal(data, destination, backupId) {
|
||||||
const backupDir = destination.path || DEFAULT_BACKUP_DIR;
|
const backupDir = destination.path || DEFAULT_BACKUP_DIR;
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
if (!fs.existsSync(backupDir)) {
|
if (!fs.existsSync(backupDir)) {
|
||||||
fs.mkdirSync(backupDir, { recursive: true });
|
fs.mkdirSync(backupDir, { recursive: true });
|
||||||
@@ -574,9 +609,9 @@ class BackupManager extends EventEmitter {
|
|||||||
|
|
||||||
const filename = `${backupId}.backup`;
|
const filename = `${backupId}.backup`;
|
||||||
const filepath = path.join(backupDir, filename);
|
const filepath = path.join(backupDir, filename);
|
||||||
|
|
||||||
fs.writeFileSync(filepath, data);
|
fs.writeFileSync(filepath, data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'local',
|
type: 'local',
|
||||||
path: filepath,
|
path: filepath,
|
||||||
@@ -584,6 +619,257 @@ class BackupManager extends EventEmitter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== CLOUD DESTINATIONS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve credentials for a given provider via the credentialManager.
|
||||||
|
* Throws if required fields are missing.
|
||||||
|
*/
|
||||||
|
async _getCloudCredentials(provider) {
|
||||||
|
const credentialManager = require('./credential-manager');
|
||||||
|
const creds = {};
|
||||||
|
if (provider === 'dropbox') {
|
||||||
|
creds.token = await credentialManager.retrieve('backup.dropbox.token');
|
||||||
|
if (!creds.token) throw new Error('Dropbox token not configured');
|
||||||
|
} else if (provider === 'webdav') {
|
||||||
|
creds.url = await credentialManager.retrieve('backup.webdav.url');
|
||||||
|
creds.username = await credentialManager.retrieve('backup.webdav.username');
|
||||||
|
creds.password = await credentialManager.retrieve('backup.webdav.password');
|
||||||
|
if (!creds.url || !creds.username || !creds.password) {
|
||||||
|
throw new Error('WebDAV credentials incomplete (need url, username, password)');
|
||||||
|
}
|
||||||
|
} else if (provider === 'sftp') {
|
||||||
|
creds.host = await credentialManager.retrieve('backup.sftp.host');
|
||||||
|
const portStr = await credentialManager.retrieve('backup.sftp.port');
|
||||||
|
creds.port = parseInt(portStr || '22', 10);
|
||||||
|
creds.username = await credentialManager.retrieve('backup.sftp.username');
|
||||||
|
creds.password = await credentialManager.retrieve('backup.sftp.password');
|
||||||
|
creds.privateKey = await credentialManager.retrieve('backup.sftp.privateKey');
|
||||||
|
if (!creds.host || !creds.username || (!creds.password && !creds.privateKey)) {
|
||||||
|
throw new Error('SFTP credentials incomplete (need host, username, and either password or privateKey)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return creds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Dropbox -----
|
||||||
|
|
||||||
|
async saveToDropbox(data, destination, backupId) {
|
||||||
|
const { Dropbox } = require('dropbox');
|
||||||
|
const creds = await this._getCloudCredentials('dropbox');
|
||||||
|
const dbx = new Dropbox({ accessToken: creds.token });
|
||||||
|
|
||||||
|
const folder = (destination.path || '/dashcaddy-backups').replace(/\/+$/, '');
|
||||||
|
const remotePath = `${folder}/${backupId}.backup`;
|
||||||
|
|
||||||
|
await dbx.filesUpload({
|
||||||
|
path: remotePath,
|
||||||
|
contents: data,
|
||||||
|
mode: { '.tag': 'overwrite' },
|
||||||
|
autorename: false,
|
||||||
|
mute: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'dropbox',
|
||||||
|
path: remotePath,
|
||||||
|
size: data.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadFromDropbox(location) {
|
||||||
|
const { Dropbox } = require('dropbox');
|
||||||
|
const creds = await this._getCloudCredentials('dropbox');
|
||||||
|
const dbx = new Dropbox({ accessToken: creds.token });
|
||||||
|
const result = await dbx.filesDownload({ path: location.path });
|
||||||
|
// Node SDK returns fileBinary on the result
|
||||||
|
const fileBinary = result.result.fileBinary || result.result.fileBlob;
|
||||||
|
if (Buffer.isBuffer(fileBinary)) return fileBinary;
|
||||||
|
return Buffer.from(fileBinary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- WebDAV -----
|
||||||
|
|
||||||
|
async saveToWebDAV(data, destination, backupId) {
|
||||||
|
const { createClient } = require('webdav');
|
||||||
|
const creds = await this._getCloudCredentials('webdav');
|
||||||
|
const client = createClient(creds.url, {
|
||||||
|
username: creds.username,
|
||||||
|
password: creds.password
|
||||||
|
});
|
||||||
|
|
||||||
|
const folder = (destination.path || '/dashcaddy-backups').replace(/\/+$/, '');
|
||||||
|
|
||||||
|
// Ensure folder exists
|
||||||
|
try {
|
||||||
|
const exists = await client.exists(folder);
|
||||||
|
if (!exists) await client.createDirectory(folder, { recursive: true });
|
||||||
|
} catch (_) {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
const remotePath = `${folder}/${backupId}.backup`;
|
||||||
|
await client.putFileContents(remotePath, data, { overwrite: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'webdav',
|
||||||
|
path: remotePath,
|
||||||
|
size: data.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadFromWebDAV(location) {
|
||||||
|
const { createClient } = require('webdav');
|
||||||
|
const creds = await this._getCloudCredentials('webdav');
|
||||||
|
const client = createClient(creds.url, {
|
||||||
|
username: creds.username,
|
||||||
|
password: creds.password
|
||||||
|
});
|
||||||
|
const data = await client.getFileContents(location.path);
|
||||||
|
return Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- SFTP -----
|
||||||
|
|
||||||
|
async saveToSFTP(data, destination, backupId) {
|
||||||
|
const SftpClient = require('ssh2-sftp-client');
|
||||||
|
const creds = await this._getCloudCredentials('sftp');
|
||||||
|
const client = new SftpClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect({
|
||||||
|
host: creds.host,
|
||||||
|
port: creds.port,
|
||||||
|
username: creds.username,
|
||||||
|
password: creds.password || undefined,
|
||||||
|
privateKey: creds.privateKey || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const folder = (destination.path || '/dashcaddy-backups').replace(/\/+$/, '');
|
||||||
|
// Ensure remote dir exists
|
||||||
|
try {
|
||||||
|
const exists = await client.exists(folder);
|
||||||
|
if (!exists) await client.mkdir(folder, true);
|
||||||
|
} catch (_) {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
const remotePath = `${folder}/${backupId}.backup`;
|
||||||
|
await client.put(Buffer.from(data), remotePath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'sftp',
|
||||||
|
path: remotePath,
|
||||||
|
size: data.length
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
try { await client.end(); } catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadFromSFTP(location) {
|
||||||
|
const SftpClient = require('ssh2-sftp-client');
|
||||||
|
const creds = await this._getCloudCredentials('sftp');
|
||||||
|
const client = new SftpClient();
|
||||||
|
try {
|
||||||
|
await client.connect({
|
||||||
|
host: creds.host,
|
||||||
|
port: creds.port,
|
||||||
|
username: creds.username,
|
||||||
|
password: creds.password || undefined,
|
||||||
|
privateKey: creds.privateKey || undefined
|
||||||
|
});
|
||||||
|
const buffer = await client.get(location.path);
|
||||||
|
return Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
||||||
|
} finally {
|
||||||
|
try { await client.end(); } catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that a destination is reachable + writable + deletable.
|
||||||
|
* Performs a small write/read/delete probe.
|
||||||
|
*/
|
||||||
|
async testDestination(destination) {
|
||||||
|
const probeId = `test-${Date.now()}`;
|
||||||
|
const probeData = Buffer.from(`dashcaddy-test-${probeId}`);
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const location = await this.saveToDestination(probeData, destination, probeId);
|
||||||
|
|
||||||
|
// Read it back
|
||||||
|
let readBack = null;
|
||||||
|
try {
|
||||||
|
readBack = await this.loadFromDestination(location);
|
||||||
|
} catch (_) {
|
||||||
|
// Some providers (e.g. local) we already trust the file system; skip
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the probe
|
||||||
|
try {
|
||||||
|
await this._deleteFromDestination(location);
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
type: destination.type,
|
||||||
|
elapsedMs: elapsed,
|
||||||
|
verified: readBack ? readBack.equals(probeData) : null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
type: destination.type,
|
||||||
|
error: error.message,
|
||||||
|
elapsedMs: Date.now() - start
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a backup from a destination location
|
||||||
|
*/
|
||||||
|
async _deleteFromDestination(location) {
|
||||||
|
if (location.type === 'local') {
|
||||||
|
if (fs.existsSync(location.path)) fs.unlinkSync(location.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (location.type === 'dropbox') {
|
||||||
|
const { Dropbox } = require('dropbox');
|
||||||
|
const creds = await this._getCloudCredentials('dropbox');
|
||||||
|
const dbx = new Dropbox({ accessToken: creds.token });
|
||||||
|
try { await dbx.filesDeleteV2({ path: location.path }); } catch (_) { /* ignore */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (location.type === 'webdav') {
|
||||||
|
const { createClient } = require('webdav');
|
||||||
|
const creds = await this._getCloudCredentials('webdav');
|
||||||
|
const client = createClient(creds.url, { username: creds.username, password: creds.password });
|
||||||
|
try { await client.deleteFile(location.path); } catch (_) { /* ignore */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (location.type === 'sftp') {
|
||||||
|
const SftpClient = require('ssh2-sftp-client');
|
||||||
|
const creds = await this._getCloudCredentials('sftp');
|
||||||
|
const client = new SftpClient();
|
||||||
|
try {
|
||||||
|
await client.connect({
|
||||||
|
host: creds.host,
|
||||||
|
port: creds.port,
|
||||||
|
username: creds.username,
|
||||||
|
password: creds.password || undefined,
|
||||||
|
privateKey: creds.privateKey || undefined
|
||||||
|
});
|
||||||
|
try { await client.delete(location.path); } catch (_) { /* ignore */ }
|
||||||
|
} finally {
|
||||||
|
try { await client.end(); } catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify backup integrity
|
* Verify backup integrity
|
||||||
*/
|
*/
|
||||||
@@ -618,9 +904,24 @@ class BackupManager extends EventEmitter {
|
|||||||
throw new Error(`Backup not found: ${backupId}`);
|
throw new Error(`Backup not found: ${backupId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load backup data
|
// Load backup data — try each destination location until one succeeds
|
||||||
const location = backup.locations[0]; // Use first location
|
const location = backup.locations[0]; // Primary location
|
||||||
let data = fs.readFileSync(location.path);
|
let data;
|
||||||
|
try {
|
||||||
|
data = await this.loadFromDestination(location);
|
||||||
|
} catch (loadErr) {
|
||||||
|
// Fall back to other locations if available
|
||||||
|
let recovered = false;
|
||||||
|
for (let i = 1; i < backup.locations.length; i++) {
|
||||||
|
try {
|
||||||
|
data = await this.loadFromDestination(backup.locations[i]);
|
||||||
|
recovered = true;
|
||||||
|
console.log(`[BackupManager] Loaded backup from fallback location ${backup.locations[i].type}`);
|
||||||
|
break;
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
if (!recovered) throw loadErr;
|
||||||
|
}
|
||||||
|
|
||||||
// Decrypt if needed
|
// Decrypt if needed
|
||||||
if (backup.encrypted && options.encryptionKey) {
|
if (backup.encrypted && options.encryptionKey) {
|
||||||
@@ -717,63 +1018,6 @@ class BackupManager extends EventEmitter {
|
|||||||
console.log('[BackupManager] Stats restored');
|
console.log('[BackupManager] Stats restored');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Enforce storage limit by deleting oldest backups until total is within limit
|
|
||||||
*/
|
|
||||||
async enforceStorageLimit(name, maxBytes) {
|
|
||||||
const maxStr = formatBytes(maxBytes);
|
|
||||||
console.log("[BackupManager] Enforcing storage limit: " + maxStr + " for \"" + name + "\"");
|
|
||||||
|
|
||||||
const backups = this.history
|
|
||||||
.filter(b => b.name === name && b.status === 'success')
|
|
||||||
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
|
||||||
|
|
||||||
let totalSize = 0;
|
|
||||||
const locationsMap = {};
|
|
||||||
|
|
||||||
for (const backup of backups) {
|
|
||||||
for (const loc of backup.locations || []) {
|
|
||||||
if (loc.type === 'local' && loc.path) {
|
|
||||||
totalSize += loc.size || 0;
|
|
||||||
locationsMap[backup.id] = locationsMap[backup.id] || [];
|
|
||||||
locationsMap[backup.id].push(loc.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[BackupManager] Current total size: " + formatBytes(totalSize) + ", limit: " + maxStr);
|
|
||||||
|
|
||||||
if (totalSize <= maxBytes) {
|
|
||||||
console.log("[BackupManager] Storage limit OK (" + formatBytes(totalSize) + " <= " + maxStr + ")");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let freed = 0;
|
|
||||||
for (const backup of backups) {
|
|
||||||
if (totalSize <= maxBytes) break;
|
|
||||||
|
|
||||||
const paths = locationsMap[backup.id] || [];
|
|
||||||
for (const path of paths) {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(path)) {
|
|
||||||
fs.unlinkSync(path);
|
|
||||||
const sz = backup.size || 0;
|
|
||||||
totalSize -= sz;
|
|
||||||
freed += sz;
|
|
||||||
console.log("[BackupManager] Deleted " + formatBytes(sz) + ": " + path);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[BackupManager] Error deleting " + path + ": " + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.history = this.history.filter(b => b.id !== backup.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.saveHistory();
|
|
||||||
console.log("[BackupManager] Storage limit enforced. Freed " + formatBytes(freed) + ", now " + formatBytes(totalSize));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleanup old backups based on retention policy
|
* Cleanup old backups based on retention policy
|
||||||
*/
|
*/
|
||||||
@@ -788,16 +1032,18 @@ class BackupManager extends EventEmitter {
|
|||||||
|
|
||||||
for (const backup of toDelete) {
|
for (const backup of toDelete) {
|
||||||
try {
|
try {
|
||||||
// Delete from all locations
|
// Delete from all locations (local + cloud)
|
||||||
for (const location of backup.locations) {
|
for (const location of backup.locations) {
|
||||||
if (location.type === 'local' && fs.existsSync(location.path)) {
|
try {
|
||||||
fs.unlinkSync(location.path);
|
await this._deleteFromDestination(location);
|
||||||
|
} catch (delErr) {
|
||||||
|
console.warn(`[BackupManager] Could not delete ${location.type} location for ${backup.id}:`, delErr.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from history
|
// Remove from history
|
||||||
this.history = this.history.filter(b => b.id !== backup.id);
|
this.history = this.history.filter(b => b.id !== backup.id);
|
||||||
|
|
||||||
console.log(`[BackupManager] Deleted old backup: ${backup.id}`);
|
console.log(`[BackupManager] Deleted old backup: ${backup.id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[BackupManager] Error deleting backup ${backup.id}:`, error.message);
|
console.error(`[BackupManager] Error deleting backup ${backup.id}:`, error.message);
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"id": "router",
|
|
||||||
"name": "Router UI",
|
|
||||||
"logo": "/assets/router.png",
|
|
||||||
"url": "https://router.sami",
|
|
||||||
"ip": "localhost",
|
|
||||||
"tailscaleOnly": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "chat",
|
|
||||||
"name": "Chat",
|
|
||||||
"logo": "/assets/chat.png",
|
|
||||||
"url": "https://chat.sami",
|
|
||||||
"ip": "localhost",
|
|
||||||
"tailscaleOnly": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "sync",
|
|
||||||
"name": "Syncthing",
|
|
||||||
"logo": "/assets/syncthing.png",
|
|
||||||
"url": "https://sync.sami",
|
|
||||||
"ip": "localhost",
|
|
||||||
"tailscaleOnly": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "torrent",
|
|
||||||
"name": "qBittorrent",
|
|
||||||
"logo": "/assets/qBittorrent.png",
|
|
||||||
"url": "https://torrent.sami",
|
|
||||||
"ip": "localhost",
|
|
||||||
"tailscaleOnly": false,
|
|
||||||
"deployedAt": "2026-01-18T06:04:55.246Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "sonarr",
|
|
||||||
"name": "Sonarr",
|
|
||||||
"logo": "/assets/sonarr.png",
|
|
||||||
"url": "https://sonarr.sami",
|
|
||||||
"ip": "localhost",
|
|
||||||
"tailscaleOnly": false,
|
|
||||||
"deployedAt": "2026-01-18T06:04:56.612Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "radarr",
|
|
||||||
"name": "Radarr",
|
|
||||||
"logo": "/assets/radarr.png",
|
|
||||||
"url": "https://radarr.sami",
|
|
||||||
"ip": "localhost",
|
|
||||||
"tailscaleOnly": false,
|
|
||||||
"deployedAt": "2026-01-18T08:28:12.359Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "prowlarr",
|
|
||||||
"name": "Prowlarr",
|
|
||||||
"logo": "/assets/prowlarr.png",
|
|
||||||
"url": "https://prowlarr.sami",
|
|
||||||
"ip": "localhost",
|
|
||||||
"tailscaleOnly": false,
|
|
||||||
"deployedAt": "2026-01-18T08:28:13.739Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ca",
|
|
||||||
"name": "DashCA",
|
|
||||||
"logo": "/assets/certificate-icon.png",
|
|
||||||
"containerId": null,
|
|
||||||
"appTemplate": "dashca",
|
|
||||||
"tailscaleOnly": false,
|
|
||||||
"deployedAt": "2026-02-11T11:47:08.383Z",
|
|
||||||
"url": "https://ca.sami"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "plex",
|
|
||||||
"name": "Plex",
|
|
||||||
"logo": "/assets/plex.png",
|
|
||||||
"containerId": null,
|
|
||||||
"appTemplate": "plex",
|
|
||||||
"tailscaleOnly": false,
|
|
||||||
"deployedAt": "2026-02-12T02:18:36.067Z",
|
|
||||||
"url": "https://plex.sami"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "requests",
|
|
||||||
"name": "Seerr",
|
|
||||||
"logo": "/assets/seerr.png",
|
|
||||||
"url": "https://requests.sami",
|
|
||||||
"ip": "localhost",
|
|
||||||
"tailscaleOnly": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "git",
|
|
||||||
"name": "Gitea",
|
|
||||||
"logo": "/assets/gitea.png",
|
|
||||||
"url": "https://git.sami",
|
|
||||||
"ip": "localhost",
|
|
||||||
"tailscaleOnly": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "files",
|
|
||||||
"name": "Sami Files",
|
|
||||||
"logo": "/assets/sami-files.png",
|
|
||||||
"url": "https://files.sami",
|
|
||||||
"ip": "localhost",
|
|
||||||
"tailscaleOnly": false,
|
|
||||||
"containerId": null,
|
|
||||||
"appTemplate": "sami-files",
|
|
||||||
"deployedAt": "2026-06-19T00:00:00.000Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dashcaddy-api",
|
"name": "dashcaddy-api",
|
||||||
"version": "1.6.0",
|
"version": "1.8.0",
|
||||||
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ module.exports = function({
|
|||||||
// Add a new service
|
// Add a new service
|
||||||
router.post('/services', asyncHandler(async (req, res) => {
|
router.post('/services', asyncHandler(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id, name, logo } = req.body;
|
const { id, name, logo, category, containerId, port, ip, tailscaleOnly } = req.body;
|
||||||
|
|
||||||
if (!id || !name) {
|
if (!id || !name) {
|
||||||
throw new ValidationError('id and name are required');
|
throw new ValidationError('id and name are required');
|
||||||
@@ -391,7 +391,14 @@ module.exports = function({
|
|||||||
throw new ConflictError(`Service "${id}" already exists`, id);
|
throw new ConflictError(`Service "${id}" already exists`, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
services.push({ id, name, logo: logo || `/assets/${id}.png` });
|
const newService = { id, name, logo: logo || `/assets/${id}.png` };
|
||||||
|
// Persist optional metadata fields if provided
|
||||||
|
if (category) newService.category = category;
|
||||||
|
if (containerId) newService.containerId = containerId;
|
||||||
|
if (port) newService.port = port;
|
||||||
|
if (ip) newService.ip = ip;
|
||||||
|
if (typeof tailscaleOnly === 'boolean') newService.tailscaleOnly = tailscaleOnly;
|
||||||
|
services.push(newService);
|
||||||
return services;
|
return services;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -542,6 +549,8 @@ module.exports = function({
|
|||||||
};
|
};
|
||||||
if (name) services[serviceIndex].name = name;
|
if (name) services[serviceIndex].name = name;
|
||||||
if (logo) services[serviceIndex].logo = logo;
|
if (logo) services[serviceIndex].logo = logo;
|
||||||
|
// Allow category update via update endpoint too (optional body field)
|
||||||
|
if (req.body.category !== undefined) services[serviceIndex].category = req.body.category || undefined;
|
||||||
results.services = 'updated';
|
results.services = 'updated';
|
||||||
} else {
|
} else {
|
||||||
results.services = 'not found';
|
results.services = 'not found';
|
||||||
|
|||||||
Executable → Regular
@@ -39,6 +39,13 @@ let dockerMaintenance, logDigest;
|
|||||||
try { dockerMaintenance = require('../docker-maintenance'); } catch (_) { /* optional module */ }
|
try { dockerMaintenance = require('../docker-maintenance'); } catch (_) { /* optional module */ }
|
||||||
try { logDigest = require('../log-digest'); } catch (_) { /* optional module */ }
|
try { logDigest = require('../log-digest'); } catch (_) { /* optional module */ }
|
||||||
|
|
||||||
|
// Workflow engine (bundled workflows)
|
||||||
|
let bundledWorkflowsModule;
|
||||||
|
let workflowEngine = null;
|
||||||
|
try {
|
||||||
|
bundledWorkflowsModule = require('../bundled-workflows');
|
||||||
|
} catch (_) { /* optional module */ }
|
||||||
|
|
||||||
// Templates
|
// Templates
|
||||||
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates');
|
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates');
|
||||||
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../recipe-templates');
|
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../recipe-templates');
|
||||||
@@ -69,6 +76,7 @@ const recipesRoutes = require('../routes/recipes');
|
|||||||
const themesRoutes = require('../routes/themes');
|
const themesRoutes = require('../routes/themes');
|
||||||
const dockerResourcesRoutes = require('../routes/docker-resources');
|
const dockerResourcesRoutes = require('../routes/docker-resources');
|
||||||
const eventsRoutes = require('../routes/events');
|
const eventsRoutes = require('../routes/events');
|
||||||
|
const workflowsRoutes = require('../routes/workflows');
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const { APP } = require('../constants');
|
const { APP } = require('../constants');
|
||||||
@@ -308,9 +316,55 @@ async function createApp() {
|
|||||||
app,
|
app,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initialize workflow engine if bundled-workflows is available
|
||||||
|
if (bundledWorkflowsModule && ctx.docker) {
|
||||||
|
try {
|
||||||
|
const { WorkflowEngine } = bundledWorkflowsModule;
|
||||||
|
const workflowCtx = {
|
||||||
|
docker: ctx.docker,
|
||||||
|
notification: ctx.notification,
|
||||||
|
backupManager: ctx.backupManager,
|
||||||
|
resourceMonitor: ctx.resourceMonitor,
|
||||||
|
servicesStateManager: ctx.servicesStateManager
|
||||||
|
};
|
||||||
|
workflowEngine = new WorkflowEngine(workflowCtx);
|
||||||
|
ctx.workflowEngine = workflowEngine;
|
||||||
|
log.info('app', 'Workflow engine initialized');
|
||||||
|
} catch (err) {
|
||||||
|
log.error('app', 'Failed to initialize workflow engine', { error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build versioned API router
|
// Build versioned API router
|
||||||
const apiRouter = express.Router();
|
const apiRouter = express.Router();
|
||||||
|
|
||||||
|
// Wire up notification listeners for resourceMonitor and backupManager
|
||||||
|
if (ctx.notification && ctx.resourceMonitor) {
|
||||||
|
ctx.resourceMonitor.on('alert', (alertData) => {
|
||||||
|
ctx.notification.sendAlert(alertData).catch(err => {
|
||||||
|
log.error('notification', 'Failed to send alert', { error: err.message });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ctx.resourceMonitor.on('auto-restart', (data) => {
|
||||||
|
ctx.notification.sendServiceEvent('auto-restart', data).catch(err => {
|
||||||
|
log.error('notification', 'Failed to send auto-restart notification', { error: err.message });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.notification && ctx.backupManager) {
|
||||||
|
ctx.backupManager.on('backup-complete', (data) => {
|
||||||
|
ctx.notification.send('backup-complete', data).catch(err => {
|
||||||
|
log.error('notification', 'Failed to send backup-complete', { error: err.message });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ctx.backupManager.on('backup-failed', (data) => {
|
||||||
|
ctx.notification.send('backup-failed', data).catch(err => {
|
||||||
|
log.error('notification', 'Failed to send backup-failed', { error: err.message });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Mount route modules
|
// Mount route modules
|
||||||
apiRouter.use(authRoutes(ctx));
|
apiRouter.use(authRoutes(ctx));
|
||||||
apiRouter.use(configRoutes(ctx));
|
apiRouter.use(configRoutes(ctx));
|
||||||
@@ -330,7 +384,8 @@ async function createApp() {
|
|||||||
apiRouter.use('/containers', containerRoutes({
|
apiRouter.use('/containers', containerRoutes({
|
||||||
docker: ctx.docker,
|
docker: ctx.docker,
|
||||||
log: ctx.log,
|
log: ctx.log,
|
||||||
asyncHandler: ctx.asyncHandler
|
asyncHandler: ctx.asyncHandler,
|
||||||
|
workflowEngine: ctx.workflowEngine
|
||||||
}));
|
}));
|
||||||
apiRouter.use(serviceRoutes({
|
apiRouter.use(serviceRoutes({
|
||||||
servicesStateManager: ctx.servicesStateManager,
|
servicesStateManager: ctx.servicesStateManager,
|
||||||
@@ -361,7 +416,8 @@ async function createApp() {
|
|||||||
resourceMonitor: ctx.resourceMonitor,
|
resourceMonitor: ctx.resourceMonitor,
|
||||||
docker: ctx.docker,
|
docker: ctx.docker,
|
||||||
asyncHandler: ctx.asyncHandler,
|
asyncHandler: ctx.asyncHandler,
|
||||||
log: ctx.log
|
log: ctx.log,
|
||||||
|
notificationManager: ctx.notification
|
||||||
}));
|
}));
|
||||||
apiRouter.use(updatesRoutes({
|
apiRouter.use(updatesRoutes({
|
||||||
updateManager: ctx.updateManager,
|
updateManager: ctx.updateManager,
|
||||||
@@ -404,8 +460,8 @@ async function createApp() {
|
|||||||
}));
|
}));
|
||||||
apiRouter.use(backupsRoutes({
|
apiRouter.use(backupsRoutes({
|
||||||
backupManager: ctx.backupManager,
|
backupManager: ctx.backupManager,
|
||||||
asyncHandler: ctx.asyncHandler,
|
licenseManager: ctx.licenseManager,
|
||||||
licenseManager: ctx.licenseManager
|
asyncHandler: ctx.asyncHandler
|
||||||
}));
|
}));
|
||||||
apiRouter.use('/ca', caRoutes(ctx));
|
apiRouter.use('/ca', caRoutes(ctx));
|
||||||
apiRouter.use(browseRoutes({
|
apiRouter.use(browseRoutes({
|
||||||
@@ -435,6 +491,11 @@ async function createApp() {
|
|||||||
updateManager: ctx.updateManager,
|
updateManager: ctx.updateManager,
|
||||||
logError: ctx.logError
|
logError: ctx.logError
|
||||||
}));
|
}));
|
||||||
|
apiRouter.use(workflowsRoutes({
|
||||||
|
workflowEngine: ctx.workflowEngine,
|
||||||
|
licenseManager: ctx.licenseManager,
|
||||||
|
asyncHandler: ctx.asyncHandler
|
||||||
|
}));
|
||||||
|
|
||||||
// Inline API routes
|
// Inline API routes
|
||||||
apiRouter.get('/health', (req, res) => {
|
apiRouter.get('/health', (req, res) => {
|
||||||
|
|||||||
@@ -27,14 +27,10 @@ readonly API_DIR="${SITES_DIR}/dashcaddy-api"
|
|||||||
readonly DASHBOARD_DIR="${SITES_DIR}/status"
|
readonly DASHBOARD_DIR="${SITES_DIR}/status"
|
||||||
readonly CONTAINER_NAME="dashcaddy-api"
|
readonly CONTAINER_NAME="dashcaddy-api"
|
||||||
readonly CADDY_ADMIN_PORT=2019
|
readonly CADDY_ADMIN_PORT=2019
|
||||||
readonly BACKUP_DIR="${BACKUP_DIR:-${INSTALL_DIR}/backups}"
|
|
||||||
readonly DEFAULT_MAX_STORAGE_BYTES=""
|
|
||||||
|
|
||||||
# ---- Tunables (overridable via flags) --------------------------------------
|
# ---- Tunables (overridable via flags) --------------------------------------
|
||||||
API_PORT=3001
|
API_PORT=3001
|
||||||
LOCAL_PORT=8080
|
LOCAL_PORT=8080
|
||||||
BACKUP_DIR=""
|
|
||||||
BACKUP_LIMIT=""
|
|
||||||
|
|
||||||
# ---- Runtime state ---------------------------------------------------------
|
# ---- Runtime state ---------------------------------------------------------
|
||||||
DOMAIN_MODE="" # public | custom-tld | local
|
DOMAIN_MODE="" # public | custom-tld | local
|
||||||
@@ -393,7 +389,6 @@ EOF
|
|||||||
create_directories() {
|
create_directories() {
|
||||||
mkdir -p "$INSTALL_DIR" "$DOCKER_DATA" "$SITES_DIR" "$API_DIR" "$DASHBOARD_DIR" "${DASHBOARD_DIR}/assets"
|
mkdir -p "$INSTALL_DIR" "$DOCKER_DATA" "$SITES_DIR" "$API_DIR" "$DASHBOARD_DIR" "${DASHBOARD_DIR}/assets"
|
||||||
mkdir -p /opt/dashcaddy/updates /opt/dashcaddy/scripts
|
mkdir -p /opt/dashcaddy/updates /opt/dashcaddy/scripts
|
||||||
mkdir -p "${BACKUP_DIR}"
|
|
||||||
ok "Directories created"
|
ok "Directories created"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,41 +626,7 @@ CEOF
|
|||||||
# Docker Compose
|
# Docker Compose
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
# Parse size string like "10GB" or "1TB" to bytes
|
|
||||||
parse_size_to_bytes() {
|
|
||||||
local size="$1"
|
|
||||||
local value unit
|
|
||||||
|
|
||||||
# Strip whitespace
|
|
||||||
size=$(echo "$size" | tr -d ' ')
|
|
||||||
|
|
||||||
# Extract numeric value and unit
|
|
||||||
if [[ $size =~ ^([0-9.]+)([kmgtKMGT][bb]?|[bB]?)$ ]]; then
|
|
||||||
value="${BASH_REMATCH[1]}"
|
|
||||||
unit="${BASH_REMATCH[2]}"
|
|
||||||
|
|
||||||
# Normalize unit to uppercase without 'B' suffix for simplicity
|
|
||||||
unit=$(echo "$unit" | tr '[:lower:]' '[:upper:]')
|
|
||||||
case "$unit" in
|
|
||||||
K|KB) echo $((value * 1024)) ;;
|
|
||||||
M|MB) echo $((value * 1024 * 1024)) ;;
|
|
||||||
G|GB) echo $((value * 1024 * 1024 * 1024)) ;;
|
|
||||||
T|TB) echo $((value * 1024 * 1024 * 1024 * 1024)) ;;
|
|
||||||
*) echo "$value" ;;
|
|
||||||
esac
|
|
||||||
else
|
|
||||||
# Not recognized, treat as raw bytes
|
|
||||||
echo "$size"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
generate_docker_compose() {
|
generate_docker_compose() {
|
||||||
# Convert BACKUP_LIMIT to bytes if set (e.g., "10GB" -> 10737418240)
|
|
||||||
local backup_limit_bytes=""
|
|
||||||
if [[ -n "$BACKUP_LIMIT" ]]; then
|
|
||||||
backup_limit_bytes=$(parse_size_to_bytes "$BACKUP_LIMIT")
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat > "${API_DIR}/docker-compose.yml" <<DCEOF
|
cat > "${API_DIR}/docker-compose.yml" <<DCEOF
|
||||||
services:
|
services:
|
||||||
dashcaddy-api:
|
dashcaddy-api:
|
||||||
@@ -687,7 +648,6 @@ services:
|
|||||||
- ${DASHBOARD_DIR}:/app/dashboard:rw
|
- ${DASHBOARD_DIR}:/app/dashboard:rw
|
||||||
- /opt/dashcaddy/updates:/app/updates:rw
|
- /opt/dashcaddy/updates:/app/updates:rw
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- dashcaddy-backups:/app/backups
|
|
||||||
environment:
|
environment:
|
||||||
- CADDYFILE_PATH=/caddyfile
|
- CADDYFILE_PATH=/caddyfile
|
||||||
- CADDY_ADMIN_URL=http://host.docker.internal:${CADDY_ADMIN_PORT}
|
- CADDY_ADMIN_URL=http://host.docker.internal:${CADDY_ADMIN_PORT}
|
||||||
@@ -705,10 +665,6 @@ services:
|
|||||||
- DASHCADDY_HOST_UPDATES_DIR=/opt/dashcaddy/updates
|
- DASHCADDY_HOST_UPDATES_DIR=/opt/dashcaddy/updates
|
||||||
- DASHCADDY_API_SOURCE_DIR=${API_DIR}
|
- DASHCADDY_API_SOURCE_DIR=${API_DIR}
|
||||||
- DASHCADDY_FRONTEND_DIR=/app/dashboard
|
- DASHCADDY_FRONTEND_DIR=/app/dashboard
|
||||||
- BACKUP_DIR=/app/backups
|
|
||||||
- BACKUP_MAX_STORAGE_BYTES=${backup_limit_bytes:-0}
|
|
||||||
- BACKUP_CONFIG_FILE=/app/backup-config.json
|
|
||||||
- BACKUP_HISTORY_FILE=/app/backup-history.json
|
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -717,14 +673,6 @@ services:
|
|||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
volumes:
|
|
||||||
dashcaddy-backups:
|
|
||||||
driver: local
|
|
||||||
driver_opts:
|
|
||||||
type: none
|
|
||||||
o: bind
|
|
||||||
device: ${BACKUP_DIR}
|
|
||||||
DCEOF
|
DCEOF
|
||||||
|
|
||||||
ok "docker-compose.yml generated"
|
ok "docker-compose.yml generated"
|
||||||
@@ -932,8 +880,6 @@ parse_args() {
|
|||||||
--skip-caddy) SKIP_CADDY=true; shift ;;
|
--skip-caddy) SKIP_CADDY=true; shift ;;
|
||||||
--uninstall) UNINSTALL=true; shift ;;
|
--uninstall) UNINSTALL=true; shift ;;
|
||||||
--keep-config) KEEP_CONFIG=true; shift ;;
|
--keep-config) KEEP_CONFIG=true; shift ;;
|
||||||
--backup-dir) BACKUP_DIR="${2:-}"; shift; shift ;;
|
|
||||||
--backup-limit) BACKUP_LIMIT="${2:-}"; shift; shift ;;
|
|
||||||
--yes|-y) AUTO_YES=true; shift ;;
|
--yes|-y) AUTO_YES=true; shift ;;
|
||||||
--help|-h) print_help; exit 0 ;;
|
--help|-h) print_help; exit 0 ;;
|
||||||
*) warn "Unknown option: $1 (ignored)"; shift ;;
|
*) warn "Unknown option: $1 (ignored)"; shift ;;
|
||||||
@@ -968,8 +914,6 @@ print_help() {
|
|||||||
--source PATH Use local source files
|
--source PATH Use local source files
|
||||||
--skip-docker Already have Docker
|
--skip-docker Already have Docker
|
||||||
--skip-caddy Already have Caddy
|
--skip-caddy Already have Caddy
|
||||||
--backup-dir PATH Backup directory (default: /etc/dashcaddy/backups)
|
|
||||||
--backup-limit SIZE Storage limit for backups (e.g., 10GB, 1TB)
|
|
||||||
--uninstall Remove DashCaddy
|
--uninstall Remove DashCaddy
|
||||||
--keep-config Keep configs during uninstall
|
--keep-config Keep configs during uninstall
|
||||||
--yes Skip confirmations
|
--yes Skip confirmations
|
||||||
|
|||||||
@@ -226,34 +226,17 @@ class ConfigManager {
|
|||||||
* @returns {Promise<Object>} Disk space info
|
* @returns {Promise<Object>} Disk space info
|
||||||
*/
|
*/
|
||||||
async getDiskSpace(testPath) {
|
async getDiskSpace(testPath) {
|
||||||
|
// Note: This is a simplified version. In production, you'd use a library like 'check-disk-space'
|
||||||
try {
|
try {
|
||||||
const fsPromises = require('fs').promises;
|
const stats = await fs.stat(testPath);
|
||||||
const pathModule = require('path');
|
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
await fsPromises.mkdir(testPath, { recursive: true });
|
|
||||||
|
|
||||||
// Use statfs for true disk space (works on all filesystems: ext4, Btrfs, XFS, ZFS, APFS, NTFS)
|
|
||||||
const stats = await fsPromises.statfs(testPath);
|
|
||||||
|
|
||||||
const totalBytes = stats.blocks * stats.bsize;
|
|
||||||
const freeBytes = stats.bfree * stats.bsize;
|
|
||||||
const availableBytes = stats.bavail * stats.bsize; // Available to non-root users
|
|
||||||
const usedBytes = totalBytes - freeBytes;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
available: true,
|
available: true,
|
||||||
path: testPath,
|
path: testPath
|
||||||
total: totalBytes,
|
|
||||||
used: usedBytes,
|
|
||||||
free: freeBytes,
|
|
||||||
availableBytes: availableBytes,
|
|
||||||
usagePercent: parseFloat(((usedBytes / totalBytes) * 100).toFixed(2))
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
available: false,
|
available: false,
|
||||||
path: testPath,
|
|
||||||
error: error.message
|
error: error.message
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,11 +62,6 @@ const state = {
|
|||||||
installPath: '',
|
installPath: '',
|
||||||
health: null
|
health: null
|
||||||
},
|
},
|
||||||
// Backup configuration
|
|
||||||
backup: {
|
|
||||||
maxStorageGB: 10,
|
|
||||||
backupDir: ''
|
|
||||||
},
|
|
||||||
// Uninstall mode
|
// Uninstall mode
|
||||||
uninstallMode: false,
|
uninstallMode: false,
|
||||||
uninstall: {
|
uninstall: {
|
||||||
@@ -378,24 +373,6 @@ function updateBranding(field, value) {
|
|||||||
if (field === 'primaryColor') render();
|
if (field === 'primaryColor') render();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backup functions
|
|
||||||
function updateBackup(field, value) {
|
|
||||||
state.backup[field] = value;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectBackupDir() {
|
|
||||||
try {
|
|
||||||
const result = await window.electronAPI.selectFolder();
|
|
||||||
if (result.success && result.path) {
|
|
||||||
state.backup.backupDir = result.path;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Backup dir selection failed:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectLogo() {
|
async function selectLogo() {
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.selectFile({
|
const result = await window.electronAPI.selectFile({
|
||||||
@@ -443,10 +420,6 @@ async function startInstallation() {
|
|||||||
password: state.dns.password,
|
password: state.dns.password,
|
||||||
token: state.dns.token
|
token: state.dns.token
|
||||||
} : null,
|
} : null,
|
||||||
backup: {
|
|
||||||
maxStorageGB: state.backup.maxStorageGB,
|
|
||||||
backupDir: state.backup.backupDir || null
|
|
||||||
},
|
|
||||||
autoStart: true
|
autoStart: true
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -990,31 +963,6 @@ function renderDashboardSetup() {
|
|||||||
<p class="hint">Port for the DashCaddy API server (default: 3001)</p>
|
<p class="hint">Port for the DashCaddy API server (default: 3001)</p>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
<div class="folder-input">
|
|
||||||
<label>Backup Storage Limit (GB)</label>
|
|
||||||
<div class="input-row">
|
|
||||||
<input type="number"
|
|
||||||
value="${state.backup.maxStorageGB}"
|
|
||||||
min="1" max="10240"
|
|
||||||
oninput="updateBackup('maxStorageGB', parseInt(this.value) || 10)">
|
|
||||||
</div>
|
|
||||||
<p class="hint">Maximum storage for backups in GB (default: 10, max: 10TB)</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${state.tier !== 'basic' ? `
|
|
||||||
<div class="folder-input">
|
|
||||||
<label>Backup Directory</label>
|
|
||||||
<div class="input-row">
|
|
||||||
<input type="text"
|
|
||||||
value="${escapeHtml(state.backup.backupDir)}"
|
|
||||||
readonly
|
|
||||||
placeholder="Default: $INSTALL_DIR/backups">
|
|
||||||
<button class="btn-browse" onclick="selectBackupDir()">Browse...</button>
|
|
||||||
</div>
|
|
||||||
<p class="hint">Where backup files are stored on the host</p>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -7,28 +7,15 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- {{API_PATH}}:/app
|
- {{API_PATH}}:/app
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- dashcaddy-backups:/app/backups
|
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT={{API_PORT}}
|
- PORT={{API_PORT}}
|
||||||
- SERVICES_FILE=/app/services.json
|
- SERVICES_FILE=/app/services.json
|
||||||
- CADDY_ADMIN_URL=http://host.docker.internal:2019
|
- CADDY_ADMIN_URL=http://host.docker.internal:2019
|
||||||
- BACKUP_DIR=/app/backups
|
|
||||||
- BACKUP_MAX_STORAGE_BYTES={{BACKUP_MAX_STORAGE_BYTES}}
|
|
||||||
- BACKUP_CONFIG_FILE=/app/backup-config.json
|
|
||||||
- BACKUP_HISTORY_FILE=/app/backup-history.json
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- dashcaddy
|
- dashcaddy
|
||||||
|
|
||||||
volumes:
|
|
||||||
dashcaddy-backups:
|
|
||||||
driver: local
|
|
||||||
driver_opts:
|
|
||||||
type: none
|
|
||||||
o: bind
|
|
||||||
device: {{BACKUP_DIR}}
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
dashcaddy:
|
dashcaddy:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
Executable
+76
@@ -0,0 +1,76 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Samihost fail2ban watchdog — auto-unban whitelisted IPs and keep ignoreip list in sync.
|
||||||
|
# Deployed to /usr/local/bin/samihost-fail2ban-watchdog.sh on 194.163.161.162
|
||||||
|
# Cron: every 30 min (0,30 * * * *)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
JAIL_LOCAL=/etc/fail2ban/jail.local
|
||||||
|
BACKUP=/etc/fail2ban/jail.local.watchdog.bak
|
||||||
|
EXPECTED_IGNOREIP="127.0.0.1/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 fc00::/7 fe80::/10 100.64.0.0/10 100.121.150.22 100.85.236.10 100.71.97.12 100.81.59.99 100.98.123.59 194.233.88.206 173.212.201.200 194.163.161.162"
|
||||||
|
LOG=/var/log/samihost-fail2ban-watchdog.log
|
||||||
|
TELEGRAM_LOG=/tmp/fail2ban-watchdog-last-action
|
||||||
|
|
||||||
|
ts() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
|
||||||
|
log() { echo "$(ts) $*" | tee -a "$LOG"; }
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$LOG")"
|
||||||
|
touch "$LOG"
|
||||||
|
|
||||||
|
# --- 1. Verify ignoreip line is intact and matches expected ---
|
||||||
|
CURRENT=$(grep '^ignoreip' "$JAIL_LOCAL" | sed 's/^ignoreip[[:space:]]*=[[:space:]]*//' || true)
|
||||||
|
EXPECTED_NORMALIZED=$(echo "$EXPECTED_IGNOREIP" | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
|
||||||
|
CURRENT_NORMALIZED=$(echo "$CURRENT" | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
|
||||||
|
|
||||||
|
if [ "$CURRENT_NORMALIZED" != "$EXPECTED_NORMALIZED" ]; then
|
||||||
|
log "ALERT: ignoreip line drifted. Restoring."
|
||||||
|
cp "$JAIL_LOCAL" "$BACKUP"
|
||||||
|
sed -i "s|^ignoreip = .*|ignoreip = $EXPECTED_IGNOREIP|" "$JAIL_LOCAL"
|
||||||
|
fail2ban-client reload
|
||||||
|
echo "ignoreip restored at $(ts)" > "$TELEGRAM_LOG"
|
||||||
|
log "ignoreip restored, fail2ban reloaded"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 2. Unban any currently-banned IPs that match our trusted set ---
|
||||||
|
BANNED=$(fail2ban-client status sshd 2>/dev/null | awk -F: '/Banned IP list/{print $2}' | tr ' ' '\n' | grep -v '^$' || true)
|
||||||
|
UNBANNED=0
|
||||||
|
for ip in $BANNED; do
|
||||||
|
# Match against any trusted network
|
||||||
|
is_trusted=0
|
||||||
|
for net in 127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 ::1 fc00::/7 fe80::/10 100.121.150.22 100.85.236.10 100.71.97.12 100.81.59.99 100.98.123.59 194.233.88.206 173.212.201.200 194.163.161.162; do
|
||||||
|
if [[ "$net" == *"/"* ]]; then
|
||||||
|
# CIDR match (simple IPv4 only — IPv6 needs python or ipcalc, skip for now)
|
||||||
|
base="${net%/*}"
|
||||||
|
mask="${net#*/}"
|
||||||
|
if [[ "$ip" == "$base"* ]] || python3 -c "import ipaddress,sys; sys.exit(0 if ipaddress.ip_address('$ip') in ipaddress.ip_network('$net', strict=False) else 1)" 2>/dev/null; then
|
||||||
|
is_trusted=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [ "$ip" = "$net" ]; then
|
||||||
|
is_trusted=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$is_trusted" = "1" ]; then
|
||||||
|
if fail2ban-client set sshd unbanip "$ip" >/dev/null 2>&1; then
|
||||||
|
log "auto-unbanned trusted IP: $ip"
|
||||||
|
UNBANNED=$((UNBANNED+1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
[ "$UNBANNED" -gt 0 ] && echo "auto-unbanned $UNBANNED trusted IPs at $(ts)" > "$TELEGRAM_LOG"
|
||||||
|
|
||||||
|
# --- 3. Cap the ban count — if more than 200 are banned, mass-unban stale ones ---
|
||||||
|
TOTAL_BANNED=$(fail2ban-client status sshd 2>/dev/null | awk '/Currently banned/{print $NF}' || echo 0)
|
||||||
|
if [ "$TOTAL_BANNED" -gt 200 ]; then
|
||||||
|
log "ALERT: $TOTAL_BANNED IPs banned. Mass-unbanning all."
|
||||||
|
for ip in $BANNED; do
|
||||||
|
fail2ban-client set sshd unbanip "$ip" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
echo "mass-unbanned $TOTAL_BANNED stale bans at $(ts)" > "$TELEGRAM_LOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "watchdog run complete (unbanned=$UNBANNED, total_banned=$TOTAL_BANNED)"
|
||||||
@@ -72,6 +72,7 @@ const bundles = {
|
|||||||
],
|
],
|
||||||
'init.js': [
|
'init.js': [
|
||||||
JS('core', 'init.js'),
|
JS('core', 'init.js'),
|
||||||
|
JS('monitoring-widgets.js'),
|
||||||
JS('keyboard-shortcuts.js'),
|
JS('keyboard-shortcuts.js'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+114
-87
File diff suppressed because one or more lines are too long
Vendored
+308
-233
File diff suppressed because one or more lines are too long
Vendored
+129
-18
File diff suppressed because one or more lines are too long
@@ -256,6 +256,9 @@
|
|||||||
<option value="on">🟢 Online</option>
|
<option value="on">🟢 Online</option>
|
||||||
<option value="off">🔴 Offline</option>
|
<option value="off">🔴 Offline</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select id="service-filter-category" style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
|
||||||
|
<option value="all">All Categories</option>
|
||||||
|
</select>
|
||||||
<button id="batch-operations-btn" class="btn-sm" style="padding: 8px 12px;">☰ Batch Operations</button>
|
<button id="batch-operations-btn" class="btn-sm" style="padding: 8px 12px;">☰ Batch Operations</button>
|
||||||
<span id="service-filter-count" style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;"></span>
|
<span id="service-filter-count" style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,6 +95,8 @@
|
|||||||
const card = el('div', 'card');
|
const card = el('div', 'card');
|
||||||
card.setAttribute('data-app', s.id);
|
card.setAttribute('data-app', s.id);
|
||||||
card.setAttribute('data-status', 'off'); // Initial status
|
card.setAttribute('data-status', 'off'); // Initial status
|
||||||
|
if (s.containerId) card.setAttribute('data-container-id', s.containerId);
|
||||||
|
if (s.category) card.setAttribute('data-category', s.category);
|
||||||
if (s.recipeId) card.setAttribute('data-recipe-id', s.recipeId);
|
if (s.recipeId) card.setAttribute('data-recipe-id', s.recipeId);
|
||||||
|
|
||||||
const dot = el('span', 'dot bad at-bl'); dot.id = 'dot-' + s.id + '-grid'; card.appendChild(dot);
|
const dot = el('span', 'dot bad at-bl'); dot.id = 'dot-' + s.id + '-grid'; card.appendChild(dot);
|
||||||
@@ -156,6 +158,16 @@
|
|||||||
nameSpan.appendChild(tsBadge);
|
nameSpan.appendChild(tsBadge);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add Category badge if service has one (colored pill with icon)
|
||||||
|
if (s.category) {
|
||||||
|
const cats = (typeof DC !== 'undefined' && DC.CATEGORIES) || window.DC_CATEGORIES || {};
|
||||||
|
const catInfo = cats[s.category] || {};
|
||||||
|
const catBadge = el('span', 'cat-badge', `${catInfo.icon || ''} ${s.category}`.trim());
|
||||||
|
catBadge.title = `Category: ${s.category}`;
|
||||||
|
catBadge.style.cssText = `margin-left: 6px; font-size: 0.65rem; padding: 1px 6px; border-radius: 999px; background: color-mix(in srgb, ${catInfo.color || '#7f8c8d'} 25%, transparent); color: ${catInfo.color || '#7f8c8d'}; border: 1px solid color-mix(in srgb, ${catInfo.color || '#7f8c8d'} 50%, transparent); white-space: nowrap; font-weight: 500;`;
|
||||||
|
nameSpan.appendChild(catBadge);
|
||||||
|
}
|
||||||
|
|
||||||
row.appendChild(el('span', 'spacer'));
|
row.appendChild(el('span', 'spacer'));
|
||||||
|
|
||||||
const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill);
|
const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill);
|
||||||
@@ -282,6 +294,9 @@
|
|||||||
|
|
||||||
// Group recipe cards visually after grid is built
|
// Group recipe cards visually after grid is built
|
||||||
if (window.groupRecipeCards) requestAnimationFrame(() => window.groupRecipeCards());
|
if (window.groupRecipeCards) requestAnimationFrame(() => window.groupRecipeCards());
|
||||||
|
|
||||||
|
// Refresh the service filter so the category dropdown reflects new services
|
||||||
|
if (window.refreshServiceFilter) window.refreshServiceFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBadge(id, up, responseTime = null) {
|
function setBadge(id, up, responseTime = null) {
|
||||||
|
|||||||
@@ -59,11 +59,13 @@
|
|||||||
}
|
}
|
||||||
_dashboardInitialized = true;
|
_dashboardInitialized = true;
|
||||||
await window.loadServices();
|
await window.loadServices();
|
||||||
|
await loadTemplateCategories();
|
||||||
window.buildGrid();
|
window.buildGrid();
|
||||||
animateTopCards();
|
animateTopCards();
|
||||||
window.refreshAll();
|
window.refreshAll();
|
||||||
setInterval(window.refreshAll, DC.POLL.DASHBOARD);
|
setInterval(window.refreshAll, DC.POLL.DASHBOARD);
|
||||||
if (typeof window.refreshCredsButtons === 'function') window.refreshCredsButtons();
|
if (typeof window.refreshCredsButtons === 'function') window.refreshCredsButtons();
|
||||||
|
if (typeof window.refreshMonitoringWidgets === 'function') window.refreshMonitoringWidgets();
|
||||||
// Update auth card (may have already been updated by the auto-load IIFE but ensure it's correct)
|
// Update auth card (may have already been updated by the auto-load IIFE but ensure it's correct)
|
||||||
if (typeof window._updateAuthCard === 'function') {
|
if (typeof window._updateAuthCard === 'function') {
|
||||||
try {
|
try {
|
||||||
@@ -200,6 +202,55 @@
|
|||||||
window.loadCustomServices = loadCustomServices;
|
window.loadCustomServices = loadCustomServices;
|
||||||
registerServiceWorker();
|
registerServiceWorker();
|
||||||
|
|
||||||
|
// ===== TEMPLATE CATEGORIES =====
|
||||||
|
// Cached template categories from /api/v1/templates for use across the UI
|
||||||
|
// (service create/edit, filter dropdown, category badges, etc.)
|
||||||
|
async function loadTemplateCategories() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/v1/templates', { cache: 'no-store' });
|
||||||
|
if (!r.ok) return;
|
||||||
|
const data = await r.json();
|
||||||
|
if (data && data.categories) {
|
||||||
|
window.DC_CATEGORIES = data.categories;
|
||||||
|
// Also expose via globals.js constant for convenience
|
||||||
|
if (typeof DC !== 'undefined') DC.CATEGORIES = data.categories;
|
||||||
|
// Populate any category <select> that's already in the DOM
|
||||||
|
populateCategorySelects();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[init] Failed to load template categories:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateCategorySelects() {
|
||||||
|
const cats = window.DC_CATEGORIES || (typeof DC !== 'undefined' && DC.CATEGORIES);
|
||||||
|
if (!cats) return;
|
||||||
|
document.querySelectorAll('select[data-role="service-category"]').forEach(select => {
|
||||||
|
const current = select.dataset.current || '';
|
||||||
|
// Clear options but keep the first (placeholder)
|
||||||
|
const placeholder = select.querySelector('option[value=""]');
|
||||||
|
select.innerHTML = '';
|
||||||
|
if (placeholder) select.appendChild(placeholder);
|
||||||
|
else {
|
||||||
|
const ph = document.createElement('option');
|
||||||
|
ph.value = '';
|
||||||
|
ph.textContent = '— Select category —';
|
||||||
|
select.appendChild(ph);
|
||||||
|
}
|
||||||
|
Object.entries(cats).forEach(([name, info]) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = name;
|
||||||
|
opt.textContent = `${info.icon || ''} ${name}`.trim();
|
||||||
|
if (name === current) opt.selected = true;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow other modules to re-run population after they (re)inject selects
|
||||||
|
window.populateCategorySelects = populateCategorySelects;
|
||||||
|
window.loadTemplateCategories = loadTemplateCategories;
|
||||||
|
|
||||||
// TOTP-gated initialization
|
// TOTP-gated initialization
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -262,6 +262,7 @@
|
|||||||
const proxyIp = document.getElementById('external-proxy-ip').value.trim() || SITE.dnsIp || 'localhost';
|
const proxyIp = document.getElementById('external-proxy-ip').value.trim() || SITE.dnsIp || 'localhost';
|
||||||
const preserveHost = document.getElementById('external-preserve-host').checked;
|
const preserveHost = document.getElementById('external-preserve-host').checked;
|
||||||
const followRedirects = document.getElementById('external-follow-redirects').checked;
|
const followRedirects = document.getElementById('external-follow-redirects').checked;
|
||||||
|
const category = document.getElementById('external-service-category')?.value || '';
|
||||||
|
|
||||||
if (!name || !externalUrl) {
|
if (!name || !externalUrl) {
|
||||||
showNotification('Please fill in Name and External URL', 'warning');
|
showNotification('Please fill in Name and External URL', 'warning');
|
||||||
@@ -341,6 +342,8 @@
|
|||||||
isExternal: true,
|
isExternal: true,
|
||||||
isCustom: true
|
isCustom: true
|
||||||
};
|
};
|
||||||
|
// Only attach category if user actually picked one
|
||||||
|
if (category) newService.category = category;
|
||||||
|
|
||||||
window.APPS.push(newService);
|
window.APPS.push(newService);
|
||||||
results.dashboard = true;
|
results.dashboard = true;
|
||||||
@@ -457,6 +460,13 @@
|
|||||||
const healthCheck = document.getElementById('health-check-input')?.value || '';
|
const healthCheck = document.getElementById('health-check-input')?.value || '';
|
||||||
const timeout = document.getElementById('timeout-input')?.value || 30;
|
const timeout = document.getElementById('timeout-input')?.value || 30;
|
||||||
|
|
||||||
|
// Category is optional — pulled from either local or external select by the
|
||||||
|
// openAddServiceModal reset. If user doesn't choose one, it stays undefined
|
||||||
|
// and we don't send it (so the backend keeps the existing behavior).
|
||||||
|
const categoryEl = document.getElementById('service-category-input')
|
||||||
|
|| document.getElementById('external-service-category');
|
||||||
|
const category = categoryEl?.value || '';
|
||||||
|
|
||||||
const dnsToken = window.getToken(getPrimaryDnsId(), 'admin');
|
const dnsToken = window.getToken(getPrimaryDnsId(), 'admin');
|
||||||
|
|
||||||
if (!name || !port || !ip) {
|
if (!name || !port || !ip) {
|
||||||
@@ -525,6 +535,8 @@
|
|||||||
logo: logo || `/assets/${subdomain}.png`,
|
logo: logo || `/assets/${subdomain}.png`,
|
||||||
tailscaleOnly: tailscaleOnly || false
|
tailscaleOnly: tailscaleOnly || false
|
||||||
};
|
};
|
||||||
|
// Only include category if user actually picked one
|
||||||
|
if (category) serviceConfig.category = category;
|
||||||
|
|
||||||
await window.addServiceToConfig(serviceConfig);
|
await window.addServiceToConfig(serviceConfig);
|
||||||
results.dashboard = true;
|
results.dashboard = true;
|
||||||
|
|||||||
@@ -19,6 +19,16 @@
|
|||||||
document.getElementById('edit-tailscale-only').checked = service.tailscaleOnly || false;
|
document.getElementById('edit-tailscale-only').checked = service.tailscaleOnly || false;
|
||||||
document.getElementById('edit-logo-url').value = service.logo || '';
|
document.getElementById('edit-logo-url').value = service.logo || '';
|
||||||
|
|
||||||
|
// Populate the category select for this service, then set the current value.
|
||||||
|
// populateCategorySelects() uses data-current so we set it first, then call.
|
||||||
|
const categorySelect = document.getElementById('edit-service-category');
|
||||||
|
if (categorySelect) {
|
||||||
|
categorySelect.dataset.current = service.category || '';
|
||||||
|
if (typeof window.populateCategorySelects === 'function') {
|
||||||
|
window.populateCategorySelects();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
modal.classList.add('show');
|
modal.classList.add('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +46,7 @@
|
|||||||
const newIp = document.getElementById('edit-ip').value.trim() || 'localhost';
|
const newIp = document.getElementById('edit-ip').value.trim() || 'localhost';
|
||||||
const tailscaleOnly = document.getElementById('edit-tailscale-only').checked;
|
const tailscaleOnly = document.getElementById('edit-tailscale-only').checked;
|
||||||
const newLogo = document.getElementById('edit-logo-url').value.trim();
|
const newLogo = document.getElementById('edit-logo-url').value.trim();
|
||||||
|
const newCategory = document.getElementById('edit-service-category')?.value || '';
|
||||||
|
|
||||||
if (!newSubdomain) {
|
if (!newSubdomain) {
|
||||||
showNotification('Subdomain is required', 'warning');
|
showNotification('Subdomain is required', 'warning');
|
||||||
@@ -51,6 +62,7 @@
|
|||||||
if (newIp !== currentEditService.ip) changes.push('ip');
|
if (newIp !== currentEditService.ip) changes.push('ip');
|
||||||
if (tailscaleOnly !== (currentEditService.tailscaleOnly || false)) changes.push('tailscale');
|
if (tailscaleOnly !== (currentEditService.tailscaleOnly || false)) changes.push('tailscale');
|
||||||
if (newLogo && newLogo !== currentEditService.logo) changes.push('logo');
|
if (newLogo && newLogo !== currentEditService.logo) changes.push('logo');
|
||||||
|
if (newCategory !== (currentEditService.category || '')) changes.push('category');
|
||||||
|
|
||||||
if (changes.length === 0) {
|
if (changes.length === 0) {
|
||||||
closeServiceEditModal();
|
closeServiceEditModal();
|
||||||
@@ -72,7 +84,8 @@
|
|||||||
port: newPort || currentEditService.port,
|
port: newPort || currentEditService.port,
|
||||||
ip: newIp,
|
ip: newIp,
|
||||||
tailscaleOnly,
|
tailscaleOnly,
|
||||||
logo: newLogo || undefined
|
logo: newLogo || undefined,
|
||||||
|
category: newCategory
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,7 +104,8 @@
|
|||||||
port: newPort || window.APPS[appIndex].port,
|
port: newPort || window.APPS[appIndex].port,
|
||||||
ip: newIp,
|
ip: newIp,
|
||||||
tailscaleOnly,
|
tailscaleOnly,
|
||||||
logo: newLogo || window.APPS[appIndex].logo
|
logo: newLogo || window.APPS[appIndex].logo,
|
||||||
|
category: newCategory || undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,9 @@
|
|||||||
name: serviceConfig.name,
|
name: serviceConfig.name,
|
||||||
logo: serviceConfig.logo || `/assets/${serviceConfig.subdomain}.png`
|
logo: serviceConfig.logo || `/assets/${serviceConfig.subdomain}.png`
|
||||||
};
|
};
|
||||||
|
// Forward optional metadata fields if provided
|
||||||
|
if (serviceConfig.category) newService.category = serviceConfig.category;
|
||||||
|
if (serviceConfig.containerId) newService.containerId = serviceConfig.containerId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await secureFetch('/api/v1/services', {
|
const response = await secureFetch('/api/v1/services', {
|
||||||
|
|||||||
@@ -82,6 +82,16 @@
|
|||||||
Enter a URL or upload an image file (PNG, JPG, SVG)
|
Enter a URL or upload an image file (PNG, JPG, SVG)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Category -->
|
||||||
|
<div>
|
||||||
|
<label for="edit-service-category" class="form-label-accent-sm">
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<select id="edit-service-category" data-role="service-category" class="form-input-md">
|
||||||
|
<option value="">— No category —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="weather-modal-buttons" style="margin-top: 24px;">
|
<div class="weather-modal-buttons" style="margin-top: 24px;">
|
||||||
@@ -239,6 +249,15 @@
|
|||||||
Reload Caddy after adding
|
Reload Caddy after adding
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<!-- Category -->
|
||||||
|
<div>
|
||||||
|
<label for="service-category-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Category</label>
|
||||||
|
<select id="service-category-input" data-role="service-category" style="width: 100%;">
|
||||||
|
<option value="">— No category —</option>
|
||||||
|
</select>
|
||||||
|
<div style="font-size: 0.7rem; color: var(--muted); margin-top: 3px;">Group services on the dashboard by purpose (Media, Productivity, etc.)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr style="border: none; border-top: 1px solid var(--border); margin: 4px 0;" />
|
<hr style="border: none; border-top: 1px solid var(--border); margin: 4px 0;" />
|
||||||
|
|
||||||
<div class="grid-2col">
|
<div class="grid-2col">
|
||||||
@@ -326,6 +345,14 @@
|
|||||||
Follow Redirects
|
Follow Redirects
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<!-- Category (external) -->
|
||||||
|
<div>
|
||||||
|
<label for="external-service-category" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Category</label>
|
||||||
|
<select id="external-service-category" data-role="service-category" style="width: 100%;">
|
||||||
|
<option value="">— No category —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -155,47 +155,16 @@
|
|||||||
return b.toFixed(1) + ' ' + units[i];
|
return b.toFixed(1) + ' ' + units[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Robust services count -----
|
function setServicesCard() {
|
||||||
// Read from multiple sources so we always have a number:
|
const total = (window.APPS || []).length;
|
||||||
// 1. window.APPS (populated by grid.js after loadServices)
|
let up = 0;
|
||||||
// 2. #cards .card elements (post-buildGrid)
|
document.querySelectorAll('#cards .card').forEach(c => {
|
||||||
// 3. live fetch /api/v1/services (last-resort fallback if grid hasn't run)
|
if (c.dataset.status === 'on') up++;
|
||||||
async function fetchServicesCount() {
|
});
|
||||||
// Source 1+2: window.APPS / DOM cards
|
|
||||||
if (Array.isArray(window.APPS) && window.APPS.length > 0) {
|
|
||||||
const up = document.querySelectorAll('#cards .card[data-status="on"]').length;
|
|
||||||
return { total: window.APPS.length, up, source: 'APPS' };
|
|
||||||
}
|
|
||||||
const cards = document.querySelectorAll('#cards .card');
|
|
||||||
if (cards.length > 0) {
|
|
||||||
const up = Array.from(cards).filter(c => c.dataset.status === 'on').length;
|
|
||||||
return { total: cards.length, up, source: 'DOM' };
|
|
||||||
}
|
|
||||||
// Source 3: fetch live (endpoints may return {success, services:[...]} OR raw array)
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/v1/services', { cache: 'no-store' });
|
|
||||||
if (!r.ok) return { total: 0, up: 0, source: 'fetch-fail' };
|
|
||||||
const body = await r.json();
|
|
||||||
const list = (body && Array.isArray(body.services)) ? body.services
|
|
||||||
: (Array.isArray(body)) ? body
|
|
||||||
: [];
|
|
||||||
// Persist for the grid so this fallback only fires once
|
|
||||||
if (Array.isArray(window.APPS) || typeof window.APPS === 'undefined') window.APPS = list;
|
|
||||||
const up = document.querySelectorAll('#cards .card[data-status="on"]').length;
|
|
||||||
return { total: list.length, up, source: 'fetch' };
|
|
||||||
} catch (_) {
|
|
||||||
return { total: 0, up: 0, source: 'fetch-error' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setServicesCard() {
|
|
||||||
const { total, up } = await fetchServicesCount();
|
|
||||||
const el = document.getElementById('dc-monitor-services');
|
const el = document.getElementById('dc-monitor-services');
|
||||||
const sub = document.getElementById('dc-monitor-services-sub');
|
const sub = document.getElementById('dc-monitor-services-sub');
|
||||||
if (el) el.textContent = `${up} / ${total}`;
|
if (el) el.textContent = `${up} / ${total}`;
|
||||||
if (sub) sub.textContent = total === 0
|
if (sub) sub.textContent = total === 0 ? 'no services yet' : `${up} online · ${total - up} offline`;
|
||||||
? 'no services yet'
|
|
||||||
: `${up} online · ${total - up} offline`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyHealthSummary(data) {
|
function applyHealthSummary(data) {
|
||||||
@@ -2,11 +2,50 @@
|
|||||||
(function() {
|
(function() {
|
||||||
const searchInput = document.getElementById('service-filter-search');
|
const searchInput = document.getElementById('service-filter-search');
|
||||||
const statusSelect = document.getElementById('service-filter-status');
|
const statusSelect = document.getElementById('service-filter-status');
|
||||||
|
const categorySelect = document.getElementById('service-filter-category');
|
||||||
const countSpan = document.getElementById('service-filter-count');
|
const countSpan = document.getElementById('service-filter-count');
|
||||||
|
|
||||||
|
// Build a single category list from both the API categories and any
|
||||||
|
// categories present on the actual rendered cards (covers custom services
|
||||||
|
// whose category isn't in TEMPLATE_CATEGORIES).
|
||||||
|
function getCategoryList() {
|
||||||
|
const seen = new Set();
|
||||||
|
const fromCards = new Set();
|
||||||
|
document.querySelectorAll('#cards .card[data-category]').forEach(c => {
|
||||||
|
const cat = c.dataset.category.trim();
|
||||||
|
if (cat) fromCards.add(cat);
|
||||||
|
});
|
||||||
|
const apiCats = (window.DC_CATEGORIES || (typeof DC !== 'undefined' && DC.CATEGORIES)) || {};
|
||||||
|
const all = Object.keys(apiCats).concat([...fromCards].filter(c => !apiCats[c]));
|
||||||
|
all.forEach(c => seen.add(c));
|
||||||
|
return { list: [...seen], apiCats };
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCategoryDropdown() {
|
||||||
|
if (!categorySelect) return;
|
||||||
|
const { list, apiCats } = getCategoryList();
|
||||||
|
const current = categorySelect.value;
|
||||||
|
categorySelect.innerHTML = '<option value="all">All Categories</option>';
|
||||||
|
list.sort().forEach(name => {
|
||||||
|
const info = apiCats[name];
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = name;
|
||||||
|
opt.textContent = info ? `${info.icon || ''} ${name}`.trim() : name;
|
||||||
|
categorySelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
// Restore selection if it still exists
|
||||||
|
if (current && [...categorySelect.options].some(o => o.value === current)) {
|
||||||
|
categorySelect.value = current;
|
||||||
|
} else {
|
||||||
|
categorySelect.value = 'all';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateFilter() {
|
function updateFilter() {
|
||||||
|
refreshCategoryDropdown();
|
||||||
const query = searchInput.value.toLowerCase().trim();
|
const query = searchInput.value.toLowerCase().trim();
|
||||||
const statusFilter = statusSelect.value; // 'all', 'on', or 'off'
|
const statusFilter = statusSelect.value; // 'all', 'on', or 'off'
|
||||||
|
const categoryFilter = categorySelect ? categorySelect.value : 'all';
|
||||||
|
|
||||||
const cards = document.querySelectorAll('#cards .card');
|
const cards = document.querySelectorAll('#cards .card');
|
||||||
let visibleCount = 0;
|
let visibleCount = 0;
|
||||||
@@ -15,11 +54,13 @@
|
|||||||
const name = card.querySelector('.name')?.textContent?.toLowerCase() || '';
|
const name = card.querySelector('.name')?.textContent?.toLowerCase() || '';
|
||||||
const app = card.dataset.app?.toLowerCase() || '';
|
const app = card.dataset.app?.toLowerCase() || '';
|
||||||
const status = card.dataset.status || 'off'; // 'on' or 'off'
|
const status = card.dataset.status || 'off'; // 'on' or 'off'
|
||||||
|
const category = card.dataset.category || '';
|
||||||
|
|
||||||
const matchesSearch = !query || name.includes(query) || app.includes(query);
|
const matchesSearch = !query || name.includes(query) || app.includes(query);
|
||||||
const matchesStatus = statusFilter === 'all' || status === statusFilter;
|
const matchesStatus = statusFilter === 'all' || status === statusFilter;
|
||||||
|
const matchesCategory = categoryFilter === 'all' || category === categoryFilter;
|
||||||
|
|
||||||
if (matchesSearch && matchesStatus) {
|
if (matchesSearch && matchesStatus && matchesCategory) {
|
||||||
card.style.display = '';
|
card.style.display = '';
|
||||||
visibleCount++;
|
visibleCount++;
|
||||||
} else {
|
} else {
|
||||||
@@ -44,6 +85,7 @@
|
|||||||
|
|
||||||
searchInput?.addEventListener('input', debounce(updateFilter, 200));
|
searchInput?.addEventListener('input', debounce(updateFilter, 200));
|
||||||
statusSelect?.addEventListener('change', updateFilter);
|
statusSelect?.addEventListener('change', updateFilter);
|
||||||
|
categorySelect?.addEventListener('change', updateFilter);
|
||||||
|
|
||||||
// Initial count on page load
|
// Initial count on page load
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
@@ -52,6 +94,7 @@
|
|||||||
setTimeout(updateFilter, 500);
|
setTimeout(updateFilter, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose for external triggers
|
// Expose for external triggers (called after buildGrid to repopulate categories)
|
||||||
window.refreshServiceFilter = updateFilter;
|
window.refreshServiceFilter = updateFilter;
|
||||||
|
window.refreshCategoryDropdown = refreshCategoryDropdown;
|
||||||
})();
|
})();
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'dashcaddy-shell-8ef9c82616';
|
const CACHE = 'dashcaddy-shell-43a872cc40';
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
Reference in New Issue
Block a user