Compare commits
8
Commits
v1.8.0
..
ef855e3fd7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef855e3fd7 | ||
|
|
3dff49cdc5 | ||
|
|
d230b39948 | ||
|
|
7bbd969fa2 | ||
|
|
4f377970d7 | ||
|
|
7f0d43943c | ||
|
|
9ab947a394 | ||
|
|
ad9400490d |
No files matched your search
+15
@@ -2,6 +2,8 @@
|
||||
node_modules/
|
||||
|
||||
# Runtime state/config files (generated, not source)
|
||||
# Note: data/ subdir contains runtime state (credentials, secrets, history) — never commit
|
||||
dashcaddy-api/data/
|
||||
dashcaddy-api/credentials.json
|
||||
dashcaddy-api/.env
|
||||
.env
|
||||
@@ -17,6 +19,19 @@ dashcaddy-api/update-config.json
|
||||
dashcaddy-api/update-history.json
|
||||
dashcaddy-api/dashcaddy-errors.log
|
||||
|
||||
# Auto-updater backups (created by dashcaddy-update.sh when rolling back)
|
||||
start.sh.bak*
|
||||
scripts/*.bak*
|
||||
|
||||
# Auto-updater runtime state (history + secrets + staging)
|
||||
updates/
|
||||
|
||||
# Scratch / debug scripts (left over from past sessions)
|
||||
cm_check*.js
|
||||
full_test.js
|
||||
login_test.js
|
||||
login_backup_test.js
|
||||
|
||||
# Build output
|
||||
dashcaddy-installer/build-output/
|
||||
dashcaddy-installer/dist/
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.8.0
|
||||
c64bbe2
|
||||
@@ -2459,6 +2459,74 @@ const APP_TEMPLATES = {
|
||||
"World data is persisted in the data volume",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+82
-328
@@ -9,6 +9,15 @@ const { execSync } = require('child_process');
|
||||
const crypto = require('crypto');
|
||||
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_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');
|
||||
@@ -20,14 +29,6 @@ class BackupManager extends EventEmitter {
|
||||
this.history = this.loadHistory();
|
||||
this.scheduledJobs = new Map();
|
||||
this.running = false;
|
||||
this.notificationManager = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the notification manager for sending backup notifications
|
||||
*/
|
||||
setNotificationManager(nm) {
|
||||
this.notificationManager = nm;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,7 +84,7 @@ class BackupManager extends EventEmitter {
|
||||
case 'monthly':
|
||||
intervalMs = 30 * 24 * 60 * 60 * 1000;
|
||||
break;
|
||||
default: {
|
||||
default:
|
||||
// Custom interval in minutes
|
||||
const minutes = parseInt(backup.schedule, 10);
|
||||
if (!isNaN(minutes) && minutes > 0) {
|
||||
@@ -92,7 +93,6 @@ class BackupManager extends EventEmitter {
|
||||
console.error(`[BackupManager] Invalid schedule for ${name}: ${backup.schedule}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule the job
|
||||
@@ -184,15 +184,12 @@ class BackupManager extends EventEmitter {
|
||||
await this.cleanupOldBackups(name, backup.retention);
|
||||
}
|
||||
|
||||
this.emit('backup-complete', historyEntry);
|
||||
|
||||
// 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);
|
||||
});
|
||||
// Enforce storage limit (delete oldest until within maxStorageBytes)
|
||||
if (backup.maxStorageBytes) {
|
||||
await this.enforceStorageLimit(name, backup.maxStorageBytes);
|
||||
}
|
||||
|
||||
this.emit('backup-complete', historyEntry);
|
||||
console.log(`[BackupManager] Backup ${name} completed in ${duration}ms`);
|
||||
|
||||
return historyEntry;
|
||||
@@ -209,14 +206,7 @@ class BackupManager extends EventEmitter {
|
||||
|
||||
this.addToHistory(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;
|
||||
}
|
||||
}
|
||||
@@ -566,42 +556,17 @@ class BackupManager extends EventEmitter {
|
||||
switch (destination.type) {
|
||||
case 'local':
|
||||
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:
|
||||
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
|
||||
*/
|
||||
async saveToLocal(data, destination, backupId) {
|
||||
const backupDir = destination.path || DEFAULT_BACKUP_DIR;
|
||||
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
@@ -609,9 +574,9 @@ class BackupManager extends EventEmitter {
|
||||
|
||||
const filename = `${backupId}.backup`;
|
||||
const filepath = path.join(backupDir, filename);
|
||||
|
||||
|
||||
fs.writeFileSync(filepath, data);
|
||||
|
||||
|
||||
return {
|
||||
type: 'local',
|
||||
path: filepath,
|
||||
@@ -619,257 +584,6 @@ 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
|
||||
*/
|
||||
@@ -904,24 +618,9 @@ class BackupManager extends EventEmitter {
|
||||
throw new Error(`Backup not found: ${backupId}`);
|
||||
}
|
||||
|
||||
// Load backup data — try each destination location until one succeeds
|
||||
const location = backup.locations[0]; // Primary location
|
||||
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;
|
||||
}
|
||||
// Load backup data
|
||||
const location = backup.locations[0]; // Use first location
|
||||
let data = fs.readFileSync(location.path);
|
||||
|
||||
// Decrypt if needed
|
||||
if (backup.encrypted && options.encryptionKey) {
|
||||
@@ -1018,6 +717,63 @@ class BackupManager extends EventEmitter {
|
||||
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
|
||||
*/
|
||||
@@ -1032,18 +788,16 @@ class BackupManager extends EventEmitter {
|
||||
|
||||
for (const backup of toDelete) {
|
||||
try {
|
||||
// Delete from all locations (local + cloud)
|
||||
// Delete from all locations
|
||||
for (const location of backup.locations) {
|
||||
try {
|
||||
await this._deleteFromDestination(location);
|
||||
} catch (delErr) {
|
||||
console.warn(`[BackupManager] Could not delete ${location.type} location for ${backup.id}:`, delErr.message);
|
||||
if (location.type === 'local' && fs.existsSync(location.path)) {
|
||||
fs.unlinkSync(location.path);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from history
|
||||
this.history = this.history.filter(b => b.id !== backup.id);
|
||||
|
||||
|
||||
console.log(`[BackupManager] Deleted old backup: ${backup.id}`);
|
||||
} catch (error) {
|
||||
console.error(`[BackupManager] Error deleting backup ${backup.id}:`, error.message);
|
||||
|
||||
@@ -319,6 +319,53 @@ class CredentialManager {
|
||||
: data.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a credential with diagnostic info on failure.
|
||||
*
|
||||
* Used by the TOTP recovery flow: when a user is locked out and the secret
|
||||
* can't be decrypted (e.g. encryption key was rotated by a container
|
||||
* recreate), we need to distinguish "no secret was ever set" from "secret
|
||||
* is on disk but unreadable" so the UI can show a useful next step.
|
||||
*
|
||||
* Status codes:
|
||||
* 'ok' — value decrypted / returned as-is
|
||||
* 'missing' — key is not present in the store at all
|
||||
* 'unreadable' — key is present but decryption failed (key mismatch / corruption)
|
||||
* 'malformed' — entry exists but value is not in expected encrypted format
|
||||
*
|
||||
* @param {string} key - Credential identifier
|
||||
* @returns {Promise<{ status: string, value: string|null, error?: string }>}
|
||||
*/
|
||||
async diagnose(key) {
|
||||
try {
|
||||
const credentials = await this.loadCredentialsFile();
|
||||
const data = credentials[key];
|
||||
if (!data) return { status: 'missing', value: null };
|
||||
|
||||
if (!cryptoUtils.isEncrypted(data.value)) {
|
||||
// Plaintext entry — return as-is
|
||||
return { status: 'ok', value: data.value };
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = cryptoUtils.decrypt(data.value);
|
||||
return { status: 'ok', value: decrypted };
|
||||
} catch (decryptErr) {
|
||||
// Most common cause: the encryption key on disk is different from
|
||||
// the key that originally encrypted this entry (rotated by a
|
||||
// container recreate that didn't preserve CREDENTIALS_FILE env).
|
||||
console.warn(
|
||||
`[CredentialManager] '${key}' is present but cannot be decrypted ` +
|
||||
`(likely encryption-key mismatch): ${decryptErr.message}`
|
||||
);
|
||||
return { status: 'unreadable', value: null, error: decryptErr.message };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[CredentialManager] diagnose('${key}') failed:`, err.message);
|
||||
return { status: 'malformed', value: null, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFromFile(key) {
|
||||
await this._lockedUpdate(credentials => {
|
||||
delete credentials[key];
|
||||
|
||||
@@ -66,6 +66,31 @@ function loadOrCreateKey() {
|
||||
if (keyData.length >= 64) {
|
||||
encryptionKey = Buffer.from(keyData, 'hex');
|
||||
console.log('[Crypto] Loaded encryption key from file');
|
||||
// First-run bootstrap: if .bak doesn't exist yet, write the current
|
||||
// key to it. This ensures the silent recovery path is available from
|
||||
// the very next restart without requiring an explicit rotateKey().
|
||||
if (!fs.existsSync(KEY_FILE + '.bak')) {
|
||||
try {
|
||||
fs.writeFileSync(KEY_FILE + '.bak', keyData, { mode: 0o600 });
|
||||
console.log(`[Crypto] Seeded ${KEY_FILE}.bak with current key for future fallback`);
|
||||
} catch (e) {
|
||||
console.warn('[Crypto] Could not seed .bak key file:', e.message);
|
||||
}
|
||||
}
|
||||
// Try fallback to .bak key if primary can't decrypt existing credentials.
|
||||
// This handles the "container recreate rotated the key" case where the
|
||||
// backup key on disk is the ORIGINAL key that can still read the
|
||||
// bind-mounted /app/data/credentials.json written before the upgrade.
|
||||
if (fs.existsSync(KEY_FILE + '.bak')) {
|
||||
try {
|
||||
const backupData = fs.readFileSync(KEY_FILE + '.bak', 'utf8').trim();
|
||||
if (backupData.length >= 64) {
|
||||
encryptionKey = tryFallbackToBackupKey(Buffer.from(keyData, 'hex'), Buffer.from(backupData, 'hex'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Crypto] Could not check backup key:', e.message);
|
||||
}
|
||||
}
|
||||
return encryptionKey;
|
||||
}
|
||||
// File exists but key is invalid/empty - will generate new one below
|
||||
@@ -89,6 +114,64 @@ function loadOrCreateKey() {
|
||||
return encryptionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the primary key fails to decrypt any existing credentials, try the backup
|
||||
* key. This is the silent recovery path: if a container recreate replaced
|
||||
* .encryption-key with a fresh one but left .encryption-key.bak (the previous
|
||||
* key), the old key can still decrypt the bind-mounted credentials.json and
|
||||
* the user stays logged in without ever noticing.
|
||||
*
|
||||
* Called only at startup when both key files exist. Returns the working key
|
||||
* (either primary or backup). If neither works, returns the primary (existing
|
||||
* behavior — `retrieve()` will surface "unreadable" via credential-manager.diagnose).
|
||||
*
|
||||
* @param {Buffer} primaryKey - key from .encryption-key
|
||||
* @param {Buffer} backupKey - key from .encryption-key.bak
|
||||
* @returns {Buffer} the key that should be used
|
||||
*/
|
||||
function tryFallbackToBackupKey(primaryKey, backupKey) {
|
||||
const CREDENTIALS_FILE = process.env.CREDENTIALS_FILE ||
|
||||
require('path').join(__dirname, 'credentials.json');
|
||||
if (!fs.existsSync(CREDENTIALS_FILE)) return primaryKey;
|
||||
|
||||
let credentials;
|
||||
try {
|
||||
credentials = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf8'));
|
||||
} catch {
|
||||
return primaryKey;
|
||||
}
|
||||
|
||||
// Find the first encrypted entry to probe
|
||||
const probeEntry = Object.values(credentials).find(v => v && v.value && isEncrypted(v.value));
|
||||
if (!probeEntry) return primaryKey;
|
||||
|
||||
const tryDecrypt = (key) => {
|
||||
const parts = probeEntry.value.split(':');
|
||||
if (parts.length !== 3) return false;
|
||||
try {
|
||||
const iv = Buffer.from(parts[0], 'base64');
|
||||
const tag = Buffer.from(parts[1], 'base64');
|
||||
const ct = Buffer.from(parts[2], 'base64');
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
Buffer.concat([decipher.update(ct), decipher.final()]);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
};
|
||||
|
||||
if (tryDecrypt(primaryKey)) return primaryKey;
|
||||
if (tryDecrypt(backupKey)) {
|
||||
console.warn(
|
||||
'[Crypto] Primary encryption key failed to decrypt credentials; ' +
|
||||
'fell back to .encryption-key.bak. The current primary key was set ' +
|
||||
'without preserving the original. Consider rotating the key explicitly ' +
|
||||
'via the credential-manager API to avoid this warning next restart.'
|
||||
);
|
||||
return backupKey;
|
||||
}
|
||||
return primaryKey; // neither works — credential-manager.diagnose() will report 'unreadable'
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive data
|
||||
* @param {string|object} data - Data to encrypt (strings or objects)
|
||||
@@ -276,6 +359,17 @@ function rotateKey() {
|
||||
const oldKey = loadOrCreateKey(); // Ensure we have the current key loaded
|
||||
const newKey = generateKey();
|
||||
|
||||
// Save the OLD key to .bak BEFORE swapping the primary. This gives the
|
||||
// startup-time fallback a way to recover the previous key if a future
|
||||
// restart loses the new one (e.g. another accidental recreate). The .bak
|
||||
// file is overwritten on each rotate so it always holds the previous key,
|
||||
// not an ever-accumulating history.
|
||||
try {
|
||||
fs.writeFileSync(KEY_FILE + '.bak', oldKey.toString('hex'), { mode: 0o600 });
|
||||
} catch (error) {
|
||||
console.warn(`[Crypto] Could not save backup key to ${KEY_FILE}.bak:`, error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(KEY_FILE, newKey.toString('hex'), { mode: 0o600 });
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
@@ -9,9 +9,24 @@ const http = require('http');
|
||||
const EventEmitter = require('events');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const paths = require('./platform-paths');
|
||||
|
||||
const HEALTH_CONFIG_FILE = process.env.HEALTH_CONFIG_FILE || path.join(__dirname, 'health-config.json');
|
||||
const HEALTH_HISTORY_FILE = process.env.HEALTH_HISTORY_FILE || path.join(__dirname, 'health-history.json');
|
||||
// Persist health config + history alongside the other state files (services.json,
|
||||
// config.json) rather than next to the source. In a container that data dir is the
|
||||
// mounted /app/data volume, so uptime history survives container recreates/updates;
|
||||
// previously these defaulted to __dirname (unmounted /app) and every recreate wiped
|
||||
// the accumulated history, blanking the dashboard uptime bars. Explicit env vars
|
||||
// still override.
|
||||
const HEALTH_DATA_DIR = process.env.HEALTH_DATA_DIR || path.dirname(paths.configFile);
|
||||
const HEALTH_CONFIG_FILE = process.env.HEALTH_CONFIG_FILE || path.join(HEALTH_DATA_DIR, 'health-config.json');
|
||||
const HEALTH_HISTORY_FILE = process.env.HEALTH_HISTORY_FILE || path.join(HEALTH_DATA_DIR, 'health-history.json');
|
||||
|
||||
// Legacy locations (next to the source) used before the data-dir default. Read these
|
||||
// once on first load if the new files are absent, so upgrading installs migrate their
|
||||
// accumulated history/config instead of starting empty. The next save() rewrites to
|
||||
// the new location.
|
||||
const LEGACY_HEALTH_CONFIG_FILE = path.join(__dirname, 'health-config.json');
|
||||
const LEGACY_HEALTH_HISTORY_FILE = path.join(__dirname, 'health-history.json');
|
||||
const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10); // 30 seconds
|
||||
const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff
|
||||
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
|
||||
@@ -541,8 +556,10 @@ class HealthChecker extends EventEmitter {
|
||||
*/
|
||||
loadConfig() {
|
||||
try {
|
||||
if (fs.existsSync(HEALTH_CONFIG_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(HEALTH_CONFIG_FILE, 'utf8'));
|
||||
const file = fs.existsSync(HEALTH_CONFIG_FILE) ? HEALTH_CONFIG_FILE
|
||||
: (HEALTH_CONFIG_FILE !== LEGACY_HEALTH_CONFIG_FILE && fs.existsSync(LEGACY_HEALTH_CONFIG_FILE) ? LEGACY_HEALTH_CONFIG_FILE : null);
|
||||
if (file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit('log', 'error', `Error loading config: ${error.message}`);
|
||||
@@ -566,8 +583,10 @@ class HealthChecker extends EventEmitter {
|
||||
*/
|
||||
loadHistory() {
|
||||
try {
|
||||
if (fs.existsSync(HEALTH_HISTORY_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(HEALTH_HISTORY_FILE, 'utf8'));
|
||||
const file = fs.existsSync(HEALTH_HISTORY_FILE) ? HEALTH_HISTORY_FILE
|
||||
: (HEALTH_HISTORY_FILE !== LEGACY_HEALTH_HISTORY_FILE && fs.existsSync(LEGACY_HEALTH_HISTORY_FILE) ? LEGACY_HEALTH_HISTORY_FILE : null);
|
||||
if (file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit('log', 'error', `Error loading history: ${error.message}`);
|
||||
|
||||
@@ -283,6 +283,7 @@ module.exports = function configureMiddleware(app, {
|
||||
{ path: '/probe/', prefix: true },
|
||||
{ path: '/api/v1/tailscale/', prefix: true },
|
||||
{ path: '/api/v1/totp/config', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/totp/recovery-info', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/totp/verify', exact: true },
|
||||
{ path: '/api/v1/totp/setup', exact: true, method: 'POST' },
|
||||
{ path: '/api/v1/totp/verify-setup', exact: true, method: 'POST' },
|
||||
@@ -304,6 +305,15 @@ module.exports = function configureMiddleware(app, {
|
||||
{ path: '/api/v1/license/feature/', prefix: true, method: 'GET' },
|
||||
{ path: '/api/v1/config', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/services/status', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' },
|
||||
// Read-only update/version info shown on the dashboard view (verification
|
||||
// modal, topbar version, update badges). Mutating actions — update-apply,
|
||||
// rollback (POST) — are NOT listed here and stay TOTP-protected.
|
||||
{ path: '/api/v1/system/version', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-status', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-history', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-check', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/updates/available', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-notify', exact: true, method: 'POST' },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dashcaddy-api",
|
||||
"version": "1.8.0",
|
||||
"version": "1.7.8",
|
||||
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -37,6 +37,66 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
||||
});
|
||||
}, 'totp-config-get'));
|
||||
|
||||
// Recovery diagnostic (public, no auth required).
|
||||
//
|
||||
// Returns information a locked-out user needs to choose a recovery path:
|
||||
// - whether TOTP is configured at all (isSetUp)
|
||||
// - whether the stored secret is readable by the current encryption key
|
||||
// - a human-readable hint matching the situation
|
||||
//
|
||||
// Status values:
|
||||
// 'not_configured' — no TOTP setup yet, user should set it up
|
||||
// 'healthy' — secret present and decryptable, normal login
|
||||
// 'unreadable' — secret on disk but can't decrypt (key rotated)
|
||||
// 'corrupt' — entry exists but value is malformed
|
||||
//
|
||||
// This route never returns the secret itself — only metadata about it.
|
||||
router.get('/totp/recovery-info', asyncHandler(async (req, res) => {
|
||||
if (!ctx.totpConfig.isSetUp) {
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'not_configured',
|
||||
isSetUp: false,
|
||||
hint: 'TOTP has not been set up on this server yet. Open settings to configure it.'
|
||||
});
|
||||
}
|
||||
|
||||
const diag = await ctx.credentialManager.diagnose('totp.secret');
|
||||
if (diag.status === 'ok') {
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'healthy',
|
||||
isSetUp: true,
|
||||
hint: 'TOTP is configured and the stored secret is readable. Enter your authenticator code to log in.'
|
||||
});
|
||||
}
|
||||
if (diag.status === 'unreadable') {
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'unreadable',
|
||||
isSetUp: true,
|
||||
hint: 'Your stored TOTP secret is on disk but cannot be decrypted — this usually means the encryption key changed during an upgrade. ' +
|
||||
'If you saved your Base32 secret when you first set up TOTP, paste it below to restore access. ' +
|
||||
'Otherwise you will need SSH access to the server to recover or rotate the key.'
|
||||
});
|
||||
}
|
||||
if (diag.status === 'missing') {
|
||||
// Config says isSetUp:true but no secret in store — corrupted config state
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'corrupt',
|
||||
isSetUp: true,
|
||||
hint: 'TOTP is marked as configured but the secret is missing. Set up TOTP again with a fresh secret.'
|
||||
});
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'corrupt',
|
||||
isSetUp: true,
|
||||
hint: 'TOTP storage is in an unexpected state. ' + (diag.error || '')
|
||||
});
|
||||
}, 'totp-recovery-info'));
|
||||
|
||||
// Generate new TOTP secret + QR code
|
||||
router.post('/totp/setup', asyncHandler(async (req, res) => {
|
||||
const { authenticator } = require('otplib');
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
const express = require('express');
|
||||
const { success } = require('../response-helpers');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { success } = require('../response-helpers');
|
||||
|
||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups');
|
||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
|
||||
const DEFAULT_MAX_STORAGE_BYTES = process.env.BACKUP_MAX_STORAGE_BYTES
|
||||
? parseInt(process.env.BACKUP_MAX_STORAGE_BYTES, 10)
|
||||
: 0;
|
||||
|
||||
/**
|
||||
* Backups routes factory
|
||||
@@ -41,6 +45,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
runImmediately: backup.runImmediately || false,
|
||||
destination: backup.destination || 'local',
|
||||
destinationPath: backup.destinationPath || DEFAULT_BACKUP_DIR,
|
||||
maxStorageBytes: backup.maxStorageBytes || null,
|
||||
lastRun: lastRun ? lastRun.toISOString() : null,
|
||||
nextRun: nextRun ? nextRun.toISOString() : null,
|
||||
lastBackupId: appHistory.length > 0 ? appHistory[0].id : null
|
||||
@@ -52,8 +57,8 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
|
||||
// Create or update a scheduled backup for an app
|
||||
router.post('/backups/schedule', premiumGating, asyncHandler(async (req, res) => {
|
||||
const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath } = req.body;
|
||||
|
||||
const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath, maxStorageBytes } = req.body;
|
||||
|
||||
if (!appId) {
|
||||
const { ValidationError } = require('../errors');
|
||||
throw new ValidationError('appId is required');
|
||||
@@ -61,7 +66,12 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
|
||||
const config = backupManager.getConfig();
|
||||
if (!config.backups) config.backups = {};
|
||||
|
||||
|
||||
// Parse maxStorageBytes if provided as string (e.g. "10GB")
|
||||
const parsedMaxStorage = maxStorageBytes
|
||||
? (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : maxStorageBytes)
|
||||
: null;
|
||||
|
||||
// Build the backup config for this app
|
||||
const backupConfig = {
|
||||
enabled: enabled !== undefined ? enabled : true,
|
||||
@@ -71,7 +81,8 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
destination: destination || 'local',
|
||||
destinationPath: destinationPath || DEFAULT_BACKUP_DIR,
|
||||
include: ['all'],
|
||||
destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }]
|
||||
destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }],
|
||||
maxStorageBytes: parsedMaxStorage
|
||||
};
|
||||
|
||||
config.backups[appId] = backupConfig;
|
||||
@@ -490,6 +501,39 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
success(res, { history });
|
||||
}, 'backups-history'));
|
||||
|
||||
// Get storage info for backups destination
|
||||
router.get('/backups/storage-info', asyncHandler(async (req, res) => {
|
||||
const storageInfo = await getStorageInfo();
|
||||
success(res, storageInfo);
|
||||
}, 'backups-storage-info'));
|
||||
|
||||
// Schedule a backup
|
||||
router.post('/backups/schedule', asyncHandler(async (req, res) => {
|
||||
const { name, schedule, maxStorageBytes, ...backupConfig } = req.body;
|
||||
|
||||
if (!name || !schedule) {
|
||||
return res.status(400).json({ error: 'name and schedule are required' });
|
||||
}
|
||||
|
||||
const config = backupManager.getConfig();
|
||||
|
||||
// Store maxStorageBytes in the backup config (converted to bytes)
|
||||
const maxBytes = typeof maxStorageBytes === 'number' && maxStorageBytes > 0
|
||||
? maxStorageBytes
|
||||
: (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : 0);
|
||||
|
||||
config.backups[name] = {
|
||||
...backupConfig,
|
||||
enabled: true,
|
||||
schedule,
|
||||
maxStorageBytes: maxBytes,
|
||||
destinations: backupConfig.destinations || [{ type: 'local' }]
|
||||
};
|
||||
|
||||
backupManager.updateConfig(config);
|
||||
success(res, { message: `Backup '${name}' scheduled`, maxStorageBytes: maxBytes });
|
||||
}, 'backups-schedule'));
|
||||
|
||||
// Restore from backup
|
||||
router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => {
|
||||
const result = await backupManager.restoreBackup(req.params.backupId, req.body);
|
||||
@@ -616,7 +660,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
*/
|
||||
function calculateNextRun(lastRun, schedule) {
|
||||
if (!lastRun) return null;
|
||||
|
||||
|
||||
const intervals = {
|
||||
'hourly': 60 * 60 * 1000,
|
||||
'daily': 24 * 60 * 60 * 1000,
|
||||
@@ -625,7 +669,7 @@ function calculateNextRun(lastRun, schedule) {
|
||||
};
|
||||
|
||||
const baseInterval = intervals[schedule];
|
||||
|
||||
|
||||
if (baseInterval) {
|
||||
return new Date(lastRun.getTime() + baseInterval);
|
||||
}
|
||||
@@ -653,3 +697,121 @@ function formatBytes(bytes) {
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage information for the backup directory
|
||||
*/
|
||||
async function getStorageInfo() {
|
||||
const result = {
|
||||
destination: DEFAULT_BACKUP_DIR,
|
||||
maxStorageBytes: DEFAULT_MAX_STORAGE_BYTES,
|
||||
usedBytes: 0,
|
||||
availableBytes: 0,
|
||||
usagePercent: 0,
|
||||
backupCount: 0,
|
||||
oldestBackup: null,
|
||||
newestBackup: null
|
||||
};
|
||||
|
||||
try {
|
||||
// Get disk space info
|
||||
const diskSpace = await getDiskSpaceInfo(DEFAULT_BACKUP_DIR);
|
||||
result.availableBytes = diskSpace.available;
|
||||
|
||||
// Scan for backup files
|
||||
if (DEFAULT_MAX_STORAGE_BYTES > 0) {
|
||||
result.maxStorageBytes = DEFAULT_MAX_STORAGE_BYTES;
|
||||
} else {
|
||||
result.maxStorageBytes = diskSpace.total || 0;
|
||||
}
|
||||
|
||||
let totalSize = 0;
|
||||
let oldestTime = null;
|
||||
let newestTime = null;
|
||||
|
||||
try {
|
||||
const entries = await fsp.readdir(DEFAULT_BACKUP_DIR);
|
||||
for (const entry of entries) {
|
||||
if (entry.endsWith('.backup')) {
|
||||
const filePath = path.join(DEFAULT_BACKUP_DIR, entry);
|
||||
try {
|
||||
const stats = await fsp.stat(filePath);
|
||||
totalSize += stats.size;
|
||||
result.backupCount++;
|
||||
|
||||
const fileTime = new Date(stats.mtime);
|
||||
if (!oldestTime || fileTime < oldestTime) oldestTime = fileTime;
|
||||
if (!newestTime || fileTime > newestTime) newestTime = fileTime;
|
||||
} catch (e) {
|
||||
// Skip files we can't stat
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Backup directory might not exist yet
|
||||
}
|
||||
|
||||
result.usedBytes = totalSize;
|
||||
result.oldestBackup = oldestTime ? oldestTime.toISOString() : null;
|
||||
result.newestBackup = newestTime ? newestTime.toISOString() : null;
|
||||
|
||||
// Calculate available (total limit - used), or from disk space if no limit set
|
||||
if (result.maxStorageBytes > 0) {
|
||||
result.availableBytes = Math.max(0, result.maxStorageBytes - totalSize);
|
||||
result.usagePercent = parseFloat(((totalSize / result.maxStorageBytes) * 100).toFixed(2));
|
||||
} else if (diskSpace.total) {
|
||||
result.availableBytes = diskSpace.available;
|
||||
result.usagePercent = diskSpace.total > 0
|
||||
? parseFloat((((diskSpace.total - diskSpace.available) / diskSpace.total) * 100).toFixed(2))
|
||||
: 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BackupsRouter] Error getting storage info:', error.message);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get disk space info (filesystem-agnostic)
|
||||
*/
|
||||
async function getDiskSpaceInfo(dirPath) {
|
||||
try {
|
||||
const diskInfo = await fsp.statfs(dirPath);
|
||||
return {
|
||||
total: diskInfo.blocks * diskInfo.bsize,
|
||||
available: diskInfo.bfree * diskInfo.bsize,
|
||||
used: (diskInfo.blocks - diskInfo.bfree) * diskInfo.bsize
|
||||
};
|
||||
} catch (error) {
|
||||
// Directory might not exist or be accessible
|
||||
return { total: 0, available: 0, used: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse storage size string like "10GB" to bytes
|
||||
*/
|
||||
function parseStorageSize(sizeStr) {
|
||||
if (!sizeStr || typeof sizeStr === 'number') return sizeStr || 0;
|
||||
|
||||
const match = String(sizeStr).match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB|K|M|G|T)?$/i);
|
||||
if (!match) return 0;
|
||||
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = (match[2] || 'B').toUpperCase();
|
||||
|
||||
const multipliers = {
|
||||
'B': 1,
|
||||
'K': 1024,
|
||||
'KB': 1024,
|
||||
'M': 1024 * 1024,
|
||||
'MB': 1024 * 1024,
|
||||
'G': 1024 * 1024 * 1024,
|
||||
'GB': 1024 * 1024 * 1024,
|
||||
'T': 1024 * 1024 * 1024 * 1024,
|
||||
'TB': 1024 * 1024 * 1024 * 1024
|
||||
};
|
||||
|
||||
return Math.floor(value * (multipliers[unit] || 1));
|
||||
}
|
||||
@@ -372,7 +372,7 @@ module.exports = function({
|
||||
// Add a new service
|
||||
router.post('/services', asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const { id, name, logo, category, containerId, port, ip, tailscaleOnly } = req.body;
|
||||
const { id, name, logo } = req.body;
|
||||
|
||||
if (!id || !name) {
|
||||
throw new ValidationError('id and name are required');
|
||||
@@ -391,14 +391,7 @@ module.exports = function({
|
||||
throw new ConflictError(`Service "${id}" already exists`, id);
|
||||
}
|
||||
|
||||
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);
|
||||
services.push({ id, name, logo: logo || `/assets/${id}.png` });
|
||||
return services;
|
||||
});
|
||||
|
||||
@@ -549,8 +542,6 @@ module.exports = function({
|
||||
};
|
||||
if (name) services[serviceIndex].name = name;
|
||||
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';
|
||||
} else {
|
||||
results.services = 'not found';
|
||||
|
||||
Regular → Executable
+6
-1
@@ -134,11 +134,16 @@ restart_container() {
|
||||
# Stop and remove existing container so new env var is applied
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
|
||||
# Re-create with same volumes and the SERVICES_FILE env var
|
||||
# Re-create with same volumes. CRITICAL: must include CREDENTIALS_FILE +
|
||||
# ENCRYPTION_KEY_FILE pointing at /app/data/ so the container reads the TOTP
|
||||
# secret from the bind-mounted host data dir (not image-local /app/credentials.json
|
||||
# which gets a fresh encryption key on every container recreate = TOTP breaks).
|
||||
docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \
|
||||
-p 127.0.0.1:3001:3001 \
|
||||
-v /opt/dashcaddy/dashcaddy-api/data:/app/data \
|
||||
-e SERVICES_FILE=/app/data/services.json \
|
||||
-e CREDENTIALS_FILE=/app/d...son \
|
||||
-e ENCRYPTION_KEY_FILE=/app/data/.encryption-key \
|
||||
"$image"
|
||||
log "Container restarted with fresh env"
|
||||
}
|
||||
|
||||
@@ -164,6 +164,21 @@ async function createApp() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Back-compat: reverse-proxy SSO snippets (Caddy forward_auth + per-service
|
||||
// auto-login pages) historically call these endpoints under the pre-1.5.0
|
||||
// prefix `/api/auth/...`. The canonical mount is `/api/v1`. Hand-maintained
|
||||
// Caddyfiles have repeatedly drifted back to the old prefix and 404'd the SSO
|
||||
// gate (breaking Plex/Jellyfin/Emby/chat). Transparently rewrite ONLY these two
|
||||
// auth paths to the v1 mount so the gate is tolerant of that drift. Must run
|
||||
// before configureMiddleware() so CSRF/auth see the canonical path. This is
|
||||
// deliberately narrow — NOT a general `/api` -> `/api/v1` alias.
|
||||
app.use((req, res, next) => {
|
||||
if (req.url.startsWith('/api/auth/gate/') || req.url.startsWith('/api/auth/app-token/')) {
|
||||
req.url = '/api/v1' + req.url.slice(4); // '/api'.length === 4
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Configure middleware
|
||||
const middlewareResult = configureMiddleware(app, {
|
||||
siteConfig: config.siteConfig,
|
||||
|
||||
@@ -155,16 +155,47 @@
|
||||
return b.toFixed(1) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function setServicesCard() {
|
||||
const total = (window.APPS || []).length;
|
||||
let up = 0;
|
||||
document.querySelectorAll('#cards .card').forEach(c => {
|
||||
if (c.dataset.status === 'on') up++;
|
||||
});
|
||||
// ----- Robust services count -----
|
||||
// Read from multiple sources so we always have a number:
|
||||
// 1. window.APPS (populated by grid.js after loadServices)
|
||||
// 2. #cards .card elements (post-buildGrid)
|
||||
// 3. live fetch /api/v1/services (last-resort fallback if grid hasn't run)
|
||||
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 sub = document.getElementById('dc-monitor-services-sub');
|
||||
if (el) el.textContent = `${up} / ${total}`;
|
||||
if (sub) sub.textContent = total === 0 ? 'no services yet' : `${up} online · ${total - up} offline`;
|
||||
if (sub) sub.textContent = total === 0
|
||||
? 'no services yet'
|
||||
: `${up} online · ${total - up} offline`;
|
||||
}
|
||||
|
||||
function applyHealthSummary(data) {
|
||||
@@ -27,10 +27,14 @@ readonly API_DIR="${SITES_DIR}/dashcaddy-api"
|
||||
readonly DASHBOARD_DIR="${SITES_DIR}/status"
|
||||
readonly CONTAINER_NAME="dashcaddy-api"
|
||||
readonly CADDY_ADMIN_PORT=2019
|
||||
readonly BACKUP_DIR="${BACKUP_DIR:-${INSTALL_DIR}/backups}"
|
||||
readonly DEFAULT_MAX_STORAGE_BYTES=""
|
||||
|
||||
# ---- Tunables (overridable via flags) --------------------------------------
|
||||
API_PORT=3001
|
||||
LOCAL_PORT=8080
|
||||
BACKUP_DIR=""
|
||||
BACKUP_LIMIT=""
|
||||
|
||||
# ---- Runtime state ---------------------------------------------------------
|
||||
DOMAIN_MODE="" # public | custom-tld | local
|
||||
@@ -389,6 +393,7 @@ EOF
|
||||
create_directories() {
|
||||
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 "${BACKUP_DIR}"
|
||||
ok "Directories created"
|
||||
}
|
||||
|
||||
@@ -626,7 +631,41 @@ CEOF
|
||||
# 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() {
|
||||
# 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
|
||||
services:
|
||||
dashcaddy-api:
|
||||
@@ -648,6 +687,7 @@ services:
|
||||
- ${DASHBOARD_DIR}:/app/dashboard:rw
|
||||
- /opt/dashcaddy/updates:/app/updates:rw
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- dashcaddy-backups:/app/backups
|
||||
environment:
|
||||
- CADDYFILE_PATH=/caddyfile
|
||||
- CADDY_ADMIN_URL=http://host.docker.internal:${CADDY_ADMIN_PORT}
|
||||
@@ -665,6 +705,10 @@ services:
|
||||
- DASHCADDY_HOST_UPDATES_DIR=/opt/dashcaddy/updates
|
||||
- DASHCADDY_API_SOURCE_DIR=${API_DIR}
|
||||
- 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:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
@@ -673,6 +717,14 @@ services:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
dashcaddy-backups:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: ${BACKUP_DIR}
|
||||
DCEOF
|
||||
|
||||
ok "docker-compose.yml generated"
|
||||
@@ -880,6 +932,8 @@ parse_args() {
|
||||
--skip-caddy) SKIP_CADDY=true; shift ;;
|
||||
--uninstall) UNINSTALL=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 ;;
|
||||
--help|-h) print_help; exit 0 ;;
|
||||
*) warn "Unknown option: $1 (ignored)"; shift ;;
|
||||
@@ -914,6 +968,8 @@ print_help() {
|
||||
--source PATH Use local source files
|
||||
--skip-docker Already have Docker
|
||||
--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
|
||||
--keep-config Keep configs during uninstall
|
||||
--yes Skip confirmations
|
||||
|
||||
@@ -226,17 +226,34 @@ class ConfigManager {
|
||||
* @returns {Promise<Object>} Disk space info
|
||||
*/
|
||||
async getDiskSpace(testPath) {
|
||||
// Note: This is a simplified version. In production, you'd use a library like 'check-disk-space'
|
||||
try {
|
||||
const stats = await fs.stat(testPath);
|
||||
|
||||
const fsPromises = require('fs').promises;
|
||||
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 {
|
||||
available: true,
|
||||
path: testPath
|
||||
path: testPath,
|
||||
total: totalBytes,
|
||||
used: usedBytes,
|
||||
free: freeBytes,
|
||||
availableBytes: availableBytes,
|
||||
usagePercent: parseFloat(((usedBytes / totalBytes) * 100).toFixed(2))
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
path: testPath,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,6 +62,11 @@ const state = {
|
||||
installPath: '',
|
||||
health: null
|
||||
},
|
||||
// Backup configuration
|
||||
backup: {
|
||||
maxStorageGB: 10,
|
||||
backupDir: ''
|
||||
},
|
||||
// Uninstall mode
|
||||
uninstallMode: false,
|
||||
uninstall: {
|
||||
@@ -373,6 +378,24 @@ function updateBranding(field, value) {
|
||||
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() {
|
||||
try {
|
||||
const result = await window.electronAPI.selectFile({
|
||||
@@ -420,6 +443,10 @@ async function startInstallation() {
|
||||
password: state.dns.password,
|
||||
token: state.dns.token
|
||||
} : null,
|
||||
backup: {
|
||||
maxStorageGB: state.backup.maxStorageGB,
|
||||
backupDir: state.backup.backupDir || null
|
||||
},
|
||||
autoStart: true
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -963,6 +990,31 @@ function renderDashboardSetup() {
|
||||
<p class="hint">Port for the DashCaddy API server (default: 3001)</p>
|
||||
</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>
|
||||
`;
|
||||
|
||||
@@ -7,15 +7,28 @@ services:
|
||||
volumes:
|
||||
- {{API_PATH}}:/app
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- dashcaddy-backups:/app/backups
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT={{API_PORT}}
|
||||
- SERVICES_FILE=/app/services.json
|
||||
- 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
|
||||
networks:
|
||||
- dashcaddy
|
||||
|
||||
volumes:
|
||||
dashcaddy-backups:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: {{BACKUP_DIR}}
|
||||
|
||||
networks:
|
||||
dashcaddy:
|
||||
driver: bridge
|
||||
@@ -1,272 +0,0 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
|
||||
/**
|
||||
* OpenClaw management routes
|
||||
* Proxies gateway API calls through DashCaddy so the token never leaves the server.
|
||||
*
|
||||
* GET /openclaw/status → container info + gateway health
|
||||
* POST /openclaw/deploy → deploy OpenClaw container
|
||||
* GET /openclaw/proxy/* → proxy GET to gateway
|
||||
* POST /openclaw/proxy/* → proxy POST to gateway
|
||||
* DELETE /openclaw → remove container
|
||||
*/
|
||||
module.exports = function openClawRoutes(ctx) {
|
||||
const router = express.Router();
|
||||
const docker = ctx.docker;
|
||||
const asyncHandler = ctx.asyncHandler;
|
||||
const log = ctx.log || console;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function findOpenClawContainer() {
|
||||
const containers = await docker.client.listContainers({ all: true });
|
||||
return containers.find(function(c) {
|
||||
return c.Image === 'ghcr.io/nousresearch/openclaw:latest' ||
|
||||
(c.Labels && c.Labels['dashcaddy.managed'] === 'true' &&
|
||||
c.Names.some(function(n) { return n.includes('openclaw'); }));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
async function getGatewayToken(containerId) {
|
||||
try {
|
||||
const info = await docker.client.containerInfo(containerId);
|
||||
const entry = (info.Config.Env || []).find(function(e) {
|
||||
return e.startsWith('OPENCLAW_GATEWAY_TOKEN=');
|
||||
});
|
||||
return entry ? entry.split('=')[1] : null;
|
||||
} catch(err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getContainerPort(containerId) {
|
||||
try {
|
||||
const containers = await docker.client.listContainers({ all: true });
|
||||
const c = containers.find(function(x) {
|
||||
return x.Id === containerId || x.Id.startsWith(containerId);
|
||||
});
|
||||
if (c && c.Ports) {
|
||||
const p = c.Ports.find(function(x) { return x.PrivatePort === 18792; });
|
||||
if (p && p.PublicPort) return String(p.PublicPort);
|
||||
}
|
||||
return '18792';
|
||||
} catch(err) {
|
||||
return '18792';
|
||||
}
|
||||
}
|
||||
|
||||
async function gatewayHealth(baseUrl, token) {
|
||||
return new Promise(function(resolve) {
|
||||
const headers = {};
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
const req = http.get(baseUrl + '/health', { headers: headers }, function(res) {
|
||||
let data = '';
|
||||
res.on('data', function(d) { data += d; });
|
||||
res.on('end', function() {
|
||||
try { resolve({ ok: true, data: JSON.parse(data) }); }
|
||||
catch(e) { resolve({ ok: true, data: data }); }
|
||||
});
|
||||
});
|
||||
req.on('error', function(e) { resolve({ ok: false, error: e.message }); });
|
||||
req.setTimeout(5000, function() { req.destroy(); resolve({ ok: false, error: 'timeout' }); });
|
||||
});
|
||||
}
|
||||
|
||||
function proxyRequest(req, res, targetBase, path, token) {
|
||||
const headers = {};
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
headers['X-Forwarded-For'] = req.ip;
|
||||
headers['X-Forwarded-Proto'] = req.protocol;
|
||||
|
||||
const url = targetBase + '/' + path;
|
||||
const method = req.method;
|
||||
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
const body = JSON.stringify(req.body);
|
||||
headers['Content-Type'] = 'application/json';
|
||||
headers['Content-Length'] = Buffer.byteLength(body);
|
||||
|
||||
const proxyReq = http.request(url, { method: method, headers: headers }, function(proxyRes) {
|
||||
res.set(proxyRes.headers);
|
||||
res.status(proxyRes.statusCode);
|
||||
proxyRes.on('data', function(d) { res.write(d); });
|
||||
proxyRes.on('end', function() { res.end(); });
|
||||
});
|
||||
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
|
||||
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
|
||||
proxyReq.write(body);
|
||||
proxyReq.end();
|
||||
} else {
|
||||
const proxyReq = http.get(url, { headers: headers }, function(proxyRes) {
|
||||
res.set(proxyRes.headers);
|
||||
res.status(proxyRes.statusCode);
|
||||
proxyRes.on('data', function(d) { res.write(d); });
|
||||
proxyRes.on('end', function() { res.end(); });
|
||||
});
|
||||
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
|
||||
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /openclaw/status ────────────────────────────────────────────────
|
||||
|
||||
router.get('/status', asyncHandler(async function(req, res) {
|
||||
const container = await findOpenClawContainer();
|
||||
|
||||
if (!container) {
|
||||
return res.json({ success: true, deployed: false });
|
||||
}
|
||||
|
||||
const token = await getGatewayToken(container.Id);
|
||||
const port = await getContainerPort(container.Id);
|
||||
const baseUrl = 'http://localhost:' + port;
|
||||
const health = await gatewayHealth(baseUrl, token);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deployed: true,
|
||||
container: {
|
||||
id: container.Id.slice(0, 12),
|
||||
name: container.Name,
|
||||
state: container.State,
|
||||
status: container.Status,
|
||||
created: container.Created,
|
||||
image: container.Image
|
||||
},
|
||||
gateway: {
|
||||
url: baseUrl,
|
||||
port: port,
|
||||
healthy: health.ok,
|
||||
healthData: health.data || null,
|
||||
tokenSet: !!token
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
// ── POST /openclaw/deploy ───────────────────────────────────────────────
|
||||
|
||||
router.post('/deploy', asyncHandler(async function(req, res) {
|
||||
const existing = await findOpenClawContainer();
|
||||
if (existing) {
|
||||
return res.status(409).json({ success: false, error: 'OpenClaw is already deployed' });
|
||||
}
|
||||
|
||||
const image = 'ghcr.io/nousresearch/openclaw:latest';
|
||||
const name = 'openclaw-' + Date.now();
|
||||
const gatewayToken = generateToken();
|
||||
|
||||
// Pull image
|
||||
log.info('Pulling ' + image + '...');
|
||||
try {
|
||||
await new Promise(function(resolve, reject) {
|
||||
docker.client.pull(image, function(err, stream) {
|
||||
if (err) return reject(err);
|
||||
docker.client.modem.followProgress(stream, function(err2) {
|
||||
if (err2) return reject(err2);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch(e) {
|
||||
log.error('OpenClaw pull failed: ' + e.message);
|
||||
return res.status(500).json({ success: false, error: 'Failed to pull image: ' + e.message });
|
||||
}
|
||||
|
||||
// Create + start container
|
||||
try {
|
||||
const container = await docker.client.createContainer({
|
||||
name: name,
|
||||
Image: image,
|
||||
Env: [
|
||||
'OPENCLAW_GATEWAY_MODE=local',
|
||||
'OPENCLAW_GATEWAY_TOKEN=' + gatewayToken
|
||||
],
|
||||
HostConfig: {
|
||||
PortBindings: { '18792/tcp': [{ HostPort: '18792' }] },
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
Labels: {
|
||||
'dashcaddy.managed': 'true',
|
||||
'dashcaddy.app': 'openclaw'
|
||||
}
|
||||
},
|
||||
ExposedPorts: { '18792/tcp': {} }
|
||||
});
|
||||
|
||||
await container.start();
|
||||
log.info('OpenClaw deployed: ' + container.id.slice(0, 12));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deployed: true,
|
||||
container: { id: container.id.slice(0, 12), name: name },
|
||||
gateway: {
|
||||
url: 'http://localhost:18792',
|
||||
token: gatewayToken
|
||||
}
|
||||
});
|
||||
} catch(e) {
|
||||
log.error('OpenClaw deploy failed: ' + e.message);
|
||||
res.status(500).json({ success: false, error: 'Deploy failed: ' + e.message });
|
||||
}
|
||||
}));
|
||||
|
||||
// ── GET /openclaw/proxy/* ───────────────────────────────────────────────
|
||||
|
||||
router.get('/proxy/*', asyncHandler(async function(req, res) {
|
||||
const container = await findOpenClawContainer();
|
||||
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
|
||||
|
||||
const token = await getGatewayToken(container.Id);
|
||||
const port = await getContainerPort(container.Id);
|
||||
const baseUrl = 'http://localhost:' + port;
|
||||
const path = req.params[0];
|
||||
|
||||
proxyRequest(req, res, baseUrl, path, token);
|
||||
}));
|
||||
|
||||
// ── POST /openclaw/proxy/* ──────────────────────────────────────────────
|
||||
|
||||
router.post('/proxy/*', asyncHandler(async function(req, res) {
|
||||
const container = await findOpenClawContainer();
|
||||
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
|
||||
|
||||
const token = await getGatewayToken(container.Id);
|
||||
const port = await getContainerPort(container.Id);
|
||||
const baseUrl = 'http://localhost:' + port;
|
||||
const path = req.params[0];
|
||||
|
||||
proxyRequest(req, res, baseUrl, path, token);
|
||||
}));
|
||||
|
||||
// ── DELETE /openclaw ───────────────────────────────────────────────────
|
||||
|
||||
router.delete('/', asyncHandler(async function(req, res) {
|
||||
const container = await findOpenClawContainer();
|
||||
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
|
||||
|
||||
try {
|
||||
const c = docker.client.container(container.Id);
|
||||
await c.stop().catch(function() {});
|
||||
await c.remove({ force: true });
|
||||
log.info('OpenClaw container ' + container.Id.slice(0, 12) + ' removed');
|
||||
res.json({ success: true, message: 'OpenClaw removed' });
|
||||
} catch(e) {
|
||||
log.error('Failed to remove OpenClaw: ' + e.message);
|
||||
res.status(500).json({ success: false, error: e.message });
|
||||
}
|
||||
}));
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
// ── token generator ──────────────────────────────────────────────────────────
|
||||
|
||||
function generateToken() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 32; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
#!/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)"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
@@ -19,6 +19,9 @@ const bundles = {
|
||||
JS('skeleton-loader.js'),
|
||||
JS('theme.js'),
|
||||
JS('totp-auth.js'),
|
||||
// totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js
|
||||
// calls from showTotpOverlay(). Must come after totp-auth.js.
|
||||
JS('totp-recovery.js'),
|
||||
JS('service-credentials.js'),
|
||||
JS('totp-settings.js'),
|
||||
JS('core', 'credentials.js'),
|
||||
|
||||
Vendored
-800
@@ -1,800 +0,0 @@
|
||||
(function(o){"use strict";class f{constructor(){this.errors=[],this.maxErrors=50}logError(y,l,r={}){const h={timestamp:new Date().toISOString(),context:y,message:l instanceof Error?l.message:l,stack:l instanceof Error?l.stack:null,metadata:r};this.errors.push(h),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${y}:`,l,r)}recoverFromError(y,l){switch(this.classifyError(y)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",y,{currentStep:l}),{action:"SKIP_STEP",nextStep:l+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",y),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",y),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",y,{currentStep:l}),{action:"SKIP_STEP",nextStep:l+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",y),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",y,{currentStep:l}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(y){const l=y.message||y.toString();return l.includes("element")&&l.includes("not found")?"ELEMENT_NOT_FOUND":l.includes("storage")||l.includes("quota")?"STORAGE_UNAVAILABLE":l.includes("driver")||l.includes("undefined")?"DRIVER_NOT_LOADED":l.includes("invalid")||l.includes("validation")?"INVALID_TOOLTIP":l.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const y={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(l=>{y.byContext[l.context]=(y.byContext[l.context]||0)+1;const r=this.classifyError({message:l.message});y.byType[r]=(y.byType[r]||0)+1}),y}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const y=document.createElement("div");return y.id="onboarding-fallback",y.style.cssText=`
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: var(--card-base, #2a2a2a);
|
||||
color: var(--fg, #ffffff);
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
z-index: 9999;
|
||||
max-width: 300px;
|
||||
font-size: 14px;
|
||||
`,y.innerHTML=`
|
||||
<strong>Welcome to DashCaddy!</strong><br>
|
||||
<p style="margin: 10px 0 0 0; font-size: 12px;">
|
||||
The interactive tour is unavailable, but you can explore the dashboard freely.
|
||||
Check the documentation for help getting started.
|
||||
</p>
|
||||
`,document.body.appendChild(y),setTimeout(()=>{y.parentNode&&y.parentNode.removeChild(y)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const y={data:{},getItem(l){return this.data[l]||null},setItem(l,r){this.data[l]=r},removeItem(l){delete this.data[l]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),y}sendToErrorTracking(y){}}o.ErrorHandler=f,console.log("[ErrorHandler] Module loaded")})(window);const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const y=await fetch("/api/v1/config");if(y.ok){const l=await y.json();if(l.tld&&(SITE.tld=l.tld.startsWith(".")?l.tld:"."+l.tld),l.dns&&(SITE.dnsIp=l.dns.ip||"",SITE.dnsPort=l.dns.port||DC.DEFAULTS.DNS_PORT),l.dnsServers&&typeof l.dnsServers=="object")for(const[h,a]of Object.entries(l.dnsServers))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(SITE.dnsServers[h]=a);l.configurationType&&(SITE.configurationType=l.configurationType),l.domain&&(SITE.domain=l.domain),l.defaults&&(SITE.defaults=l.defaults),l.routingMode&&(SITE.routingMode=l.routingMode),SITE.onboardingCompleted=l.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const r=document.getElementById("manage-tokens");r&&(r.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(y=>y.textContent=SITE.tld);const f=document.getElementById("edit-tld-suffix");f&&(f.textContent=SITE.tld);const u=document.getElementById("external-proxy-ip");u&&SITE.dnsIp&&(u.value=SITE.dnsIp,u.placeholder=SITE.dnsIp)})();function buildDomain(o){return o+SITE.tld}function buildServiceUrl(o){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+o:SITE.configurationType==="public"&&SITE.domain?"https://"+o+"."+SITE.domain:"https://"+buildDomain(o)}function getDnsServerAddr(o){const f=SITE.dnsServers[o];return f?`${f.ip}:${f.port}`:buildDomain(o)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[o,f]of Object.entries(SITE.dnsServers))if(f.ip===SITE.dnsIp)return o;return null}function renderDnsCards(){const o=document.querySelector(".top");if(!o)return;const f=Object.keys(SITE.dnsServers);if(!f.length)return;const u='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',y=o.firstElementChild;f.forEach(l=>{const r=escapeHtml(l),h=escapeHtml((SITE.dnsServers[l].name||l).toUpperCase()),a=document.createElement("div");a.className="card",a.setAttribute("data-app",l),a.setAttribute("data-status","off"),a.innerHTML=`<span id="${r}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${u}</div><span class="name">${h}</span><span class="spacer"></span><span id="${r}-pill" class="badge off">OFF</span></div><div class="response-row"><span id="${r}-time" class="response-time">--</span></div><div class="health-row" id="health-${r}"><span id="uptime-${r}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${r}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${r}-restart" class="restart-btn">Restart</button><button id="${r}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${r}-open">Open</button><button id="${r}-logs" class="logs-btn">Logs</button><button id="${r}-settings" class="settings-btn">\u2699\uFE0F</button></div>`,o.insertBefore(a,y)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const o=await fetch("/api/v1/csrf-token");if(!o.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await o.jsLine truncated
|
||||
position: fixed; top: 20px; right: 20px;
|
||||
background: ${r.bg}; color: ${r.fg};
|
||||
padding: 16px 24px; border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.3);
|
||||
z-index: 10000; animation: slideIn 0.3s ease-out;
|
||||
max-width: 400px; white-space: pre-line; font-size: 14px;
|
||||
`,document.body.appendChild(h),u>0&&setTimeout(()=>h.remove(),u)}function timeAgo(o){const f=Date.now()-new Date(o).getTime();return f<6e4?"just now":f<36e5?Math.floor(f/6e4)+"m ago":f<864e5?Math.floor(f/36e5)+"h ago":Math.floor(f/864e5)+"d ago"}function safeGet(o,f=null){try{const u=localStorage.getItem(o);return u!==null?u:f}catch{return f}}function safeSet(o,f){try{localStorage.setItem(o,f)}catch{}}function safeRemove(o){try{localStorage.removeItem(o)}catch{}}function safeSessionGet(o,f=null){try{const u=sessionStorage.getItem(o);return u!==null?u:f}catch{return f}}function safeSessionSet(o,f){try{sessionStorage.setItem(o,f)}catch{}}function safeGetJSON(o,f=null){try{const u=localStorage.getItem(o);return u?JSON.parse(u):f}catch{return f}}function escapeHtml(o){return String(o??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function injectModal(o,f){document.getElementById(o)||document.body.insertAdjacentHTML("beforeend",f)}const DC_BUS={_handlers:{},on(o,f){var u;((u=this._handlers)[o]||(u[o]=[])).push(f)},off(o,f){this._handlers[o]=this._handlers[o]?.filter(u=>u!==f)},emit(o,f){this._handlers[o]?.forEach(u=>u(f))}},AppState={_apps:[],getApps(){return this._apps},setApps(o){this._apps=o,window.APPS=o,DC_BUS.emit("apps:changed",o)},findApp(o){return this._apps.find(f=>f.id===o)},addApp(o){this._apps.push(o),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(o){const f=this._apps.findIndex(u=>u.id===o);return f>-1&&(this._apps.splice(f,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),f>-1},updateApp(o,f){const u=this._apps.find(y=>y.id===o);if(u){for(const[y,l]of Object.entries(f))y!=="__proto__"&&y!=="constructor"&&y!=="prototype"&&(u[y]=l);DC_BUS.emit("apps:changed",this._apps)}return u}};(function(){function o(){const y=document.createElement("div");return y.className="skeleton-card",y.innerHTML='<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"><div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div><div style="flex:1"><div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div><div class="skeleton-bar" style="width:35%;height:10px"></div></div><div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div></div><div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div><div style="display:flex;gap:8px;margin-top:auto"><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div></div>',y}function f(y){const l=document.getElementById("cards");if(!(!l||l.querySelector(".card"))){y=y||6;for(let r=0;r<y;r++){const h=o();l.appendChild(h),setTimeout(function(){h.classList.add("loaded")},r*60)}}}function u(){const y=document.getElementById("cards");if(y){var l=y.querySelectorAll(".skeleton-card");l.length&&(l.forEach(function(r,h){setTimeout(function(){r.style.opacity="0",r.style.transform="translateY(-10px)"},h*25)}),setTimeout(function(){l.forEach(function(r){r.parentNode&&r.remove()})},l.length*25+300))}}window.SkeletonLoader={show:f,hide:u}})(),(function(){var o="theme",f="user-themes",u="custom-theme",y=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],l=y.slice(),r=["bg","fg","muted","fg-muted","card-base","card-bg","border","hover","card-hover","base","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","success","error","warning","accent","accent-strong"],h=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],a=["fg-muted","hover","card-hover","base","success","error","warning"],b={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0",
Line truncated
|
||||
background:
|
||||
radial-gradient(1200px 800px at 10% -10%, rgba(`+w.r+","+w.g+","+w.b+`, .08), transparent 60%),
|
||||
radial-gradient(1000px 700px at 110% 10%, rgba(`+w.r+","+w.g+","+w.b+`, .05), transparent 55%),
|
||||
var(--bg);
|
||||
}
|
||||
`:":root."+S+` body {
|
||||
background:
|
||||
radial-gradient(1200px 900px at 8% -12%, rgba(`+w.r+","+w.g+","+w.b+`, .10), transparent 60%),
|
||||
radial-gradient(1000px 700px at 110% -10%, rgba(`+w.r+","+w.g+","+w.b+`, .07), transparent 55%),
|
||||
var(--bg);
|
||||
}
|
||||
`}function p(S,B){var $=B.lightBg||B.bg&&g(B.bg)>.4;return $?":root."+S+` button:hover {
|
||||
background: color-mix(in srgb, var(--accent-strong) 12%, white 88%);
|
||||
border-color: rgba(0, 0, 0, .15);
|
||||
box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8);
|
||||
}
|
||||
`:":root."+S+` button:hover {
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
}
|
||||
`}function t(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function c(){r.forEach(function(S){document.documentElement.style.removeProperty("--"+S)})}function v(S,B){var $=S.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),y.indexOf($)!==-1&&($=$+"-custom");for(var x=safeGetJSON(f,{}),w=$,C=2;x[$]&&$!==B;)$=w+"-"+C++;return $}function i(S){var B=document.getElementById("user-theme-styles");B&&B.remove(),l.length=y.length,Object.keys(b).forEach(function(k){y.indexOf(k)===-1&&delete b[k]});var $=S||safeGetJSON(f,{}),x=Object.keys($);if(x=x.filter(function(k){return y.indexOf(k)===-1}),!!x.length){var w="";x.forEach(function(k){var I=$[k];l.indexOf(k)===-1&&l.push(k);var A={};r.forEach(function(D){I[D]&&(A[D]=I[D])}),A["card-bg"]=I["card-base"]||I.bg,I.lightBg&&(A.lightBg=!0);var O=e(A);a.forEach(function(D){!A[D]&&O[D]&&(A[D]=O[D])}),b[k]=A,w+=":root."+k+` {
|
||||
`,r.forEach(function(D){A[D]&&(w+=" --"+D+": "+A[D]+`;
|
||||
`)}),w+=`}
|
||||
`,w+=s(k,A),w+=p(k,A)});var C=document.createElement("style");C.id="user-theme-styles",C.textContent=w,document.head.appendChild(C)}}function E(){secureFetch("/api/v1/themes").then(function(S){return S.json()}).then(function(S){if(!(!S.success||!S.themes)){var B=S.themes,$=safeGetJSON(f,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(f,JSON.stringify(B)),i(B);var x=safeGet(o);x&&l.indexOf(x)!==-1&&L(x)}}}).catch(function(){})}function T(){var S=safeGetJSON(u);if(S){var B=S.name||"Custom",$=v(B),x={name:B};r.forEach(function(k){S[k]&&(x[k]=S[k])});var w=safeGetJSON(f,{});w[$]=x,safeSet(f,JSON.stringify(w)),safeGet(o)==="custom"&&safeSet(o,$),safeRemove(u);var C={};r.forEach(function(k){x[k]&&(C[k]=x[k])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:C})}).catch(function(){})}}function L(S){document.documentElement.classList.add("theme-transitioning"),l.forEach(function(w){w!=="dark"&&document.documentElement.classList.remove(w)}),c(),S!=="dark"&&document.documentElement.classList.add(S),safeSet(o,S);var B=b[S],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var x=B&&B.lightBg;!x&&B&&B.bg&&(x=g(B.bg)>.4),x?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}T(),i();var P=safeGet(o);P==="red"&&(P="black",safeSet(o,"black")),P&&P!=="dark"&&l.indexOf(P)===-1&&(P=null),L(P||t()),E(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(S){safeGet(o)||L(S.matches?"dark":"light")}),window.THEMES=l,window.BUILTIN_THEMES=y,window.THEME_COLORS=b,window.THEME_PROPS=r,window.BASE_PROPS=h,window.DERIVED_PROPS=a,window.USER_THEMES_KEY=f,window.applyTheme=L,window.clearCustomProperties=c,window.injectUserThemeStyles=i,window.syncThemesFromServer=E,window.slugifyThemeName=v,window.getActiveTheme=function(){return safeGet(o)||t()},window.deriveExtendedColors=e,window.hexToRgb=m,window.rgbToHex=n,window.blendColors=d})(),(function(){function o(){const h=document.querySelector(".totp-card");if(!h)return;const b=getComputedStyle(h).backgroundColor.match(/\d+/g);if(!b)return;const m=(.299*+b[0]+.587*+b[1]+.114*+b[2])/255,n=h.querySelector(".totp-logo-dark"),d=h.querySelector(".totp-logo-light");n&&(n.style.display=m>.5?"none":""),d&&(d.style.display=m>.5?"":"none")}function f(){const h=document.getElementById("totp-overlay");if(h){h.classList.add("show"),setTimeout(o,50);const a=h.querySelector(".totp-digits input");a&&setTimeout(()=>a.focus(),100)}}function u(){const h=document.getElementById("totp-overlay");h&&h.classList.remove("show")}const y=document.getElementById("totp-digits");if(y){const h=y.querySelectorAll("input");h.forEach((a,b)=>{a.addEventListener("input",m=>{const n=m.target.value.replace(/\D/g,"");m.target.value=n.slice(0,1),n&&b<h.length-1&&h[b+1].focus();const d=Array.from(h).map(g=>g.value).join("");d.length===6&&l(d)}),a.addEventListener("keydown",m=>{m.key==="Backspace"&&!m.target.value&&b>0&&(h[b-1].focus(),h[b-1].value="")}),a.addEventListener("paste",m=>{m.preventDefault();const n=(m.clipboardData.getData("text")||"").replace(/\D/g,"");n.length>=6&&(h.forEach((d,g)=>{d.value=n[g]||""}),h[5].focus(),l(n.slice(0,6)))})})}async function l(h){const a=document.getElementById("totp-error");a.textContent="Verifying...",a.className="totp-error verifying";try{const m=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:h})})).json();if(m.success){a.textContent="",m.csrfToken&&(csrfToken=m.csrfToken),u();const n=safeSessionGet("totp_redirect");if(n){try{sessionStorage.removeItem("totp_redirect")}catch{}window.location.href=n;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{a.textContent=m.error||"Invalid code",a.className="totp-error";const n=document.querySelectorAll("#totp-digits input");n.forEach(d=>{d.value=""}),n[0]?.focus()}}catch{a.textContent="Connection error",a.className="totp-error"}}const r=new URLSearchParams(window.location.search);if(r.get("auth")==="required"){const h=r.get("return");if(h)try{const a=new URL(h,window.location.origin),b=a.hostname,m=a.origin===window.location.origin,n=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,d=b.endsWith(n)||b===n.substring(1);(m||d)&&safeSessionSet("totp_redirect",h)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=f})(),(function(){const o=new ErrorHandler;injectModal("folder-browser-modal",`<div id="folder-browser-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;">
|
||||
<h3>\u{1F4C2} Browse for Media Folders</h3>
|
||||
|
||||
<div id="folder-browser-path" style="padding: 10px; background: var(--card-bg); border-radius: 6px; margin-bottom: 12px; font-family: monospace; font-size: 0.9rem; word-break: break-all;">
|
||||
/
|
||||
</div>
|
||||
|
||||
<div id="folder-browser-list" style="max-height: 400px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px;">
|
||||
<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div id="folder-browser-selected" style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--success) 10%, transparent); border: 1px solid var(--success); border-radius: 6px; display: none;">
|
||||
<div style="font-size: 0.85rem; color: var(--success); margin-bottom: 6px;">Selected folders:</div>
|
||||
<div id="folder-browser-selected-list" style="display: flex; flex-wrap: wrap; gap: 6px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons" style="margin-top: 16px; display: flex; gap: 8px; justify-content: space-between;">
|
||||
<button id="folder-browser-select-current" class="btn-accent">
|
||||
+ Add Current Folder
|
||||
</button>
|
||||
<div class="flex-row-gap">
|
||||
<button id="folder-browser-cancel">Cancel</button>
|
||||
<button id="folder-browser-done" style="background: color-mix(in srgb, var(--success) 20%, transparent); border-color: var(--success); color: var(--success);">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`),injectModal("service-creds-modal",`<div id="service-creds-modal">
|
||||
<div class="service-creds-content">
|
||||
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Service Credentials</h3>
|
||||
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Credentials are injected automatically when accessing this service.</p>
|
||||
|
||||
<!-- Status indicator -->
|
||||
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;">
|
||||
<span id="svc-creds-dot" class="status-dot"></span>
|
||||
<span id="svc-creds-status" class="text-muted-sm">No credentials stored</span>
|
||||
</div>
|
||||
|
||||
<!-- Seedhost credentials (shown for external services) -->
|
||||
<div id="svc-creds-seedhost" style="display: none; margin-bottom: 14px;">
|
||||
<label class="label-bold">Seedhost Login</label>
|
||||
<p class="hint-micro">Username shared across all services. Password is per-service.</p>
|
||||
<input type="text" id="svc-seedhost-user" placeholder="Username (shared)" autocomplete="username"
|
||||
style="width: 100%; padding: 8px 10px; margin-bottom: 6px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem; box-sizing: border-box;" />
|
||||
<input type="password" id="svc-seedhost-pass" placeholder="Password" autocomplete="current-password"
|
||||
class="input-creds" />
|
||||
</div>
|
||||
|
||||
<!-- API Key (shown for arr services or services with API key support) -->
|
||||
<div id="svc-creds-apikey" style="display: none; margin-bottom: 14px;">
|
||||
<label class="label-bold">API Key</label>
|
||||
<p class="hint-micro">Bypasses the app's own login screen</p>
|
||||
<input type="text" id="svc-apikey-input" placeholder="API key"
|
||||
style="width: 100%; padding: 8px 10px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem; font-family: monospace; box-sizing: border-box;" />
|
||||
</div>
|
||||
|
||||
<!-- Quality Profile (shown for radarr/sonarr only) -->
|
||||
<div id="svc-creds-quality" style="display: none; margin-bottom: 14px;">
|
||||
<label class="label-bold">Quality Profile</label>
|
||||
<p class="hint-micro">Used when requesting via Seerr</p>
|
||||
<div style="display: flex; gap: 8px; align-items: center;">
|
||||
<select id="svc-quality-select"
|
||||
style="flex: 1; padding: 8px 10px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem; box-sizing: border-box;">
|
||||
<option value="">-- Enter API key first --</option>
|
||||
</select>
|
||||
<button id="svc-quality-fetch" type="button"
|
||||
style="padding: 8px 12px; font-size: 0.75rem; cursor: pointer; white-space: nowrap;">
|
||||
Fetch
|
||||
</button>
|
||||
</div>
|
||||
<div id="svc-quality-status" style="font-size: 0.75rem; margin-top: 4px; min-height: 1em;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Per-service Basic Auth (shown for non-external services) -->
|
||||
<div id="svc-creds-basic" style="display: none; margin-bottom: 14px;">
|
||||
<label class="label-bold">Service Login</label>
|
||||
<input type="text" id="svc-basic-user" placeholder="Username" autocomplete="username"
|
||||
style="width: 100%; padding: 8px 10px; margin-top: 6px; margin-bottom: 6px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem; box-sizing: border-box;" />
|
||||
<input type="password" id="svc-basic-pass" placeholder="Password" autocomplete="current-password"
|
||||
class="input-creds" />
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div id="svc-creds-error" style="display: none; padding: 8px 10px; margin-bottom: 10px; background: color-mix(in srgb, var(--error, #c62828) 12%, transparent); border: 1px solid var(--error, #c62828); border-radius: 6px; font-size: 0.8rem; color: var(--error, #c62828);"></div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div style="display: flex; gap: 8px; margin-top: 14px;">
|
||||
<button id="svc-creds-save" class="btn-accent-solid" style="flex: 1; padding: 9px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;">
|
||||
Save
|
||||
</button>
|
||||
<button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;">
|
||||
Clear
|
||||
</button>
|
||||
<button id="svc-creds-close" style="padding: 9px 16px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 0.85rem;">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const f=document.getElementById("service-creds-modal");let u=null;const y=["sonarr","radarr","prowlarr","overseerr"],l=["sonarr","radarr"];function r(n){return n.externalUrl||n.url||""}function h(n){const d=document.getElementById("svc-creds-error");d.textContent=n,d.style.display=""}function a(){const n=document.getElementById("svc-creds-error");n.textContent="",n.style.display="none"}window.openServiceCredsModal=async function(n){u=n,a();const d=document.getElementById("svc-creds-title"),g=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),s=document.getElementById("svc-creds-apikey"),p=document.getElementById("svc-creds-basic"),t=document.getElementById("svc-creds-quality");d.textContent=n.name+" Credentials";const c=!!n.isExternal,v=y.includes(n.id)||y.includes(n.appTemplate),i=l.includes(n.id)||l.includes(n.appTemplate);e.style.display=c?"":"none",s.style.display=v?"":"none",t.style.display=i?"":"none",p.style.display=c?"none":"";const E=document.getElementById("svc-quality-select");E.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",c?(g.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${n.name}`):v?g.textContent="API key bypasses the app login screen automatically.":g.textContent="Credentials are injected automatically when accessing this service.",await b(n),f.classList.add("show")};async function b(n){const d=document.getElementById("svc-creds-dot"),g=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let s=!1;if(n.isExternal){try{const c=await(await fetch(`/api/v1/seedhost-creds?serviceId=${n.id}`)).json();c.success?(document.getElementById("svc-seedhost-user").value=c.username||"",c.hasCredentials&&(s=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const c=await(await fetch(`/api/v1/services/${n.id}/credentials`)).json();c.success&&(c.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",s=!0):document.getElementById("svc-apikey-input").value="",c.hasBasicAuth&&!n.isExternal?(document.getElementById("svc-basic-user").value=c.username||"",s=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const p=n.id||n.appTemplate;if(l.includes(p)&&await m(n),s){d.style.background="var(--ok-fg, #74dfc4)",g.style.color="var(--ok-fg, #74dfc4)",g.textContent="Credentials stored",e.style.display="";const t=document.getElementById(`creds-btn-${n.id}`);t&&t.classList.add("has-creds")}else d.style.background="var(--muted)",g.style.color="var(--muted)",g.textContent="No credentials stored",e.style.display="none"}async function m(n){const d=document.getElementById("svc-quality-select"),g=document.getElementById("svc-quality-status"),e=n.id||n.appTemplate,s=r(n);if(!s){d.innerHTML='<option value="">-- No service URL --</option>';return}d.innerHTML='<option value="">Loading...</option>',g.textContent="";try{const p=new URLSearchParams({service:e,url:s}),c=await(await fetch(`/api/v1/arr/quality-profiles?${p}`)).json();if(!c.success||!c.profiles?.length){d.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}d.innerHTML="";for(const v of c.profiles){const i=document.createElement("option");i.value=v.id,i.textContent=v.name,d.appendChild(i)}if(c.storedProfileId&&(d.value=String(c.storedProfileId)),!d.value){const v=c.profiles.find(i=>/720/i.test(i.name));v&&(d.value=String(v.id))}!d.value&&c.profiles.length&&(d.value=String(c.profiles[0].id)),g.innerHTML=`<span style="color: var(--ok-fg);">${c.profiles.length} profiles loaded</span>`}catch(p){d.innerHTML='<option value="">-- Failed to load --</option>',g.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${p.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!u)return;const n=u.id||u.appTemplate,d=r(u),e=document.getElementById("svc-apikey-input")?.value.trim(),s=document.getElementById("svc-quality-select"),p=document.getElementById("svc-quality-status");if(!d){p.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){p.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}s.innerHTML='<option value="">Fetching...</option>',p.textContent="";try{const t=new URLSearchParams({service:n,url:d,apiKey:e}),v=await(await fetch(`/api/v1/arr/quality-profiles?${t}`)).json();if(!v.success){s.innerHTML='<option value="">-- Error --</option>',p.innerHTML=`<span style="color: var(--error, #c62828);">${v.error||"Failed to feLine truncated
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
||||
<h3 style="margin: 0 0 16px; font-size: 1.1rem;">Authentication Settings</h3>
|
||||
|
||||
<!-- Status Banner -->
|
||||
<div id="totp-status-banner" style="margin-bottom: 16px; padding: 12px 16px; border-radius: 8px; border: 1px solid var(--border); display: flex; align-items: center; gap: 8px;">
|
||||
<span id="totp-status-dot" class="status-dot"></span>
|
||||
<span id="totp-status-text" style="font-size: 0.9rem;">TOTP is not configured</span>
|
||||
</div>
|
||||
|
||||
<!-- Setup Button (not configured state) -->
|
||||
<div id="totp-setup-section">
|
||||
<button id="totp-setup-btn" class="btn-accent-solid" style="width: 100%; padding: 12px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 0.95rem;">
|
||||
Generate New Secret
|
||||
</button>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin: 10px 0 0;">
|
||||
<div class="divider-line"></div>
|
||||
<span class="text-tiny-muted">or</span>
|
||||
<div class="divider-line"></div>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<label class="text-hint">Import an existing secret key:</label>
|
||||
<div style="display: flex; gap: 8px; margin-top: 6px;">
|
||||
<input type="text" id="totp-import-key" placeholder="Paste your Base32 key" autocomplete="off" spellcheck="false"
|
||||
style="flex: 1; padding: 10px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.9rem; font-family: monospace; letter-spacing: 1px; text-transform: uppercase;" />
|
||||
<button id="totp-import-btn" style="padding: 10px 16px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; white-space: nowrap;">
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
<div id="totp-import-error" style="color: var(--bad-fg, #ff9aa3); font-size: 0.8rem; min-height: 1.2em; margin-top: 6px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secret Display (setup flow) -->
|
||||
<div id="totp-qr-section" style="display: none;">
|
||||
<!-- Manual Key (primary - for WinAuth/desktop authenticators) -->
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 8px;">Copy this key into your authenticator app:</p>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 16px;">
|
||||
<code id="totp-manual-key" style="flex: 1; display: block; padding: 12px; background: var(--bg, #0b0f1a); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem; font-family: 'Sami Grotesk', monospace; letter-spacing: 2px; word-break: break-all; user-select: all; color: var(--fg);"></code>
|
||||
<button id="totp-copy-key" style="padding: 10px 14px; background: var(--card-base); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 1rem; white-space: nowrap; color: var(--fg);" title="Copy to clipboard">\u{1F4CB}</button>
|
||||
</div>
|
||||
|
||||
<!-- QR Code (secondary - for mobile apps) -->
|
||||
<details class="mb-16">
|
||||
<summary style="cursor: pointer; color: var(--muted); font-size: 0.8rem;">Show QR code (for mobile authenticator apps)</summary>
|
||||
<div style="text-align: center; margin-top: 8px;">
|
||||
<img id="totp-qr-image" style="width: 180px; height: 180px; border-radius: 8px;" />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Verify First Code -->
|
||||
<div style="border-top: 1px solid var(--border); padding-top: 16px;">
|
||||
<label class="font-bold-sm">Enter code to confirm setup:</label>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
||||
<input type="text" id="totp-setup-code" maxlength="6" inputmode="numeric" pattern="[0-9]{6}" placeholder="000000"
|
||||
style="flex: 1; text-align: center; font-size: 1.3rem; padding: 10px; font-family: 'Sami Grotesk', monospace; letter-spacing: 4px; background: var(--bg); color: var(--fg); border: 2px solid var(--border); border-radius: 8px; outline: none;" />
|
||||
<button id="totp-confirm-setup" class="btn-accent-solid" style="padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600;">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
<div id="totp-setup-error" style="color: var(--bad-fg, #ff9aa3); font-size: 0.8rem; min-height: 1.2em; margin-top: 6px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Duration (active state) -->
|
||||
<div id="totp-duration-section" style="display: none; margin-top: 12px;">
|
||||
<label class="font-bold-sm">Session Duration:</label>
|
||||
<select id="totp-duration-select" style="width: 100%; padding: 10px; margin-top: 6px; background: var(--bg, #0b0f1a); color: var(--fg); border: 1px solid var(--border); border-radius: 8px; font-size: 0.9rem; cursor: pointer;">
|
||||
<option value="15m">15 minutes</option>
|
||||
<option value="30m">30 minutes</option>
|
||||
<option value="1h">1 hour</option>
|
||||
<option value="2h">2 hours</option>
|
||||
<option value="4h">4 hours</option>
|
||||
<option value="8h">8 hours</option>
|
||||
<option value="12h">12 hours</option>
|
||||
<option value="24h">24 hours</option>
|
||||
<option value="never">Never (disable TOTP)</option>
|
||||
</select>
|
||||
<p style="font-size: 0.75rem; color: var(--muted); margin: 4px 0 0;">How long before you need to re-enter your code</p>
|
||||
</div>
|
||||
|
||||
<!-- Disable Button (active state) -->
|
||||
<div id="totp-disable-section" style="display: none; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border);">
|
||||
<button id="totp-disable-btn" style="width: 100%; padding: 10px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 8px; cursor: pointer; font-size: 0.9rem;">
|
||||
Disable TOTP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Close -->
|
||||
<div style="margin-top: 16px; text-align: right;">
|
||||
<button id="totp-modal-close" style="padding: 8px 20px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 8px; cursor: pointer;">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);async function f(){try{const r=await(await fetch("/api/v1/totp/config")).json();if(!r.success)return;const{enabled:h,sessionDuration:a,isSetUp:b}=r.config,m=document.getElementById("totp-status-dot"),n=document.getElementById("totp-status-text"),d=document.getElementById("totp-status-banner"),g=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),s=document.getElementById("totp-duration-section"),p=document.getElementById("totp-disable-section");h&&b?(m.style.background="var(--ok-fg, #7ef2ff)",d.style.borderColor="var(--ok-fg, #7ef2ff)",d.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",n.textContent="TOTP is active",n.style.color="var(--ok-fg, #7ef2ff)",g.style.display="none",e.style.display="none",s.style.display="block",p.style.display="block",document.getElementById("totp-duration-select").value=a):(m.style.background="var(--muted)",d.style.borderColor="var(--border)",d.style.background="transparent",n.textContent="TOTP is not configured",n.style.color="var(--muted)",g.style.display="block",e.style.display="none",s.style.display="none",p.style.display="none"),y(h&&b,a)}catch(l){console.warn("Failed to load TOTP settings:",l)}}const u={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function y(l,r){const h=document.getElementById("auth-card"),a=document.getElementById("auth-pill"),b=document.getElementById("auth-dot"),m=document.getElementById("auth-status-text");h&&(l?(h.setAttribute("data-status","on"),a.className="badge on",a.textContent="YES",b.className="dot ok at-bl",m.textContent="Session: "+(u[r]||r)):(h.setAttribute("data-status","off"),a.className="badge off",a.textContent="NO",b.className="dot bad at-bl",m.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const r=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();r.success&&(document.getElementById("totp-qr-image").src=r.qrCode,document.getElementById("totp-manual-key").textContent=r.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(l){o.logError("[TOTP] Setup Failed",l,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const l=document.getElementById("totp-import-key").value.trim(),r=document.getElementById("totp-import-error");if(r.textContent="",!l){r.textContent="Paste a Base32 secret key first";return}try{const a=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:l})})).json();a.success?(r.textContent="",document.getElementById("totp-qr-image").src=a.qrCode,document.getElementById("totp-manual-key").textContent=a.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):r.textContent=a.error||a.message||"Import failed"}catch{r.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const l=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(l).then(()=>{const r=document.getElementById("totp-copy-key");r.textContent="\u2705",setTimeout(()=>{r.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const l=document.getElementById("totp-setup-code").value,r=document.getElementById("totp-setup-error");if(!/^\d{6}$/.test(l)){r.textContent="Enter a 6-digit code";return}try{const a=await(await secureFetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:l})})).json();a.success?(r.textContent="",f()):r.textContent=a.error||"Invalid code"}catch{r.textContent="Connection error"}}),document.getElementById("totp-setup-code")?.addEventListener("keydown",l=>{l.key==="Enter"&&document.getElementById("totp-confirm-setup")?.click()}),document.getElementById("totp-duration-select")?.addEventListener("change",async l=>{try{await secureFetch("/api/v1/totp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sessionDuration:l.target.value})}),f()}catch(r){o.logError("[TOTP] Update Session Duration",r,{function:"updateSessionDuration"})}}),document.getElementById("totp-disable-btn")?.addEventListener("click",async()=>{if(confirm("Disable TOTP authentication? All services will be accessible without a code."))try{(await(awaLine truncated
|
||||
<div id="token-management-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
|
||||
<h3>\u{1F511} DNS Credentials</h3>
|
||||
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
|
||||
Enter Technitium DNS login credentials. Read-only accounts are used for logs; admin accounts for restarts, records, and updates.
|
||||
</p>
|
||||
|
||||
<div id="dns-cred-sections"></div>
|
||||
|
||||
<div class="weather-modal-buttons modal-footer-bar">
|
||||
<button id="token-clear-all" style="margin-right: auto; background: color-mix(in srgb, var(--bad-fg) 15%, transparent); border-color: var(--bad-fg); color: var(--bad-fg);">Clear All</button>
|
||||
<button id="token-cancel">Cancel</button>
|
||||
<button id="token-save" class="btn-accent">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);function o(){return Object.keys(SITE.dnsServers||{})}function f(t){return(SITE.dnsServers||{})[t]?.name||t.toUpperCase()}function u(){const t=document.getElementById("dns-cred-sections");if(!t)return;t.innerHTML="";const c=o();if(c.length===0){t.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const v of c)t.insertAdjacentHTML("beforeend",`
|
||||
<div class="token-section">
|
||||
<h4 class="token-section-title">${f(v)}</h4>
|
||||
<div class="token-grid">
|
||||
<div class="token-field">
|
||||
<label for="${v}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
|
||||
<input type="text" id="${v}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<div class="token-input-row">
|
||||
<input type="password" id="${v}-readonly-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="${v}-readonly-token">\u{1F441}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-field">
|
||||
<label for="${v}-admin-username">\u{1F527} Admin:</label>
|
||||
<input type="text" id="${v}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<div class="token-input-row">
|
||||
<input type="password" id="${v}-admin-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="${v}-admin-token">\u{1F441}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-status" id="${v}-token-status"></div>
|
||||
</div>
|
||||
`)}function y(){let t=safeSessionGet("dashcaddy-encryption-key");if(t)return t;const c=safeGet("dashcaddy-encryption-key");if(c)return safeSessionSet("dashcaddy-encryption-key",c),safeRemove("dashcaddy-encryption-key"),c;const v=new Uint8Array(32);return crypto.getRandomValues(v),t=Array.from(v,i=>i.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",t),t}const l=y();function r(t,c){if(!t)return"";const v=crypto.getRandomValues(new Uint8Array(8)),i=Array.from(v,L=>L.toString(16).padStart(2,"0")).join(""),E=new TextEncoder().encode(c+i);let T="";for(let L=0;L<t.length;L++){const P=t.charCodeAt(L)^E[L%E.length]^v[L%v.length]^L*31+17&255;T+=String.fromCharCode(P)}return i+":"+btoa(T)}function h(t,c){if(!t)return"";try{if(t.indexOf(":")===16){const T=t.substring(0,16),L=new Uint8Array(T.match(/.{2}/g).map($=>parseInt($,16))),P=atob(t.substring(17)),S=new TextEncoder().encode(c+T);let B="";for(let $=0;$<P.length;$++){const x=P.charCodeAt($)^S[$%S.length]^L[$%L.length]^$*31+17&255;B+=String.fromCharCode(x)}return B}const i=atob(t);let E="";for(let T=0;T<i.length;T++){const L=i.charCodeAt(T)^c.charCodeAt(T%c.length);E+=String.fromCharCode(L)}return E}catch{return""}}function a(t,c,v){const i=safeGet(`${t}-${c}-${v}-enc`);return h(i,l)}function b(t,c,v,i){const E=`${t}-${c}-${v}-enc`;i?safeSet(E,r(i,l)):safeRemove(E)}function m(t,c){return a(t,c,"token")}function n(t,c){return a(t,c,"username")}function d(t,c,v){b(t,c,"token",v)}function g(t,c,v){b(t,c,"username",v)}function e(){const t={};for(const c of o())t[c]={readonly:{username:n(c,"readonly"),token:m(c,"readonly")},admin:{username:n(c,"admin"),token:m(c,"admin")}};return t}function s(){o().forEach(t=>{["readonly","admin"].forEach(c=>{["token","username"].forEach(v=>{safeRemove(`${t}-${c}-${v}-enc`)})}),safeRemove(`${t}-token-enc`),safeRemove(`${t}-username-enc`)})}function p(t){const c=m(t,"readonly"),v=n(t,"readonly"),i=m(t,"admin"),E=n(t,"admin"),T=h(safeGet(`${t}-token-enc`),l),L=h(safeGet(`${t}-username-enc`),l);return{username:E||v||L,token:i||c||T,readonlyToken:c||T,readonlyUsername:v||L,adminToken:i||T,adminUsername:E||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{u();const t=document.getElementById("token-management-modal"),c=e();o().forEach(v=>{const i=c[v];document.getElementById(`${v}-readonly-username`).value=i.readonly.username,document.getElementById(`${v}-readonly-token`).value=i.readonly.token,document.getElementById(`${v}-admin-username`).value=i.admin.username,document.getElementById(`${v}-admin-token`).value=i.admin.token,document.getElementById(`${v}-token-status`).textContent=""}),t.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",t=>{const c=t.target.closest(".token-toggle");if(c){const v=c.dataset.target,i=document.getElementById(v);i.type==="password"?(i.type="text",c.textContent="\u{1F648}"):(i.type="password",c.textContent="\u{1F441}");return}t.target.id==="token-management-modal"&&t.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const t=o();t.forEach(i=>{g(i,"readonly",document.getElementById(`${i}-readonly-username`).value.trim()),d(i,"readonly",document.getElementById(`${i}-readonly-token`).value.trim()),g(i,"admin",document.getElementById(`${i}-admin-username`).value.trim()),d(i,"admin",document.getElementById(`${i}-admin-token`).value.trim())});const c={};let v=!1;if(t.forEach(i=>{const E={},T=document.getElementById(`${i}-readonly-username`).value.trim(),L=document.getElementById(`${i}-readonly-token`).value.trim(),P=document.getElementById(`${i}-admin-username`).value.trim(),S=document.getElementById(`${i}-admin-token`).value.trim();T&&L&&(E.readonly={username:T,password:L},v=!0),P&&S&&(E.admin={username:P,password:S},v=!0),Object.keys(E).length>0&&(c[i]=E)}),v){t.forEach(i=>{c[i]&&(document.getElementById(`${i}-token-status`).textContent="Verifying...",document.getElementById(`${i}-token-status`).className="token-status")});try{const E=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:c})})).json();E.results?t.forEach(T=>{const L=document.getElementById(`${T}-token-status`);if(!c[T]){L.textContent="";return}const P=E.results[T];P?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):P?.partial?(L.textContent="\u2713 "+P.partial,L.className="token-status success"):(L.textContent="\u2717 "+(P?.error||"Login failed"),L.className="token-status error")}):E.success?t.forEach(T=>{c[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2713 Saved",document.getElementById(`${T}-token-status`).className="token-status success")}):t.forEach(T=>{c[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2717 "+(E.error||"Failed"),document.getElementById(`${T}-token-status`).className="token-status error")})}catch(i){console.erro
Line truncated
|
||||
|
||||
Current: ${g.currentVersion}
|
||||
New: ${g.updateVersion}
|
||||
|
||||
`+(g.updateTitle?`${g.updateTitle}
|
||||
|
||||
`:"")+`The DNS server will restart during the update.
|
||||
Proceed?`)){m.textContent=n,m.disabled=!1,m.title="Update DNS server";return}m.textContent="\u{1F504}",m.title="Updating...";const p=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(b)}`,{method:"POST"})).json();if(!p.success)throw new Error(p.error||"Update failed");if(p.manualUpdateRequired){m.textContent="\u2B06\uFE0F",m.title=`Update available: ${p.newVersion}`;const t=p.downloadLink?`
|
||||
Download: ${p.downloadLink}`:"",c=p.instructionsLink?`
|
||||
Instructions: ${p.instructionsLink}`:"";showNotification(`${a.toUpperCase()} update requires manual installation. Current: ${p.previousVersion} \u2192 ${p.newVersion}. Please update manually on the host machine.`,"warning",8e3),m.disabled=!1;return}m.textContent="\u2705",m.title="Updated successfully!",showNotification(`${a.toUpperCase()} updated successfully! ${p.previousVersion} \u2192 ${p.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{m.textContent=n,m.disabled=!1,m.title="Update DNS server",window.refreshAll()},1e4)}catch(d){console.error("DNS update error:",d),m.textContent="\u274C",m.title="Update failed",showNotification(`Failed to update ${a.toUpperCase()}: ${d.message}`,"error"),setTimeout(()=>{m.textContent=n,m.disabled=!1,m.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",a=>{const b=a.target.closest('[id$="-update"]');if(!b)return;const m=b.id.replace("-update","");SITE.dnsServers[m]&&f(m,SITE.dnsServers[m]?.ip)}),injectModal("dns-settings-modal",`
|
||||
<div id="dns-settings-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
||||
<h3 id="dns-settings-title">DNS Settings</h3>
|
||||
|
||||
<div style="display: grid; gap: 16px; margin-top: 16px;">
|
||||
<div>
|
||||
<label for="dns-edit-ip" class="form-label-accent-sm">Server IP</label>
|
||||
<input type="text" id="dns-edit-ip" class="form-input-md" placeholder="192.168.1.1" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="dns-edit-port" class="form-label-accent-sm">Port</label>
|
||||
<input type="number" id="dns-edit-port" class="form-input-md" placeholder="5380" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="dns-edit-name" class="form-label-accent-sm">Display Name (optional)</label>
|
||||
<input type="text" id="dns-edit-name" class="form-input-md" placeholder="e.g. Primary DNS" />
|
||||
</div>
|
||||
<div class="form-hint-sm">Manage credentials via Tokens in the toolbar</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons" style="margin-top: 24px;">
|
||||
<button id="dns-settings-cancel">Cancel</button>
|
||||
<button id="dns-settings-delete" style="background: color-mix(in srgb, #e74c3c 20%, transparent); border-color: #e74c3c; color: #e74c3c;">Remove</button>
|
||||
<button id="dns-settings-save" class="btn-accent">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);let u=null;function y(a){u=a;const b=SITE.dnsServers[a]||{},m=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(b.name||a).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=b.ip||"",document.getElementById("dns-edit-port").value=b.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=b.name||"",m.classList.add("show")}async function l(){if(!u)return;const a=document.getElementById("dns-edit-ip").value.trim(),b=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,m=document.getElementById("dns-edit-name").value.trim();if(!a){showNotification("Server IP is required","warning");return}const n={dnsServers:{}};n.dnsServers[u]={ip:a,port:String(b)},m&&(n.dnsServers[u].name=m);try{const g=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)})).json();g.success?(SITE.dnsServers[u]=n.dnsServers[u],showNotification(`${u.toUpperCase()} settings saved`,"success"),h(),window.refreshAll()):showNotification(g.error||"Failed to save settings","error")}catch(d){showNotification("Failed to save: "+d.message,"error")}}async function r(){if(u&&confirm(`Remove ${u.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const b=await(await secureFetch("/api/v1/config")).json();b.dnsServers&&delete b.dnsServers[u];const n=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:b.dnsServers||{}})})).json();if(n.success){delete SITE.dnsServers[u];const d=document.querySelector(`.top [data-app="${u}"]`);d&&d.remove(),showNotification(`${u.toUpperCase()} removed from dashboard`,"success"),h()}else showNotification(n.error||"Failed to remove","error")}catch(a){showNotification("Failed to remove: "+a.message,"error")}}function h(){closeModal("dns-settings-modal"),u=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",h),document.getElementById("dns-settings-save")?.addEventListener("click",l),document.getElementById("dns-settings-delete")?.addEventListener("click",r),document.getElementById("dns-settings-modal")?.addEventListener("click",a=>{a.target.id==="dns-settings-modal"&&h()}),document.querySelector(".top")?.addEventListener("click",a=>{const b=a.target.closest('[id$="-settings"]');if(!b)return;const m=b.id.replace("-settings","");SITE.dnsServers[m]&&(a.stopPropagation(),y(m))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
|
||||
<div id="logs-modal" class="logs-modal">
|
||||
<div class="logs-modal-content" style="min-width: 800px; max-width: 1000px;">
|
||||
<div class="logs-header">
|
||||
<h3 id="logs-title">DNS Logs</h3>
|
||||
<div class="logs-controls">
|
||||
<label for="log-lines">Show:</label>
|
||||
<select id="log-lines">
|
||||
<option value="25" selected>25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
</select>
|
||||
<button id="logs-stream" class="stream-btn" title="Enable real-time streaming">\u{1F4E1} Live</button>
|
||||
<button id="logs-pause" class="pause-btn">\u23F8\uFE0F Pause</button>
|
||||
<button id="logs-close" class="close-btn">\u2715</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logs-container scroll-container">
|
||||
<div id="logs-content" class="logs-content">
|
||||
<div class="logs-loading">Loading logs...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);let o=null,f=null,u=!1,y=null,l=null,r=!1,h=null,a=null,b=!1,m=null,n=!1;async function d(x,w=25){try{const C=getDnsServerAddr(x),k=await fetch(`/api/v1/dns/logs?server=${C}&limit=${w}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const I=await k.json();return I.success&&I.logs?{logs:I.logs,count:I.count,server:I.server}:{error:I.error||"Failed to fetch logs"}}else return k.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${k.status}`}}catch(C){return console.error("DNS logs fetch failed:",C),{error:C.message}}}function g(x){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[x]||"var(--fg)"}function e(x){const w=document.createElement("div");if(w.className="log-entry",w.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",x.parsed===!1)return w.style.gridTemplateColumns="1fr",w.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(x.raw)}</span>`,w;const C=g(x.rcode),k=x.rcode==="Refused"||x.rcode==="REFUSED";return w.innerHTML=`
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(x.timestamp)}</span>
|
||||
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(x.client)}">${escapeHtml(x.client)}</span>
|
||||
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${k?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(x.domain)}">${escapeHtml(x.domain)}</span>
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(x.type)}</span>
|
||||
<span style="color: ${C}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(x.rcode)}</span>
|
||||
`,w}async function s(){if(b){await B();return}if(r){await T();return}if(u||!o)return;const x=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const C=await d(o,x);if(C.error){w.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
|
||||
<div>${escapeHtml(C.error)}</div>
|
||||
</div>`;return}w.innerHTML=`
|
||||
<div style="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
|
||||
<span>Time</span>
|
||||
<span>Client</span>
|
||||
<span>Domain</span>
|
||||
<span>Type</span>
|
||||
<span>Status</span>
|
||||
</div>`,C.logs&&C.logs.length>0?C.logs.forEach(k=>{const I=e(k);w.appendChild(I)}):w.innerHTML+=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
||||
No DNS queries logged yet
|
||||
</div>`}catch(C){w.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${escapeHtml(C.message)}
|
||||
</div>`}}function p(x){o=x,u=!1,r=!1;const w=document.getElementById("logs-modal"),C=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),I=document.getElementById("logs-stream");C.textContent=`${x.toUpperCase()} DNS Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),I&&(I.style.display="none"),w.classList.add("show"),s(),f=setInterval(s,DC.POLL.LOGS)}function t(){document.getElementById("logs-modal").classList.remove("show"),f&&(clearInterval(f),f=null),v(),o=null,r=!1,y=null,l=null,b=!1,h=null,a=null,u=!1}function c(x){m&&v();const w=document.getElementById("logs-stream"),C=document.getElementById("logs-pause"),k=document.getElementById("logs-content");f&&(clearInterval(f),f=null);try{m=new EventSource(`/api/v1/logs/stream/${x}`),n=!0,w.classList.add("active"),w.textContent="\u{1F534} Live",w.title="Streaming - click to stop",C.style.display="none";const I=document.getElementById("logs-title");I.textContent.includes("\u{1F534}")||(I.innerHTML=I.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),m.onmessage=A=>{try{const O=JSON.parse(A.data);if(O.error){console.error("Stream error:",O.error),v();return}const D=document.createElement("div");D.className="log-entry",D.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(O.stream||"stdout")==="stderr",N=R?"var(--bad-fg)":"var(--fg)",F=`<span style="background: ${R?"var(--bad-bg)":"var(--ok-bg)"}; color: ${N}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${R?"STDERR":"STDOUT"}</span>`;for(D.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${F}</div>
|
||||
<div style="flex: 1; color: ${N}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(O.text)}</div>
|
||||
`,k.appendChild(D),k.scrollTop=k.scrollHeight;k.children.length>500;)k.removeChild(k.firstChild)}catch(O){console.error("Error parsing stream data:",O)}},m.onerror=A=>{console.error("EventSource error:",A),v()}}catch(I){console.error("Failed to start streaming:",I),v()}}function v(){m&&(m.close(),m=null),n=!1;const x=document.getElementById("logs-stream"),w=document.getElementById("logs-pause"),C=document.getElementById("logs-title");x&&(x.classList.remove("active"),x.textContent="\u{1F4E1} Live",x.title="Enable real-time streaming"),w&&(w.style.display=""),C&&(C.textContent=C.textContent.replace(" \u{1F534}","")),r&&y&&!f&&(f=setInterval(T,DC.POLL.LOGS))}async function i(x,w=100){try{const C=`/api/v1/logs/container/${x}?tail=${w}×tamps=true`,k=await fetch(C,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const I=await k.json();return I.success&&I.logs?{logs:I.logs,count:I.count,containerName:I.containerName,containerId:I.containerId}:{error:I.error||"Failed to fetch container logs"}}else return{error:`HTTP ${k.status}: ${k.statusText}`}}catch(C){return console.error("Container logs fetch failed:",C),{error:C.message}}}function E(x){const w=document.createElement("div");w.className="log-entry",w.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const C=x.stream==="stderr"?"var(--bad-fg)":"var(--fg)",k=x.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return w.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${k}</div>
|
||||
<div style="flex: 1; color: ${C}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(x.text)}</div>
|
||||
`,w}async function T(){if(u||!y||!r)return;const x=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const C=await i(y,x);if(C.error){w.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
|
||||
<div>${escapeHtml(C.error)}</div>
|
||||
</div>`;return}w.innerHTML=`
|
||||
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
|
||||
<span style="flex-shrink: 0; width: 80px;">Stream</span>
|
||||
<span style="flex: 1;">Log Output</span>
|
||||
</div>`,C.logs&&C.logs.length>0?(C.logs.forEach(k=>{const I=E(k);w.appendChild(I)}),w.scrollTop=w.scrollHeight):w.innerHTML+=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
||||
No logs available for this container
|
||||
</div>`}catch(C){w.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${escapeHtml(C.message)}
|
||||
</div>`}}function L(x,w){y=x,l=w,r=!0,b=!1,u=!1,v();const C=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),I=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${w} - Container Logs`,I.textContent="\u23F8\uFE0F Pause",I.classList.remove("paused"),A&&(A.style.display=""),C.classList.add("show"),T(),f=setInterval(T,DC.POLL.LOGS)}async function P(x,w=100){try{const C=`/api/v1/logs/file?path=${encodeURIComponent(x)}&tail=${w}`,k=await fetch(C,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const I=await k.json();return I.success&&I.logs?{logs:I.logs,count:I.count,logPath:I.logPath,totalLines:I.totalLines}:{error:I.error||"Failed to fetch file logs"}}else return{error:(await k.json().catch(()=>({}))).error||`HTTP ${k.status}`}}catch(C){return console.error("File logs fetch failed:",C),{error:C.message}}}function S(x){const w=document.createElement("div");w.className="log-entry",w.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const C=x.text;let k="INFO",I="var(--fg)";C.match(/ERROR|FATAL|CRITICAL/i)?(k="ERROR",I="var(--bad-fg)"):C.match(/WARN|WARNING/i)?(k="WARN",I="#f39c12"):C.match(/DEBUG/i)&&(k="DEBUG",I="var(--muted)");const O=`<span style="background: ${I==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${I}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${k}</span>`;return w.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${O}</div>
|
||||
<div style="flex: 1; color: ${I}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(C)}</div>
|
||||
`,w}async function B(){if(u||!h||!b)return;const x=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const C=await P(h,x);if(C.error){w.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
|
||||
<div>${escapeHtml(C.error)}</div>
|
||||
</div>`;return}w.innerHTML=`
|
||||
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
|
||||
<span style="flex: 1;">Log Output (${C.count} of ${C.totalLines} lines)</span>
|
||||
</div>`,C.logs&&C.logs.length>0?(C.logs.forEach(k=>{const I=S(k);w.appendChild(I)}),w.scrollTop=w.scrollHeight):w.innerHTML+=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
||||
No logs available in this file
|
||||
</div>`}catch(C){w.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${escapeHtml(C.message)}
|
||||
</div>`}}function $(x,w){h=x,a=w,b=!0,r=!1,u=!1;const C=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),I=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${w} - Application Logs`,I.textContent="\u23F8\uFE0F Pause",I.classList.remove("paused"),A&&(A.style.display="none"),C.classList.add("show"),B(),f=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",x=>{const w=x.target.closest('[id$="-logs"]');if(!w)return;const C=w.id.replace("-logs","");SITE.dnsServers[C]&&p(C)}),document.getElementById("logs-close")?.addEventListener("click",t),document.getElementById("logs-pause")?.addEventListener("click",()=>{u=!u;const x=document.getElementById("logs-pause");u?(x.textContent="\u25B6\uFE0F Resume",x.classList.add("paused")):(x.textContent="\u23F8\uFE0F Pause",x.classList.remove("paused"),s())}),document.getElementById("log-lines")?.addEventListener("change",()=>{u||s()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!r||!y||(n?v():c(y))}),document.getElementById("logs-modal")?.addEventListener("click",x=>{x.target.id==="logs-modal"&&t()}),document.addEventListener("keydown",x=>{x.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&t()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=p})(),(function(){injectModal("service-edit-modal",`
|
||||
<div id="service-edit-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
|
||||
<h3 id="service-edit-title">Edit Service</h3>
|
||||
|
||||
<div style="display: grid; gap: 16px; margin-top: 16px;">
|
||||
<!-- Service Info -->
|
||||
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--card-bg); border-radius: 8px;">
|
||||
<img id="edit-service-logo-preview" src="" alt="" style="width: 48px; height: 48px; border-radius: 8px; object-fit: contain; background: var(--bg);" />
|
||||
<div style="flex: 1;">
|
||||
<div id="edit-service-url-display" class="text-muted-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Display Name -->
|
||||
<div>
|
||||
<label for="edit-service-name" class="form-label-accent-sm">
|
||||
Display Name
|
||||
</label>
|
||||
<input type="text" id="edit-service-name" class="form-input-md" placeholder="Service name shown on dashboard" />
|
||||
</div>
|
||||
|
||||
<!-- Subdomain -->
|
||||
<div>
|
||||
<label for="edit-subdomain" class="form-label-accent-sm">
|
||||
Subdomain
|
||||
</label>
|
||||
<div class="flex-row-gap-center">
|
||||
<input type="text" id="edit-subdomain" class="input-flex" />
|
||||
<span id="edit-tld-suffix" style="color: var(--muted);">.home</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Port -->
|
||||
<div>
|
||||
<label for="edit-port" class="form-label-accent-sm">
|
||||
Port
|
||||
</label>
|
||||
<input type="number" id="edit-port" class="form-input-md" />
|
||||
<div class="form-hint-sm">
|
||||
The port Caddy will proxy to (container's exposed port)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IP Address -->
|
||||
<div>
|
||||
<label for="edit-ip" class="form-label-accent-sm">
|
||||
IP Address
|
||||
</label>
|
||||
<input type="text" id="edit-ip" class="form-input-md" />
|
||||
</div>
|
||||
|
||||
<!-- Tailscale Protection -->
|
||||
<div>
|
||||
<label style="display: flex; align-items: center; gap: 10px; padding: 12px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" id="edit-tailscale-only" style="width: 18px; height: 18px;" />
|
||||
<div>
|
||||
<div class="fw-500">Tailscale-Only Access</div>
|
||||
<div class="text-hint">Restrict this service to Tailscale users only</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Logo -->
|
||||
<div>
|
||||
<label class="form-label-accent-sm">
|
||||
Service Logo
|
||||
</label>
|
||||
<div class="flex-row-gap-center">
|
||||
<input type="text" id="edit-logo-url" placeholder="/assets/service.png or https://..." class="input-flex" />
|
||||
<label style="padding: 10px 16px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; white-space: nowrap;">
|
||||
<input type="file" id="edit-logo-file" accept="image/*" style="display: none;" />
|
||||
Upload
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-hint-sm">
|
||||
Enter a URL or upload an image file (PNG, JPG, SVG)
|
||||
</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="">\u2014 No category \u2014</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons" style="margin-top: 24px;">
|
||||
<button id="service-edit-cancel">Cancel</button>
|
||||
<button id="service-edit-save" class="btn-accent">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`),injectModal("delete-service-modal",`
|
||||
<div id="delete-service-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 400px; max-width: 500px;">
|
||||
<h3 id="delete-modal-title" class="mb-16">Delete Service</h3>
|
||||
|
||||
<div id="delete-modal-message" style="margin-bottom: 20px; line-height: 1.5;">
|
||||
<!-- Dynamic content -->
|
||||
</div>
|
||||
|
||||
<div id="delete-modal-container-info" style="display: none; margin-bottom: 20px; padding: 12px; background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="font-weight: 500; margin-bottom: 8px;">Docker Container</div>
|
||||
<div id="delete-modal-container-name" style="font-size: 0.9rem; color: var(--muted);"></div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons" style="display: flex; gap: 8px; justify-content: flex-end;">
|
||||
<button id="delete-modal-cancel" style="padding: 10px 20px;">Cancel</button>
|
||||
<button id="delete-modal-remove" style="padding: 10px 20px; background: color-mix(in srgb, var(--accent) 20%, transparent); border-color: var(--accent); color: var(--accent);">
|
||||
Remove
|
||||
</button>
|
||||
<button id="delete-modal-delete" style="display: none; padding: 10px 20px; background: color-mix(in srgb, #e74c3c 20%, transparent); border-color: #e74c3c; color: #e74c3c;">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="delete-modal-help" style="display: none; margin-top: 16px; padding: 12px; background: color-mix(in srgb, var(--muted) 10%, transparent); border-radius: 8px; font-size: 0.85rem; color: var(--muted);">
|
||||
<div><strong>Remove:</strong> Remove from dashboard only (container keeps running)</div>
|
||||
<div style="margin-top: 6px;"><strong>Delete:</strong> Full removal - stops container, removes DNS & Caddy config</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`),injectModal("add-service-modal",`
|
||||
<div id="add-service-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
||||
<h3>Add Service</h3>
|
||||
|
||||
<!-- Service Type Tabs -->
|
||||
<div style="display: flex; gap: 2px; margin-bottom: 16px; background: var(--card-bg); border-radius: 8px; padding: 3px; border: 1px solid var(--border);">
|
||||
<label id="tab-local" style="flex: 1; text-align: center; padding: 8px; border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 500; background: var(--accent); color: var(--bg); transition: all 0.15s;">
|
||||
<input type="radio" name="service-type" value="local" id="service-type-local" checked style="display: none;" />
|
||||
Local
|
||||
</label>
|
||||
<label id="tab-external" style="flex: 1; text-align: center; padding: 8px; border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 500; color: var(--muted); transition: all 0.15s;">
|
||||
<input type="radio" name="service-type" value="external" id="service-type-external" style="display: none;" />
|
||||
External
|
||||
</label>
|
||||
</div>
|
||||
<span id="service-type-description" style="display: none;"></span>
|
||||
|
||||
<!-- LOCAL SERVICE -->
|
||||
<div id="local-service-config" style="display: grid; gap: 12px;">
|
||||
<div>
|
||||
<label for="service-name-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Name</label>
|
||||
<input type="text" id="service-name-input" placeholder="e.g., Jellyfin" style="font-size: 1rem;" />
|
||||
<div id="subdomain-preview" style="font-size: 0.75rem; color: var(--accent); margin-top: 4px; min-height: 1em;"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2col">
|
||||
<div>
|
||||
<label for="service-port-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Port</label>
|
||||
<input type="number" id="service-port-input" placeholder="e.g., 8096" style="font-size: 1rem;" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="service-ip-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">IP Address</label>
|
||||
<input type="text" id="service-ip-input" placeholder="Auto-detected" style="font-size: 1rem;" />
|
||||
<div class="quick-ip-buttons" style="display: flex; gap: 4px; margin-top: 4px; flex-wrap: wrap;">
|
||||
<button type="button" class="quick-ip-btn" data-ip="127.0.0.1" title="Localhost" style="font-size: 0.7rem; padding: 2px 6px;">localhost</button>
|
||||
<button type="button" class="quick-ip-btn" data-ip="" id="quick-ip-lan" title="LAN IP" style="font-size: 0.7rem; padding: 2px 6px;">LAN</button>
|
||||
<button type="button" class="quick-ip-btn" data-ip="" id="quick-ip-tailscale" title="Tailscale IP" style="font-size: 0.7rem; padding: 2px 6px;">Tailscale</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options (collapsed by default) -->
|
||||
<details id="local-advanced-options">
|
||||
<summary style="cursor: pointer; color: var(--accent); font-size: 0.8rem; user-select: none;">Options</summary>
|
||||
<div style="margin-top: 10px; display: grid; gap: 10px; font-size: 0.8rem;">
|
||||
|
||||
<div class="grid-2col">
|
||||
<div>
|
||||
<label for="service-subdomain-input">Subdomain:</label>
|
||||
<input type="text" id="service-subdomain-input" placeholder="auto-derived from name" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="service-logo-input">Logo URL:</label>
|
||||
<input type="text" id="service-logo-input" placeholder="/assets/name.png" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; align-items: start;">
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" id="create-dns-record" checked />
|
||||
Create DNS Record
|
||||
</label>
|
||||
<div>
|
||||
<label for="ssl-type-select">SSL:</label>
|
||||
<select id="ssl-type-select" style="width: 100%;">
|
||||
<option value="caddy-managed">Caddy Managed (Internal)</option>
|
||||
<option value="letsencrypt">Let's Encrypt</option>
|
||||
<option value="existing-ca">Existing CA</option>
|
||||
<option value="custom-ca">Custom CA</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dns-config">
|
||||
<div class="grid-2col">
|
||||
<div>
|
||||
<label for="dns-ttl-input">DNS TTL:</label>
|
||||
<input type="number" id="dns-ttl-input" value="300" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="caddyfile-path-input">Caddyfile Path:</label>
|
||||
<input type="text" id="caddyfile-path-input" value="C:\\caddy\\Caddyfile" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="existing-ca-config" style="display: none;">
|
||||
<label for="existing-ca-select">Existing CA:</label>
|
||||
<div class="flex-row-gap">
|
||||
<select id="existing-ca-select" style="flex: 1;">
|
||||
<option value="">Loading CAs...</option>
|
||||
</select>
|
||||
<button type="button" id="refresh-cas" style="padding: 4px 8px; font-size: 0.75rem;">\u{1F504}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="custom-ca-config" style="display: none;">
|
||||
<label for="ca-name-input">CA Name:</label>
|
||||
<input type="text" id="ca-name-input" placeholder="e.g., sami-ca" />
|
||||
</div>
|
||||
|
||||
<div id="manual-tailscale-status" style="padding: 6px 10px; background: var(--card-bg); border-radius: 6px; font-size: 0.75rem;">
|
||||
<span style="color: var(--muted);">Checking Tailscale...</span>
|
||||
</div>
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="checkbox" id="manual-tailscale-only" />
|
||||
Tailscale-Only Access
|
||||
</label>
|
||||
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" id="reload-caddy" checked />
|
||||
Reload Caddy after adding
|
||||
</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="">\u2014 No category \u2014</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;" />
|
||||
|
||||
<div class="grid-2col">
|
||||
<div>
|
||||
<label class="field-label-sm">
|
||||
<input type="checkbox" id="enable-auth" />
|
||||
Authentication
|
||||
</label>
|
||||
<label class="field-label-sm">
|
||||
<input type="checkbox" id="enable-cors" />
|
||||
CORS Headers
|
||||
</label>
|
||||
<label for="upstream-path-input">Upstream Path:</label>
|
||||
<input type="text" id="upstream-path-input" value="/" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="health-check-input">Health Check:</label>
|
||||
<input type="text" id="health-check-input" placeholder="/health" />
|
||||
<label for="timeout-input">Timeout (s):</label>
|
||||
<input type="number" id="timeout-input" value="30" />
|
||||
<label for="custom-headers-input">Headers (JSON):</label>
|
||||
<textarea id="custom-headers-input" placeholder='{"X-Custom": "value"}' rows="2" style="font-size: 0.7rem;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- EXTERNAL SERVICE -->
|
||||
<div id="external-service-config" style="display: none;">
|
||||
<div style="display: grid; gap: 12px;">
|
||||
<div>
|
||||
<label for="external-service-name" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Name</label>
|
||||
<input type="text" id="external-service-name" placeholder="e.g., Radarr (Seedhost)" style="font-size: 1rem;" />
|
||||
<div id="external-subdomain-preview" style="font-size: 0.75rem; color: var(--accent); margin-top: 4px; min-height: 1em;"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="external-service-url" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">External URL</label>
|
||||
<input type="url" id="external-service-url" placeholder="https://username.seedhost.eu/radarr" style="font-size: 1rem;" />
|
||||
</div>
|
||||
|
||||
<!-- Options (collapsed by default) -->
|
||||
<details id="external-advanced-options">
|
||||
<summary style="cursor: pointer; color: var(--accent); font-size: 0.8rem; user-select: none;">Options</summary>
|
||||
<div style="margin-top: 10px; display: grid; gap: 10px; font-size: 0.8rem;">
|
||||
|
||||
<div class="grid-2col">
|
||||
<div>
|
||||
<label for="external-service-subdomain">Subdomain:</label>
|
||||
<input type="text" id="external-service-subdomain" placeholder="auto-derived from name" />
|
||||
<span id="external-domain-preview" style="font-size: 0.7rem; color: var(--accent);"></span>
|
||||
</div>
|
||||
<div>
|
||||
<label for="external-service-logo">Logo URL:</label>
|
||||
<input type="text" id="external-service-logo" placeholder="/assets/name.png" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="external-service-icon">Icon Emoji:</label>
|
||||
<input type="text" id="external-service-icon" placeholder="\u{1F3AC}" maxlength="2" style="width: 60px;" />
|
||||
</div>
|
||||
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" id="external-create-dns" checked />
|
||||
Create DNS Record
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" id="external-create-caddy" checked />
|
||||
Create Caddy Reverse Proxy
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label for="external-proxy-ip">Proxy Server IP:</label>
|
||||
<input type="text" id="external-proxy-ip" placeholder="Auto-detected" />
|
||||
</div>
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" id="external-preserve-host" checked />
|
||||
Preserve Host Header
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" id="external-follow-redirects" checked />
|
||||
Follow Redirects
|
||||
</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="">\u2014 No category \u2014</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons" style="margin-top: 16px; display: flex; gap: 8px; justify-content: flex-end;">
|
||||
<button id="add-service-cancel">Cancel</button>
|
||||
<button id="add-service-create" class="btn-accent">Create Service</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`)})(),(function(){async function o(r){try{const h=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(r)}`);if(!h.ok)throw new Error(`Failed to load CAs: ${h.status}`);const a=await h.json();if(a.status==="success"){const b=document.getElementById("existing-ca-select");return b.innerHTML="",a.data.cas.length===0?b.innerHTML='<option value="">No CAs found in Caddyfile</option>':(b.innerHTML='<option value="">Select existing CA...</option>',a.data.cas.forEach(m=>{const n=document.createElement("option");typeof m=="object"?(n.value=m.id,n.textContent=m.displayName||m.name):(n.value=m,n.textContent=m),b.appendChild(n)})),a.data.cas}else throw new Error(a.message)}catch(h){console.error("Error loading CAs:",h);const a=document.getElementById("existing-ca-select");return a.innerHTML='<option value="">Error loading CAs</option>',[]}}function f(r){const{subdomain:h,port:a,ip:b,sslType:m,caName:n,existingCa:d,enableAuth:g,enableCors:e,customHeaders:s,upstreamPath:p,healthCheck:t,timeout:c,tailscaleOnly:v}=r;let i=`${buildDomain(h)} {
|
||||
`;switch(v&&(i+=` @blocked not remote_ip 100.64.0.0/10
|
||||
`,i+=` respond @blocked "Access denied. Tailscale connection required." 403
|
||||
`),m){case"letsencrypt":break;case"caddy-managed":i+=` tls internal
|
||||
`;break;case"existing-ca":d&&(i+=` tls {
|
||||
ca ${d}
|
||||
}
|
||||
`);break;case"custom-ca":n&&(i+=` tls {
|
||||
ca ${n}
|
||||
}
|
||||
`);break}if(g&&(i+=` basicauth {
|
||||
admin $2a$14$hashed_password_here
|
||||
}
|
||||
`),e&&(i+=` header {
|
||||
`,i+=` Access-Control-Allow-Origin "*"
|
||||
`,i+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
|
||||
`,i+=` Access-Control-Allow-Headers "Content-Type, Authorization"
|
||||
`,i+=` }
|
||||
`),s)try{const E=JSON.parse(s);i+=` header {
|
||||
`,Object.entries(E).forEach(([T,L])=>{i+=` ${T} "${L}"
|
||||
`}),i+=` }
|
||||
`}catch{console.warn("Invalid JSON in custom headers")}return t&&(i+=` health_uri ${t}
|
||||
`),i+=` reverse_proxy ${b}:${a} {
|
||||
`,p&&p!=="/"&&(i+=` rewrite ${p}
|
||||
`),c&&c!==30&&(i+=` transport http {
|
||||
`,i+=` dial_timeout ${c}s
|
||||
`,i+=` response_header_timeout ${c}s
|
||||
`,i+=` }
|
||||
`),i+=` }
|
||||
`,i+=`}
|
||||
`,i}async function u(r,h,a=DC.DEFAULTS.TTL){const b=window.getToken(getPrimaryDnsId(),"admin");if(!b)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const m=buildDomain(r),n=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:m,ip:h,ttl:a,token:b,server:SITE.dnsIp})});if(!n.ok){const g=await n.text();throw new Error(`DNS API Error: ${n.status} - ${g}`)}const d=await n.json();if(!d.success)throw new Error(`DNS Error: ${d.error||"Unknown error"}`);return d}async function y(r){const h={id:r.subdomain,name:r.name,logo:r.logo||`/assets/${r.subdomain}.png`};r.category&&(h.category=r.category),r.containerId&&(h.containerId=r.containerId);try{const a=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(h)});if(!a.ok){const b=await a.json();throw new Error(b.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),h}catch(a){throw console.error("Failed to add service to config:",a),a}}async function l(r){const h=document.getElementById("service-subdomain-input").value.trim(),a=document.getElementById("service-ip-input").value.trim()||"localhost",b=document.getElementById("service-port-input").value.trim()||"80",m=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(h),upstream:`${a}:${b}`,config:r})}),n=await m.json();if(!m.ok||!n.success)throw new Error(n.error||`Caddy API Error: ${m.status}`);return n}window.loadExistingCAs=o,window.generateCaddyConfig=f,window.createDnsRecord=u,window.addServiceToConfig=y,window.addToCaddyfile=l})(),(function(){let o=null;function f(a){o=a;const b=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${a.name}`,document.getElementById("edit-service-name").value=a.name,document.getElementById("edit-service-url-display").textContent=a.url||buildServiceUrl(a.id),document.getElementById("edit-service-logo-preview").src=a.logo||`/assets/${a.id}.png`,document.getElementById("edit-subdomain").value=a.id,document.getElementById("edit-port").value=a.port||"",document.getElementById("edit-ip").value=a.ip||"localhost",document.getElementById("edit-tailscale-only").checked=a.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=a.logo||"";const m=document.getElementById("edit-service-category");m&&(m.dataset.current=a.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),b.classList.add("show")}function u(){closeModal("service-edit-modal"),o=null}async function y(){if(!o)return;const a=document.getElementById("edit-subdomain").value.trim().toLowerCase(),b=document.getElementById("edit-service-name").value.trim(),m=document.getElementById("edit-port").value.trim(),n=document.getElementById("edit-ip").value.trim()||"localhost",d=document.getElementById("edit-tailscale-only").checked,g=document.getElementById("edit-logo-url").value.trim(),e=document.getElementById("edit-service-category")?.value||"";if(!a){showNotification("Subdomain is required","warning");return}const s=o.id,p=[];if(a!==s&&p.push("subdomain"),b&&b!==o.name&&p.push("name"),m&&m!==String(o.port)&&p.push("port"),n!==o.ip&&p.push("ip"),d!==(o.tailscaleOnly||!1)&&p.push("tailscale"),g&&g!==o.logo&&p.push("logo"),e!==(o.category||"")&&p.push("category"),p.length===0){u();return}const t=document.getElementById("service-edit-save");t.textContent="Saving...",t.disabled=!0;try{const v=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:s,newSubdomain:a,name:b||o.name,port:m||o.port,ip:n,tailscaleOnly:d,logo:g||void 0,category:e})})).json();if(!v.success)throw new Error(v.error||"Failed to update service");const i=window.APPS.findIndex(E=>E.id===s);i!==-1&&(window.APPS[i]={...window.APPS[i],id:a,name:b||window.APPS[i].name,port:m||window.APPS[i].port,ip:n,tailscaleOnly:d,logo:g||window.APPS[i].logo,category:e||void 0}),u(),window.buildGrid(),window.refreshAll()}catch(c){console.error("Error saving service changes:",c),showNotification(`Error saving changes: ${c.message}`,"error")}finally{t.textContent="Save Changes",t.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async a=>{const b=a.target.files[0];if(!b)return;if(!b.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const m=new FileReader;m.onload=async n=>{const d=n.target.result;if(document.getElementById("edit-service-logo-preview").src=d,document.getElementById("edit-logo-url").value=d,o)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${o.id}.png`,data:d})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").val
Line truncated
|
||||
|
||||
This will:
|
||||
1. Pull the latest image
|
||||
2. Stop the container
|
||||
3. Recreate with same settings
|
||||
|
||||
The service will be briefly unavailable.`))try{n&&(n.textContent="\u{1F504}",n.disabled=!0,n.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${a}/update`,{method:"POST"})).json();if(e.success){const s=window.APPS.find(p=>p.id===m);s&&e.newContainerId&&(s.containerId=e.newContainerId),n&&(n.textContent="\u2705",n.title="Updated successfully!",setTimeout(()=>{n.textContent=d,n.disabled=!1,n.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${b} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(g){console.error("Update error:",g),n&&(n.textContent="\u274C",n.title="Update failed",setTimeout(()=>{n.textContent=d,n.disabled=!1,n.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${b}: ${g.message}`,"error")}}async function h(a,b){const m=window.APPS.find(i=>i.id===a),n=m?buildDomain(m.id):null,d=m?.containerId,g=await l(b||a,d,m?.containerId);if(g===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(g&&d)try{const i=new URLSearchParams({containerId:m.containerId,subdomain:m.id,ip:m.ip||"localhost",deleteContainer:"true"}),T=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(m.id)}?${i.toString()}`,{method:"DELETE"})).json();T.success?e={...e,...T.results,dashboard:!1}:console.error("App removal failed:",T.error)}catch(i){console.error("App removal error:",i)}else if(g&&n){try{const i=m?.ip||"localhost",T=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(n)}&type=A&ipAddress=${encodeURIComponent(i)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=T.success?"deleted":T.error||"failed"}catch(i){e.dns=i.message}try{const E=await(await secureFetch(`/api/v1/site/${encodeURIComponent(n)}`,{method:"DELETE"})).json();e.caddy=E.success||E.error&&E.error.includes("not found")?"removed":E.error||"failed"}catch(i){e.caddy=i.message}}const s=window.APPS.findIndex(i=>i.id===a);s>-1&&(window.APPS.splice(s,1),e.dashboard=!0);try{const i=safeGetJSON("custom-apps",[]),E=i.findIndex(T=>T.id===a);E>-1&&(i.splice(E,1),safeSet("custom-apps",JSON.stringify(i)))}catch{}try{const E=await(await secureFetch(`/api/v1/services/${encodeURIComponent(a)}`,{method:"DELETE"})).json();e.service=E.success?"removed":E.error||"failed"}catch(i){e.service=i.message}window.buildGrid(),window.refreshAll();let p=!1,t=[];e.dashboard||(p=!0,t.push("\u2717 Failed to remove from dashboard"));const c=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],v=i=>!i||c.some(E=>i.toLowerCase().includes(E.toLowerCase()));e.container&&!v(e.container)&&(p=!0,t.push(`\u26A0 Container: ${e.container}`)),e.dns&&!v(e.dns)&&(p=!0,t.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!v(e.caddy)&&(p=!0,t.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!v(e.service)&&(p=!0,t.push(`\u26A0 Service File: ${e.service}`)),p&&showNotification(`Error deleting "${b||a}": ${t.join(", ")}`,"error",6e3)}window.openServiceEditModal=f,window.showDeleteModal=l,window.updateContainer=r,window.deleteService=h})(),(function(){function o(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function f(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function u(){const e=document.getElementById("service-subdomain-input").value||"subdomain",s=document.getElementById("service-ip-input").value||y.lan||"localhost",p=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,t=document.getElementById("ssl-type-select").value,c=document.getElementById("ca-name-input").value||"sami-ca",v=document.getElementById("existing-ca-select").value,i=document.getElementById("enable-auth").checked,E=document.getElementById("enable-cors").checked,T=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",P=document.getElementById("health-check-input").value,S=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(e)} \u2192 ${s}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const x={subdomain:e,port:p,ip:s,sslType:t,caName:c,existingCa:v,enableAuth:i,enableCors:E,customHeaders:T,upstreamPath:L,healthCheck:P,timeout:S},w=window.generateCaddyConfig(x),C=document.getElementById("caddy-config-preview");C&&(C.value=w)}const y={localhost:"127.0.0.1",lan:"",tailscale:""};async function l(){try{const t=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(t.ok){const c=await t.json();c.lan&&(y.lan=c.lan),c.tailscale&&(y.tailscale=c.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),s=document.getElementById("quick-ip-tailscale");e&&(y.lan?(e.dataset.ip=y.lan,e.textContent=`LAN (${y.lan})`,e.title=`LAN IP: ${y.lan}`):e
Line truncated
|
||||
<span style="color: #4caf50;">\u2713 Connected</span>
|
||||
<span style="color: var(--muted); margin-left: 6px;">${E.self?.hostname} (${E.self?.ip})</span>
|
||||
`,v.disabled=!1):E.installed?(c.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',v.disabled=!0):(c.innerHTML='<span style="color: var(--muted);">Not available</span>',v.disabled=!0)}catch{c.innerHTML='<span style="color: var(--muted);">Could not check</span>',v.disabled=!0}v.checked=!1,u()}function a(){const e=document.getElementById("service-type-local"),s=document.getElementById("service-type-external"),p=document.getElementById("local-service-config"),t=document.getElementById("external-service-config"),c=document.getElementById("tab-local"),v=document.getElementById("tab-external");function i(){e.checked?(p.style.display="grid",t.style.display="none",c&&(c.style.background="var(--accent)",c.style.color="var(--bg)"),v&&(v.style.background="transparent",v.style.color="var(--muted)")):(p.style.display="none",t.style.display="block",v&&(v.style.background="var(--accent)",v.style.color="var(--bg)"),c&&(c.style.background="transparent",c.style.color="var(--muted)"))}e?.addEventListener("change",i),s?.addEventListener("change",i)}function b(){const e=document.getElementById("service-name-input"),s=document.getElementById("service-subdomain-input"),p=document.getElementById("subdomain-preview");let t=!1;e?.addEventListener("input",()=>{const L=o(e.value);!t&&s&&(s.value=L),p&&(p.textContent=L?`\u2192 ${buildDomain(L)}`:""),u()}),s?.addEventListener("input",()=>{t=s.value!==o(e?.value||"");const L=s.value.trim()||o(e?.value||"");p&&(p.textContent=L?`\u2192 ${buildDomain(L)}`:""),u()});const c=document.getElementById("external-service-name"),v=document.getElementById("external-service-subdomain"),i=document.getElementById("external-subdomain-preview"),E=document.getElementById("external-domain-preview");let T=!1;c?.addEventListener("input",()=>{const L=o(c.value);!T&&v&&(v.value=L);const P=v?.value||L;i&&(i.textContent=P?`\u2192 ${buildDomain(P)}`:""),E&&(E.textContent=P?buildDomain(P):"")}),v?.addEventListener("input",()=>{T=v.value!==o(c?.value||"");const L=v.value.trim()||o(c?.value||"");i&&(i.textContent=L?`\u2192 ${buildDomain(L)}`:""),E&&(E.textContent=L?buildDomain(L):"")})}async function m(){const e=document.getElementById("external-service-name").value.trim(),s=document.getElementById("external-service-url").value.trim(),p=(document.getElementById("external-service-subdomain").value.trim()||o(e)).toLowerCase(),t=document.getElementById("external-service-logo").value.trim(),c=document.getElementById("external-service-icon").value.trim(),v=document.getElementById("external-create-dns").checked,i=document.getElementById("external-create-caddy").checked,E=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",T=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!e||!s){showNotification("Please fill in Name and External URL","warning");return}if(!p){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!s.startsWith("http://")&&!s.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const S=buildDomain(p);try{const B={dns:null,caddy:null,dashboard:!1};if(v)if(window.getToken(getPrimaryDnsId(),"admin"))try{const A=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:S,ip:E,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(I){B.dns=I.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(i)try{const k={subdomain:p,externalUrl:s,preserveHost:T,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},A=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(k){B.caddy=k.message}const $={id:p,name:e,url:`https://${S}`,externalUrl:s,logo:t||c||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const x=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],w=window.APPS.filter(k=>!x.includes(k.id));safeSet("custom-services",JSON.stringify(w));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(k){console.warn("Failed to save to services.json:",k)}window.buildGrid(),window.refreshAll(),n();const C=[`External service "${e}" added!`];v&&C.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),i&&C.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),C.push(`Access at: https://${S}`),showNotification(C.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`F
Line truncated
|
||||
Vendored
+1
-1
@@ -127,7 +127,7 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
|
||||
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
|
||||
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${g.replace(/_/g," ")}</div>
|
||||
</div>
|
||||
`,D+="</div>",x.innerHTML=D,N("setup-step-summary")}async function z(x){try{const D=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)});return D.ok?(await D.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${D.status}`),{function:"saveConfigToServer"}),!1)}catch(D){return errorHandler.logError("[SetupWizard] Save Config",D,{function:"saveConfigToServer"}),!1}}async function A(){const x={setupComplete:!0,configurationType:h,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};h==="homelab"?(x.tld=document.getElementById("setup-tld")?.value?.trim()||".home",x.caName=document.getElementById("setup-ca-name")?.value?.trim()||"",x.dns={provider:"technitium",ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},x.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}):h==="simple"?(x.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",x.defaults={dnsType:"none",sslType:"none",targetIP:x.defaultIP}):h==="public"&&(x.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",x.email=document.getElementById("setup-public-email")?.value?.trim()||"",x.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",x.defaults={dnsType:x.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const D=await z(x);safeSet("dashcaddy-config",JSON.stringify(x)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const g=h==="homelab"?"Professional Home Lab":h==="simple"?"Simple Setup":"Public Server",u=D?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${g}. Settings saved to: ${u}`,"success",5e3),setTimeout(()=>location.reload(),500)}const v=document.getElementById("setup-step-1-next");v&&(v.onclick=function(x){x.preventDefault();const D=document.querySelector('input[name="config-type"]:checked');D&&(h=D.value),N(h==="homelab"?"setup-step-homelab":h==="simple"?"setup-step-simple":h==="public"?"setup-step-public":"setup-step-homelab")});const L=document.getElementById("setup-skip");L&&(L.onclick=async function(x){x.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const b=document.getElementById("setup-tld");b&&(b.oninput=function(x){const D=x.target.value||".home",g=document.getElementById("tld-preview"),u=document.getElementById("tld-preview-2");g&&(g.textContent=D),u&&(u.textContent=D)});const M=document.getElementById("setup-homelab-back");M&&(M.onclick=function(x){x.preventDefault(),N("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-tld")?.value?.trim()||"",g=document.getElementById("setup-ca-name")?.value?.trim()||"",u=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!D||!D.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!g){showNotification("Please enter a Certificate Authority name","warning");return}if(!u){showNotification("Please enter your DNS server IP address","warning");return}O()});const B=document.getElementById("setup-simple-back");B&&(B.onclick=function(x){x.preventDefault(),N("setup-step-1")});const S=document.getElementById("setup-simple-next");S&&(S.onclick=function(x){x.preventDefault(),O()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(x){x.onchange=function(){var D=document.getElementById("dns-requirement-note");D&&(D.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const T=document.getElementById("setup-public-back");T&&(T.onclick=function(x){x.preventDefault(),N("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-public-domain")?.value?.trim()||"",g=document.getElementById("setup-public-email")?.value?.trim()||"";if(!D){showNotification("Please enter your domain name","warning");return}if(!g||!g.includes("@")){showNotification("Please enter a valid email address","warning");return}O()});const H=document.getElementById("setup-summary-back");H&&(H.onclick=function(x){x.preventDefault(),h==="homelab"?N("setup-step-homelab"):h==="simple"?N("setup-step-simple"):h==="public"&&N("setup-step-pub
Line truncated
|
||||
`,D+="</div>",x.innerHTML=D,N("setup-step-summary")}async function z(x){try{const D=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)});return D.ok?(await D.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${D.status}`),{function:"saveConfigToServer"}),!1)}catch(D){return errorHandler.logError("[SetupWizard] Save Config",D,{function:"saveConfigToServer"}),!1}}async function A(){const x={setupComplete:!0,configurationType:h,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(h==="homelab"){x.tld=document.getElementById("setup-tld")?.value?.trim()||".home",x.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const f=document.getElementById("setup-dns-provider")?.value||"technitium";x.dns={provider:f,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},x.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else h==="simple"?(x.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",x.defaults={dnsType:"none",sslType:"none",targetIP:x.defaultIP}):h==="public"&&(x.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",x.email=document.getElementById("setup-public-email")?.value?.trim()||"",x.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",x.defaults={dnsType:x.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const D=await z(x);safeSet("dashcaddy-config",JSON.stringify(x)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const g=h==="homelab"?"Professional Home Lab":h==="simple"?"Simple Setup":"Public Server",u=D?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${g}. Settings saved to: ${u}`,"success",5e3),setTimeout(()=>location.reload(),500)}const v=document.getElementById("setup-step-1-next");v&&(v.onclick=function(x){x.preventDefault();const D=document.querySelector('input[name="config-type"]:checked');D&&(h=D.value),N(h==="homelab"?"setup-step-homelab":h==="simple"?"setup-step-simple":h==="public"?"setup-step-public":"setup-step-homelab")});const L=document.getElementById("setup-skip");L&&(L.onclick=async function(x){x.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const b=document.getElementById("setup-tld");b&&(b.oninput=function(x){const D=x.target.value||".home",g=document.getElementById("tld-preview"),u=document.getElementById("tld-preview-2");g&&(g.textContent=D),u&&(u.textContent=D)});const M=document.getElementById("setup-homelab-back");M&&(M.onclick=function(x){x.preventDefault(),N("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-tld")?.value?.trim()||"",g=document.getElementById("setup-ca-name")?.value?.trim()||"",u=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!D||!D.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!g){showNotification("Please enter a Certificate Authority name","warning");return}if(!u){showNotification("Please enter your DNS server IP address","warning");return}O()});const B=document.getElementById("setup-simple-back");B&&(B.onclick=function(x){x.preventDefault(),N("setup-step-1")});const S=document.getElementById("setup-simple-next");S&&(S.onclick=function(x){x.preventDefault(),O()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(x){x.onchange=function(){var D=document.getElementById("dns-requirement-note");D&&(D.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const T=document.getElementById("setup-public-back");T&&(T.onclick=function(x){x.preventDefault(),N("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-public-domain")?.value?.trim()||"",g=document.getElementById("setup-public-email")?.value?.trim()||"";if(!D){showNotification("Please enter your domain name","warning");return}if(!g||!g.includes("@")){showNotification("Please enter a valid email address","warning");return}O()});const H=document.getElementById("setup-summary-back");H&&(H.onclick=function(x){x.preventDefault(),h==="homelab"?N("setup-step-homela
Line truncated
|
||||
<div class="app-selector-content">
|
||||
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
|
||||
<div id="app-selector-grid" class="app-selector-grid"></div>
|
||||
|
||||
Vendored
+19
-19
@@ -1,4 +1,4 @@
|
||||
(function(){function v(){const a=safeGet("custom-services");if(a)try{JSON.parse(a).forEach(e=>{window.APPS.find(n=>n.id===e.id)||window.APPS.push(e)})}catch(i){console.warn("Failed to load custom services:",i)}}v();function k(){const a=document.querySelectorAll(".top .card");a.forEach((i,e)=>{i.style.transitionDelay=`${Math.min(e*60,300)}ms`}),requestAnimationFrame(()=>{a.forEach(i=>i.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const a=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(i=>{console.warn("[init] Service worker registration failed:",i)})};document.readyState==="complete"?a():window.addEventListener("load",a,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await y(),window.buildGrid(),k(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const i=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();i.success&&window._updateAuthCard(i.config.enabled&&i.config.isSetUp,i.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}S(),C()&&b()}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const a=document.createElement("script");if(a.src="/dist/onboarding.js",a.defer=!0,document.head.appendChild(a),!document.querySelector('link[href="/css/driver.min.css"]')){const i=document.createElement("link");i.rel="stylesheet",i.href="/css/driver.min.css",document.head.appendChild(i)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const i=document.createElement("link");i.rel="stylesheet",i.href="/css/onboarding.css",document.head.appendChild(i)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!a||!a.tourCompleted&&a.currentStep===0}catch{return!0}}function E(){const a=document.querySelectorAll(".tools-section");if(!a.length)return;let i={};try{i=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}a.forEach(e=>{const n=e.dataset.section,c=e.querySelector(".tools-section-header");c&&(i[n]&&(e.classList.add("open"),c.setAttribute("aria-expanded","true")),c.addEventListener("click",s=>{s.preventDefault();const l=e.classList.toggle("open");c.setAttribute("aria-expanded",l?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(t=>{m[t.dataset.section]=t.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let a=typeof SITE<"u"&&SITE.onboardingCompleted;try{const n=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));a=a||!!(n&&n.tourCompleted)}catch{}const i=a?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!i)return;const e=document.createElement("button");e.id="restart-tour-btn",e.textContent=a?"Help Tour":"\u{1F393} Help Tour",e.title="Restart the onboarding tour",e.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const n=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(n),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(n),5e3)}},i.appendChild(e)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function y(){try{const a=await fetch("/api/v1/templates",{cache:"no-store"});if(!a.ok)return;const i=await a.json();i&&i.categories&&(window.DC_CATEGORIES=i.categories,typeof DC<"u"&&(DC.CATEGORIES=i.categories),q())}catch(a){console.warn("[init] Failed to load template categories:",a)}}function q(){const a=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;a&&document.querySelectorAll('select[data-role="service-category"]').forEach(i=>{const e=i.dataset.current||"",n=i.querySelector('option[value=""]');if(i.innerHTML="",n)i.appendChild(n);else{const c=document.createElement("option");c.value="",c.textContent="\u2014 Select category \u2014",i.appendChild(c)}Object.entries(a).forEach(([c,s])=>{const l=document.createElement("option");l.value=c,l.textContent=`${s.icon||""} ${c}`.trim(),c===e&&(l.selected=!0),i.appendChild(l)})})}window.populateCategorySelects=q,window.loadTemplateCategories=y,(async()=>{try{const i=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(i.success&&i.config.enabled&&(await fetch("/api/v1/totp/check-session",{cache:"no-store"})).status===401){window._showTotpOverlay();return}}catch(a){console.warn("TOTP check failed, proceeding normally:",a)}f(Line truncated
|
||||
(function(){function v(){const i=safeGet("custom-services");if(i)try{JSON.parse(i).forEach(d=>{window.APPS.find(o=>o.id===d.id)||window.APPS.push(d)})}catch(a){console.warn("Failed to load custom services:",a)}}v();function k(){const i=document.querySelectorAll(".top .card");i.forEach((a,d)=>{a.style.transitionDelay=`${Math.min(d*60,300)}ms`}),requestAnimationFrame(()=>{i.forEach(a=>a.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const i=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(a=>{console.warn("[init] Service worker registration failed:",a)})};document.readyState==="complete"?i():window.addEventListener("load",i,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await g(),window.buildGrid(),k(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const a=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();a.success&&window._updateAuthCard(a.config.enabled&&a.config.isSetUp,a.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}S(),C()&&b()}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const i=document.createElement("script");if(i.src="/dist/onboarding.js",i.defer=!0,document.head.appendChild(i),!document.querySelector('link[href="/css/driver.min.css"]')){const a=document.createElement("link");a.rel="stylesheet",a.href="/css/driver.min.css",document.head.appendChild(a)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const a=document.createElement("link");a.rel="stylesheet",a.href="/css/onboarding.css",document.head.appendChild(a)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const i=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!i||!i.tourCompleted&&i.currentStep===0}catch{return!0}}function E(){const i=document.querySelectorAll(".tools-section");if(!i.length)return;let a={};try{a=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}i.forEach(d=>{const o=d.dataset.section,n=d.querySelector(".tools-section-header");n&&(a[o]&&(d.classList.add("open"),n.setAttribute("aria-expanded","true")),n.addEventListener("click",c=>{c.preventDefault();const r=d.classList.toggle("open");n.setAttribute("aria-expanded",r?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(e=>{m[e.dataset.section]=e.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let i=typeof SITE<"u"&&SITE.onboardingCompleted;try{const o=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));i=i||!!(o&&o.tourCompleted)}catch{}const a=i?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!a)return;const d=document.createElement("button");d.id="restart-tour-btn",d.textContent=i?"Help Tour":"\u{1F393} Help Tour",d.title="Restart the onboarding tour",d.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const o=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(o),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(o),5e3)}},a.appendChild(d)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function g(){try{const i=await fetch("/api/v1/templates",{cache:"no-store"});if(!i.ok)return;const a=await i.json();a&&a.categories&&(window.DC_CATEGORIES=a.categories,typeof DC<"u"&&(DC.CATEGORIES=a.categories),q())}catch(i){console.warn("[init] Failed to load template categories:",i)}}function q(){const i=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;i&&document.querySelectorAll('select[data-role="service-category"]').forEach(a=>{const d=a.dataset.current||"",o=a.querySelector('option[value=""]');if(a.innerHTML="",o)a.appendChild(o);else{const n=document.createElement("option");n.value="",n.textContent="\u2014 Select category \u2014",a.appendChild(n)}Object.entries(i).forEach(([n,c])=>{const r=document.createElement("option");r.value=n,r.textContent=`${c.icon||""} ${n}`.trim(),n===d&&(r.selected=!0),a.appendChild(r)})})}window.populateCategorySelects=q,window.loadTemplateCategories=g,(async()=>{try{const a=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(a.success&&a.config.enabled&&(await fetch("/api/v1/totp/check-session",{cache:"no-store"})).status===401){window._showTotpOverlay();return}}catch(i){console.warn("TOTP check failed, proceeding normally:",i)}f(Line truncated
|
||||
.dc-monitor {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
@@ -109,7 +109,7 @@
|
||||
<div class="dc-monitor-value" id="dc-monitor-health">\u2014</div>
|
||||
<div class="dc-monitor-sub" id="dc-monitor-health-sub">\u2014</div>
|
||||
</div>
|
||||
`,k.parentNode.insertBefore(u,k);function h(e,n){const c=document.getElementById(e);if(!c)return;const s=Math.max(0,Math.min(100,Number(n)||0));c.style.width=s+"%",c.classList.remove("warn","bad"),s>=85?c.classList.add("bad"):s>=65&&c.classList.add("warn")}function f(e){return e==null||isNaN(e)?"\u2014":Math.round(e*10)/10+"%"}function b(e){if(e==null||isNaN(e))return"\u2014";const n=["B","KB","MB","GB","TB"];let c=0;for(;e>=1024&&c<n.length-1;)e/=1024,c++;return e.toFixed(1)+" "+n[c]}function C(){const e=(window.APPS||[]).length;let n=0;document.querySelectorAll("#cards .card").forEach(l=>{l.dataset.status==="on"&&n++});const c=document.getElementById("dc-monitor-services"),s=document.getElementById("dc-monitor-services-sub");c&&(c.textContent=`${n} / ${e}`),s&&(s.textContent=e===0?"no services yet":`${n} online \xB7 ${e-n} offline`)}function E(e){const n=document.getElementById("dc-monitor-health"),c=document.getElementById("dc-monitor-health-sub");if(!n)return;if(!e||e.summary==null){n.textContent="\u2014",c&&(c.textContent="no data");return}const s=e.summary,l=s.healthy??s.up??0,m=s.unhealthy??s.down??0,t=s.total??l+m;n.textContent=`${l}/${t}`,c&&(m===0?c.innerHTML='<span class="dc-monitor-pill ok">\u25CF all healthy</span>':m<=2?c.innerHTML=`<span class="dc-monitor-pill warn">\u25CF ${m} degraded</span>`:c.innerHTML=`<span class="dc-monitor-pill bad">\u25CF ${m} down</span>`)}async function S(){try{const e=await fetch("/api/v1/monitoring/stats",{cache:"no-store"});if(!e.ok)return null;const n=await e.json();return n&&n.stats?n.stats:null}catch{return null}}async function y(){try{const e=await fetch("/api/v1/health-checks/status",{cache:"no-store"});return e.ok?await e.json():null}catch{return null}}function q(e){const n=document.getElementById("dc-monitor-containers"),c=document.getElementById("dc-monitor-containers-sub"),s=document.getElementById("dc-monitor-cpu"),l=document.getElementById("dc-monitor-mem");if(!e){n&&(n.textContent="\u2014"),s&&(s.textContent="\u2014"),l&&(l.textContent="\u2014");return}const m=Object.values(e);if(m.length===0){n&&(n.textContent="0"),c&&(c.textContent="no containers reporting"),s&&(s.textContent="0%"),l&&(l.textContent="0%"),h("dc-monitor-cpu-bar",0),h("dc-monitor-mem-bar",0);return}let t=0,o=0,r=0,p=0,d=0;m.forEach(g=>{if(g.cpu!=null){const x=Number(g.cpu);isNaN(x)||(t+=x>1?x:x*100,p++)}if(g.memory!=null){const x=Number(g.memory);isNaN(x)||(o+=x,r+=Number(g.memoryUsage||0),d++)}});const w=p?t/p:0,L=d?o/d:0;if(n&&(n.textContent=String(m.length)),c){const g=r?` \xB7 ${b(r)} RAM`:"";c.textContent=`running${g}`}s&&(s.textContent=f(w)),l&&(l.textContent=f(L)),h("dc-monitor-cpu-bar",w),h("dc-monitor-mem-bar",L)}let a=!1;async function i(){if(!a){a=!0;try{C();const[e,n]=await Promise.all([S(),y()]);q(e),E(n);const c=document.getElementById("dc-monitor-refresh-stamp");if(c){const s=new Date;c.textContent=`updated ${s.toLocaleTimeString()}`}}finally{a=!1}}}window.refreshMonitoringWidgets=i,setInterval(i,typeof DC<"u"&&DC.POLL&&DC.POLL.STATS||5e3),setTimeout(i,200)})(),(function(){"use strict";const v=(...t)=>{window.DASHCADDY_DEBUG&&console.log(...t)},k=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let u=null,h=null,f=null;function b(){try{C(),document.addEventListener("keydown",E),v("[Keyboard Shortcuts] Initialized"),v("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),v("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function C(){u=document.createElement("div"),u.id="quick-search-modal",u.className="quick-search-modal",u.innerHTML=`
|
||||
`,k.parentNode.insertBefore(u,k);function h(o,n){const c=document.getElementById(o);if(!c)return;const r=Math.max(0,Math.min(100,Number(n)||0));c.style.width=r+"%",c.classList.remove("warn","bad"),r>=85?c.classList.add("bad"):r>=65&&c.classList.add("warn")}function f(o){return o==null||isNaN(o)?"\u2014":Math.round(o*10)/10+"%"}function b(o){if(o==null||isNaN(o))return"\u2014";const n=["B","KB","MB","GB","TB"];let c=0;for(;o>=1024&&c<n.length-1;)o/=1024,c++;return o.toFixed(1)+" "+n[c]}async function C(){if(Array.isArray(window.APPS)&&window.APPS.length>0){const n=document.querySelectorAll('#cards .card[data-status="on"]').length;return{total:window.APPS.length,up:n,source:"APPS"}}const o=document.querySelectorAll("#cards .card");if(o.length>0){const n=Array.from(o).filter(c=>c.dataset.status==="on").length;return{total:o.length,up:n,source:"DOM"}}try{const n=await fetch("/api/v1/services",{cache:"no-store"});if(!n.ok)return{total:0,up:0,source:"fetch-fail"};const c=await n.json(),r=c&&Array.isArray(c.services)?c.services:Array.isArray(c)?c:[];(Array.isArray(window.APPS)||typeof window.APPS>"u")&&(window.APPS=r);const m=document.querySelectorAll('#cards .card[data-status="on"]').length;return{total:r.length,up:m,source:"fetch"}}catch{return{total:0,up:0,source:"fetch-error"}}}async function E(){const{total:o,up:n}=await C(),c=document.getElementById("dc-monitor-services"),r=document.getElementById("dc-monitor-services-sub");c&&(c.textContent=`${n} / ${o}`),r&&(r.textContent=o===0?"no services yet":`${n} online \xB7 ${o-n} offline`)}function S(o){const n=document.getElementById("dc-monitor-health"),c=document.getElementById("dc-monitor-health-sub");if(!n)return;if(!o||o.summary==null){n.textContent="\u2014",c&&(c.textContent="no data");return}const r=o.summary,m=r.healthy??r.up??0,e=r.unhealthy??r.down??0,t=r.total??m+e;n.textContent=`${m}/${t}`,c&&(e===0?c.innerHTML='<span class="dc-monitor-pill ok">\u25CF all healthy</span>':e<=2?c.innerHTML=`<span class="dc-monitor-pill warn">\u25CF ${e} degraded</span>`:c.innerHTML=`<span class="dc-monitor-pill bad">\u25CF ${e} down</span>`)}async function g(){try{const o=await fetch("/api/v1/monitoring/stats",{cache:"no-store"});if(!o.ok)return null;const n=await o.json();return n&&n.stats?n.stats:null}catch{return null}}async function q(){try{const o=await fetch("/api/v1/health-checks/status",{cache:"no-store"});return o.ok?await o.json():null}catch{return null}}function i(o){const n=document.getElementById("dc-monitor-containers"),c=document.getElementById("dc-monitor-containers-sub"),r=document.getElementById("dc-monitor-cpu"),m=document.getElementById("dc-monitor-mem");if(!o){n&&(n.textContent="\u2014"),r&&(r.textContent="\u2014"),m&&(m.textContent="\u2014");return}const e=Object.values(o);if(e.length===0){n&&(n.textContent="0"),c&&(c.textContent="no containers reporting"),r&&(r.textContent="0%"),m&&(m.textContent="0%"),h("dc-monitor-cpu-bar",0),h("dc-monitor-mem-bar",0);return}let t=0,s=0,p=0,l=0,y=0;e.forEach(w=>{if(w.cpu!=null){const x=Number(w.cpu);isNaN(x)||(t+=x>1?x:x*100,l++)}if(w.memory!=null){const x=Number(w.memory);isNaN(x)||(s+=x,p+=Number(w.memoryUsage||0),y++)}});const A=l?t/l:0,L=y?s/y:0;if(n&&(n.textContent=String(e.length)),c){const w=p?` \xB7 ${b(p)} RAM`:"";c.textContent=`running${w}`}r&&(r.textContent=f(A)),m&&(m.textContent=f(L)),h("dc-monitor-cpu-bar",A),h("dc-monitor-mem-bar",L)}let a=!1;async function d(){if(!a){a=!0;try{E();const[o,n]=await Promise.all([g(),q()]);i(o),S(n);const c=document.getElementById("dc-monitor-refresh-stamp");if(c){const r=new Date;c.textContent=`updated ${r.toLocaleTimeString()}`}}finally{a=!1}}}window.refreshMonitoringWidgets=d,setInterval(d,typeof DC<"u"&&DC.POLL&&DC.POLL.STATS||5e3),setTimeout(d,200)})(),(function(){"use strict";const v=(...e)=>{window.DASHCADDY_DEBUG&&console.log(...e)},k=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let u=null,h=null,f=null;function b(){try{C(),document.addEventListener("keydown",E),v("[Keyboard Shortcuts] Initialized"),v("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),v("[Keyboard Shortcuts] Press Escape to close modals")}catch(e){console.warn("[Keyboard Shortcuts] Failed to initialize:",e.message)}}function C(){u=document.createElement("div"),u.id="quick-search-modal",u.className="quick-search-modal",u.innerHTML=`
|
||||
<div class="quick-search-content">
|
||||
<div class="quick-search-input-wrapper">
|
||||
<span class="quick-search-icon">\u{1F50D}</span>
|
||||
@@ -123,7 +123,7 @@
|
||||
<span><kbd>Esc</kbd> Close</span>
|
||||
</div>
|
||||
</div>
|
||||
`;const t=document.createElement("style");t.textContent=`
|
||||
`;const e=document.createElement("style");e.textContent=`
|
||||
.quick-search-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
@@ -271,7 +271,7 @@
|
||||
font-family: monospace;
|
||||
margin-right: 4px;
|
||||
}
|
||||
`,document.head.appendChild(t),document.body.appendChild(u),h=document.getElementById("quick-search-input"),f=document.getElementById("quick-search-results"),h.addEventListener("input",e),h.addEventListener("keydown",l),u.addEventListener("click",o=>{o.target===u&&y()})}function E(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),S();return}if(t.key==="Escape"){if(u&&u.classList.contains("show")){y();return}q()}}catch(o){console.warn("[Keyboard Shortcuts] Error handling keydown:",o.message)}}function S(){try{u.classList.add("show"),h.value="",h.focus(),a()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function y(){try{u.classList.remove("show"),h.value="",f.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function q(){for(const t of k){const o=document.querySelector(t);if(o&&(o.classList.contains("show")||o.style.display==="flex"))return o.classList.remove("show"),o.style.display="none",!0}return!1}function a(){const t=`
|
||||
`,document.head.appendChild(e),document.body.appendChild(u),h=document.getElementById("quick-search-input"),f=document.getElementById("quick-search-results"),h.addEventListener("input",d),h.addEventListener("keydown",r),u.addEventListener("click",t=>{t.target===u&&g()})}function E(e){try{if((e.ctrlKey||e.metaKey)&&e.key==="k"){e.preventDefault(),S();return}if(e.key==="Escape"){if(u&&u.classList.contains("show")){g();return}q()}}catch(t){console.warn("[Keyboard Shortcuts] Error handling keydown:",t.message)}}function S(){try{u.classList.add("show"),h.value="",h.focus(),i()}catch(e){console.warn("[Keyboard Shortcuts] Error opening quick search:",e.message)}}function g(){try{u.classList.remove("show"),h.value="",f.innerHTML=""}catch(e){console.warn("[Keyboard Shortcuts] Error closing quick search:",e.message)}}function q(){for(const e of k){const t=document.querySelector(e);if(t&&(t.classList.contains("show")||t.style.display==="flex"))return t.classList.remove("show"),t.style.display="none",!0}return!1}function i(){const e=`
|
||||
<div class="quick-search-category">Quick Actions</div>
|
||||
<div class="quick-search-item" data-action="refresh">
|
||||
<span class="quick-search-item-icon">\u{1F504}</span>
|
||||
@@ -303,29 +303,29 @@
|
||||
</div>
|
||||
|
||||
<div class="quick-search-category">Services</div>
|
||||
${i()}
|
||||
`;f.innerHTML=t,s()}function i(){const t=document.querySelectorAll(".card[data-app], #cards .card");let o="";return t.forEach(r=>{const p=r.querySelector(".name")?.textContent||"Unknown",d=r.dataset.status||"unknown",w=r.dataset.app||"";p&&p!=="--"&&(o+=`
|
||||
<div class="quick-search-item" data-action="open-service" data-service="${w}">
|
||||
<span class="quick-search-item-icon">${d==="on"?"\u{1F7E2}":"\u{1F534}"}</span>
|
||||
${a()}
|
||||
`;f.innerHTML=e,c()}function a(){const e=document.querySelectorAll(".card[data-app], #cards .card");let t="";return e.forEach(s=>{const p=s.querySelector(".name")?.textContent||"Unknown",l=s.dataset.status||"unknown",y=s.dataset.app||"";p&&p!=="--"&&(t+=`
|
||||
<div class="quick-search-item" data-action="open-service" data-service="${y}">
|
||||
<span class="quick-search-item-icon">${l==="on"?"\u{1F7E2}":"\u{1F534}"}</span>
|
||||
<div class="quick-search-item-content">
|
||||
<div class="quick-search-item-title">${p}</div>
|
||||
<div class="quick-search-item-description">Click to open service</div>
|
||||
</div>
|
||||
<span class="quick-search-item-badge">${d.toUpperCase()}</span>
|
||||
<span class="quick-search-item-badge">${l.toUpperCase()}</span>
|
||||
</div>
|
||||
`)}),o||'<div class="quick-search-empty">No services found</div>'}function e(t){try{const o=t.target.value.toLowerCase().trim();if(!o){a();return}const r=n(o);c(r)}catch(o){console.warn("[Keyboard Shortcuts] Error handling search input:",o.message)}}function n(t){const o={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(d=>{(d.title.toLowerCase().includes(t)||d.keywords.includes(t))&&o.actions.push(d)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(d=>{const w=d.querySelector(".name")?.textContent||"",L=d.dataset.app||"",g=d.dataset.status||"unknown";(w.toLowerCase().includes(t)||L.toLowerCase().includes(t))&&o.services.push({id:L,title:w,status:g,icon:g==="on"?"\u{1F7E2}":"\u{1F534}"})}),o}function c(t){let o="";t.actions.length>0&&(o+='<div class="quick-search-category">Actions</div>',t.actions.forEach(r=>{o+=`
|
||||
<div class="quick-search-item" data-action="${r.id}">
|
||||
<span class="quick-search-item-icon">${r.icon}</span>
|
||||
`)}),t||'<div class="quick-search-empty">No services found</div>'}function d(e){try{const t=e.target.value.toLowerCase().trim();if(!t){i();return}const s=o(t);n(s)}catch(t){console.warn("[Keyboard Shortcuts] Error handling search input:",t.message)}}function o(e){const t={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(l=>{(l.title.toLowerCase().includes(e)||l.keywords.includes(e))&&t.actions.push(l)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(l=>{const y=l.querySelector(".name")?.textContent||"",A=l.dataset.app||"",L=l.dataset.status||"unknown";(y.toLowerCase().includes(e)||A.toLowerCase().includes(e))&&t.services.push({id:A,title:y,status:L,icon:L==="on"?"\u{1F7E2}":"\u{1F534}"})}),t}function n(e){let t="";e.actions.length>0&&(t+='<div class="quick-search-category">Actions</div>',e.actions.forEach(s=>{t+=`
|
||||
<div class="quick-search-item" data-action="${s.id}">
|
||||
<span class="quick-search-item-icon">${s.icon}</span>
|
||||
<div class="quick-search-item-content">
|
||||
<div class="quick-search-item-title">${r.title}</div>
|
||||
<div class="quick-search-item-title">${s.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
`})),t.services.length>0&&(o+='<div class="quick-search-category">Services</div>',t.services.forEach(r=>{o+=`
|
||||
<div class="quick-search-item" data-action="open-service" data-service="${r.id}">
|
||||
<span class="quick-search-item-icon">${r.icon}</span>
|
||||
`})),e.services.length>0&&(t+='<div class="quick-search-category">Services</div>',e.services.forEach(s=>{t+=`
|
||||
<div class="quick-search-item" data-action="open-service" data-service="${s.id}">
|
||||
<span class="quick-search-item-icon">${s.icon}</span>
|
||||
<div class="quick-search-item-content">
|
||||
<div class="quick-search-item-title">${r.title}</div>
|
||||
<div class="quick-search-item-title">${s.title}</div>
|
||||
</div>
|
||||
<span class="quick-search-item-badge">${r.status.toUpperCase()}</span>
|
||||
<span class="quick-search-item-badge">${s.status.toUpperCase()}</span>
|
||||
</div>
|
||||
`})),o||(o='<div class="quick-search-empty">No results found</div>'),f.innerHTML=o,s()}function s(){f.querySelectorAll(".quick-search-item").forEach((o,r)=>{o.addEventListener("click",()=>m(o)),r===0&&o.classList.add("selected")})}function l(t){try{const o=f.querySelectorAll(".quick-search-item"),r=f.querySelector(".quick-search-item.selected"),p=Array.from(o).indexOf(r);if(t.key==="ArrowDown"){t.preventDefault(),r&&r.classList.remove("selected");const d=(p+1)%o.length;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),r&&r.classList.remove("selected");const d=p<=0?o.length-1:p-1;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),r&&m(r))}catch(o){console.warn("[Keyboard Shortcuts] Error handling search navigation:",o.message)}}function m(t){try{const o=t.dataset.action,r=t.dataset.service;switch(y(),o){case"refresh":document.getElementById("refresh")?.click();break;case"reload-caddy":document.getElementById("reload-caddy-top")?.click();break;case"add-service":document.getElementById("add-service")?.click();break;case"app-selector":document.getElementById("add-service-btn")?.click();break;case"backup":document.getElementById("backup-restore-btn")?.click();break;case"stats":document.getElementById("container-stats-btn")?.click();break;case"logs":document.getElementById("view-error-logs")?.click();break;case"tokens":document.getElementById("manage-tokens")?.click();break;case"notifications":document.getElementById("manage-notifications")?.click();break;case"theme":document.getElementById("theme")?.click();break;case"tour":document.getElementById("restart-tour-btn")?.click();break;case"open-service":if(r){const p=document.querySelector(`[data-app="${r}"] [id$="-open"], [data-app="${r}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(p)p.click();else{const d=document.querySelector(`[data-app="${r}"]`);d&&d.click()}}break;default:v("[Keyboard Shortcuts] Unknown action:",o)}}catch(o){console.warn("[Keyboard Shortcuts] Error executing action:",o.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",b):b(),window.DashCaddyKeyboardShortcuts={openQuickSearch:S,closeQuickSearch:y}})();
|
||||
`})),t||(t='<div class="quick-search-empty">No results found</div>'),f.innerHTML=t,c()}function c(){f.querySelectorAll(".quick-search-item").forEach((t,s)=>{t.addEventListener("click",()=>m(t)),s===0&&t.classList.add("selected")})}function r(e){try{const t=f.querySelectorAll(".quick-search-item"),s=f.querySelector(".quick-search-item.selected"),p=Array.from(t).indexOf(s);if(e.key==="ArrowDown"){e.preventDefault(),s&&s.classList.remove("selected");const l=(p+1)%t.length;t[l]?.classList.add("selected"),t[l]?.scrollIntoView({block:"nearest"})}else if(e.key==="ArrowUp"){e.preventDefault(),s&&s.classList.remove("selected");const l=p<=0?t.length-1:p-1;t[l]?.classList.add("selected"),t[l]?.scrollIntoView({block:"nearest"})}else e.key==="Enter"&&(e.preventDefault(),s&&m(s))}catch(t){console.warn("[Keyboard Shortcuts] Error handling search navigation:",t.message)}}function m(e){try{const t=e.dataset.action,s=e.dataset.service;switch(g(),t){case"refresh":document.getElementById("refresh")?.click();break;case"reload-caddy":document.getElementById("reload-caddy-top")?.click();break;case"add-service":document.getElementById("add-service")?.click();break;case"app-selector":document.getElementById("add-service-btn")?.click();break;case"backup":document.getElementById("backup-restore-btn")?.click();break;case"stats":document.getElementById("container-stats-btn")?.click();break;case"logs":document.getElementById("view-error-logs")?.click();break;case"tokens":document.getElementById("manage-tokens")?.click();break;case"notifications":document.getElementById("manage-notifications")?.click();break;case"theme":document.getElementById("theme")?.click();break;case"tour":document.getElementById("restart-tour-btn")?.click();break;case"open-service":if(s){const p=document.querySelector(`[data-app="${s}"] [id$="-open"], [data-app="${s}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(p)p.click();else{const l=document.querySelector(`[data-app="${s}"]`);l&&l.click()}}break;default:v("[Keyboard Shortcuts] Unknown action:",t)}}catch(t){console.warn("[Keyboard Shortcuts] Error executing action:",t.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",b):b(),window.DashCaddyKeyboardShortcuts={openQuickSearch:S,closeQuickSearch:g}})();
|
||||
Vendored
+3
-3
@@ -1,5 +1,5 @@
|
||||
this.driver=this.driver||{},this.driver.js=(function(y){"use strict";let P={};function _(e={}){P={animate:!0,allowClose:!0,overlayOpacity:.7,smoothScroll:!1,disableActiveInteraction:!1,showProgress:!1,stagePadding:10,stageRadius:5,popoverOffset:10,showButtons:["next","previous","close"],disableButtons:[],overlayColor:"#000",...e}}function a(e){return e?P[e]:P}function c(e,t,i,s){return(e/=s/2)<1?i/2*e*e+t:-i/2*(--e*(e-2)-1)+t}function r(e){const t='a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])';return e.flatMap(i=>{const s=i.matches(t),o=Array.from(i.querySelectorAll(t));return[...s?[i]:[],...o]}).filter(i=>getComputedStyle(i).pointerEvents!=="none"&&k(i))}function n(e){if(!e||A(e))return;const t=a("smoothScroll");e.scrollIntoView({behavior:!t||v(e)?"auto":"smooth",inline:"center",block:"center"})}function v(e){if(!e||!e.parentElement)return;const t=e.parentElement;return t.scrollHeight>t.clientHeight}function A(e){const t=e.getBoundingClientRect();return t.top>=0&&t.left>=0&&t.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&t.right<=(window.innerWidth||document.documentElement.clientWidth)}function k(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)}let m={};function C(e,t){m[e]=t}function l(e){return e?m[e]:m}function u(){m={}}let b={};function $(e,t){b[e]=t}function L(e){var t;(t=b[e])==null||t.call(b)}function R(){b={}}function j(e,t,i,s){let o=l("__activeStagePosition");const h=o||i.getBoundingClientRect(),S=s.getBoundingClientRect(),x=c(e,h.x,S.x-h.x,t),p=c(e,h.y,S.y-h.y,t),T=c(e,h.width,S.width-h.width,t),d=c(e,h.height,S.height-h.height,t);o={x,y:p,width:T,height:d},X(o),C("__activeStagePosition",o)}function Q(e){if(!e)return;const t=e.getBoundingClientRect(),i={x:t.x,y:t.y,width:t.width,height:t.height};C("__activeStagePosition",i),X(i)}function le(){const e=l("__activeStagePosition"),t=l("__overlaySvg");if(!e)return;if(!t){console.warn("No stage svg found.");return}const i=window.innerWidth,s=window.innerHeight;t.setAttribute("viewBox",`0 0 ${i} ${s}`)}function de(e){const t=pe(e);document.body.appendChild(t),re(t,i=>{i.target.tagName==="path"&&L("overlayClick")}),C("__overlaySvg",t)}function X(e){const t=l("__overlaySvg");if(!t){de(e);return}const i=t.firstElementChild;if(i?.tagName!=="path")throw new Error("no path element found in stage svg");i.setAttribute("d",Z(e))}function pe(e){const t=window.innerWidth,i=window.innerHeight,s=document.createElementNS("http://www.w3.org/2000/svg","svg");s.classList.add("driver-overlay","driver-overlay-animated"),s.setAttribute("viewBox",`0 0 ${t} ${i}`),s.setAttribute("xmlSpace","preserve"),s.setAttribute("xmlnsXlink","http://www.w3.org/1999/xlink"),s.setAttribute("version","1.1"),s.setAttribute("preserveAspectRatio","xMinYMin slice"),s.style.fillRule="evenodd",s.style.clipRule="evenodd",s.style.strokeLinejoin="round",s.style.strokeMiterlimit="2",s.style.zIndex="10000",s.style.position="fixed",s.style.top="0",s.style.left="0",s.style.width="100%",s.style.height="100%";const o=document.createElementNS("http://www.w3.org/2000/svg","path");return o.setAttribute("d",Z(e)),o.style.fill=a("overlayColor")||"rgb(0,0,0)",o.style.opacity=`${a("overlayOpacity")}`,o.style.pointerEvents="auto",o.style.cursor="auto",s.appendChild(o),s}function Z(e){const t=window.innerWidth,i=window.innerHeight,s=a("stagePadding")||0,o=a("stageRadius")||0,h=e.width+s*2,S=e.height+s*2,x=Math.min(o,h/2,S/2),p=Math.floor(Math.max(x,0)),T=e.x-s+p,d=e.y-s,g=h-p*2,f=S-p*2;return`M${t},0L0,0L0,${i}L${t},${i}L${t},0Z
|
||||
M${T},${d} h${g} a${p},${p} 0 0 1 ${p},${p} v${f} a${p},${p} 0 0 1 -${p},${p} h-${g} a${p},${p} 0 0 1 -${p},-${p} v-${f} a${p},${p} 0 0 1 ${p},-${p} z`}function ce(){const e=l("__overlaySvg");e&&e.remove()}function ue(){const e=document.getElementById("driver-dummy-element");if(e)return e;let t=document.createElement("div");return t.id="driver-dummy-element",t.style.width="0",t.style.height="0",t.style.pointerEvents="none",t.style.opacity="0",t.style.position="fixed",t.style.top="50%",t.style.left="50%",document.body.appendChild(t),t}function ee(e){const{element:t}=e;let i=typeof t=="string"?document.querySelector(t):t;i||(i=ue()),he(i,e)}function me(){const e=l("__activeElement"),t=l("__activeStep");e&&(Q(e),le(),ae(e,t))}function he(e,t){const i=Date.now(),s=l("__activeStep"),o=l("__activeElement")||e,h=!o||o===e,S=e.id==="driver-dummy-element",x=o.id==="driver-dummy-element",p=a("animate"),T=t.onHighlightStarted||a("onHighlightStarted"),d=t?.onHighlighted||a("onHighlighted"),g=s?.onDeselected||a("onDeselected"),f=a(),D=l();!h&&g&&g(x?void 0:o,s,{config:f,state:D}),T&&T(S?void 0:e,t,{config:f,state:D});const B=!h&&p;let E=!1;we(),C("previousStep",s),C("previousElement",o),C("activeStep",t),C("activeElement",e);const w=()=>{if(l("__transitionCallback")!==w)return;const I=Date.now()-i,O=400-I<=400/2;t.popover&&O&&!E&&B&&(oe(e,t),E=!0),a("animate")&&I<400?j(I,400,o,e):(Q(e),d&&d(S?void 0:e,t,{config:a(),state:l()}),C("__transitionCallback",void 0),C("__previousStep",s),C("__previousElement",o),C("__activeStep",t),C("__activeElement",e)),window.requestAnimationFrame(w)};C("__transitionCallback",w),window.requestAnimationFrame(w),n(e),!B&&t.popover&&oe(e,t),o.classList.remove("driver-active-element","driver-no-interaction"),o.removeAttribute("aria-haspopup"),o.removeAttribute("aria-expanded"),o.removeAttribute("aria-controls"),a("disableActiveInteraction")&&e.classList.add("driver-no-interaction"),e.classList.add("driver-active-element"),e.setAttribute("aria-haspopup","dialog"),e.setAttribute("aria-expanded","true"),e.setAttribute("aria-controls","driver-popover-content")}function ge(){var e;(e=document.getElementById("driver-dummy-element"))==null||e.remove(),document.querySelectorAll(".driver-active-element").forEach(t=>{t.classList.remove("driver-active-element","driver-no-interaction"),t.removeAttribute("aria-haspopup"),t.removeAttribute("aria-expanded"),t.removeAttribute("aria-controls")})}function U(){const e=l("__resizeTimeout");e&&window.cancelAnimationFrame(e),C("__resizeTimeout",window.requestAnimationFrame(me))}function ve(e){var t;if(!l("isInitialized")||!(e.key==="Tab"||e.keyCode===9))return;const i=l("__activeElement"),s=(t=l("popover"))==null?void 0:t.wrapper,o=r([...s?[s]:[],...i?[i]:[]]),h=o[0],S=o[o.length-1];if(e.preventDefault(),e.shiftKey){const x=o[o.indexOf(document.activeElement)-1]||S;x?.focus()}else{const x=o[o.indexOf(document.activeElement)+1]||h;x?.focus()}}function te(e){var t;((t=a("allowKeyboardControl"))==null||t)&&(e.key==="Escape"?L("escapePress"):e.key==="ArrowRight"?L("arrowRightPress"):e.key==="ArrowLeft"&&L("arrowLeftPress"))}function re(e,t,i){const s=(o,h)=>{const S=o.target;e.contains(S)&&((!i||i(S))&&(o.preventDefault(),o.stopPropagation(),o.stopImmediatePropagation()),h?.(o))};document.addEventListener("pointerdown",s,!0),document.addEventListener("mousedown",s,!0),document.addEventListener("pointerup",s,!0),document.addEventListener("mouseup",s,!0),document.addEventListener("click",o=>{s(o,t)},!0)}function fe(){window.addEventListener("keyup",te,!1),window.addEventListener("keydown",ve,!1),window.addEventListener("resize",U),window.addEventListener("scroll",U)}function ye(){window.removeEventListener("keyup",te),window.removeEventListener("resize",U),window.removeEventListener("scroll",U)}function we(){const e=l("popover");e&&(e.wrapper.style.display="none")}function oe(e,t){var i,s;let o=l("popover");o&&document.body.removeChild(o.wrapper),o=Te(),document.body.appendChild(o.wrapper);const{title:h,description:S,showButtons:x,disableButtons:p,showProgress:T,nextBtnText:d=a("nextBtnText")||"Next →",prevBtnText:g=a("prevBtnText")||"← Previous",progressText:f=a("progressText")||"{current} of {total}"}=t.popover||{};o.nextButton.innerHTML=d,o.previousButton.innerHTML=g,o.progress.innerHTML=f,h?(o.title.innerHTML=h,o.title.style.display="block"):o.title.style.display="none",S?(o.description.innerHTML=S,o.description.style.display="block"):o.description.style.display="none";const D=x||a("showButtons"),B=T||a("showProgress")||!1,E=D?.includes("next")||D?.includes("previous")||B;o.closeButton.style.display=D.includes("close")?"block":"none",E?(o.footer.style.display="flex",o.progress.style.display=B?"block":"none",o.nextButton.style.display=D.includes("next")?"block":"none",o.previousButton.style.display=D.includes("previous")?"block":"none"):o.footer.style.display="none";const w=p||a("disableButtons")||[];w!=null&&w.includes("next")&&(o.nextButton.disabled=!0
Line truncated
|
||||
this.driver=this.driver||{},this.driver.js=(function(y){"use strict";let P={};function _(e={}){P={animate:!0,allowClose:!0,overlayOpacity:.7,smoothScroll:!1,disableActiveInteraction:!1,showProgress:!1,stagePadding:10,stageRadius:5,popoverOffset:10,showButtons:["next","previous","close"],disableButtons:[],overlayColor:"#000",...e}}function a(e){return e?P[e]:P}function c(e,t,i,s){return(e/=s/2)<1?i/2*e*e+t:-i/2*(--e*(e-2)-1)+t}function r(e){const t='a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])';return e.flatMap(i=>{const s=i.matches(t),o=Array.from(i.querySelectorAll(t));return[...s?[i]:[],...o]}).filter(i=>getComputedStyle(i).pointerEvents!=="none"&&k(i))}function n(e){if(!e||A(e))return;const t=a("smoothScroll");e.scrollIntoView({behavior:!t||v(e)?"auto":"smooth",inline:"center",block:"center"})}function v(e){if(!e||!e.parentElement)return;const t=e.parentElement;return t.scrollHeight>t.clientHeight}function A(e){const t=e.getBoundingClientRect();return t.top>=0&&t.left>=0&&t.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&t.right<=(window.innerWidth||document.documentElement.clientWidth)}function k(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)}let m={};function C(e,t){m[e]=t}function l(e){return e?m[e]:m}function u(){m={}}let b={};function $(e,t){b[e]=t}function L(e){var t;(t=b[e])==null||t.call(b)}function R(){b={}}function j(e,t,i,s){let o=l("__activeStagePosition");const h=o||i.getBoundingClientRect(),T=s.getBoundingClientRect(),x=c(e,h.x,T.x-h.x,t),p=c(e,h.y,T.y-h.y,t),S=c(e,h.width,T.width-h.width,t),d=c(e,h.height,T.height-h.height,t);o={x,y:p,width:S,height:d},X(o),C("__activeStagePosition",o)}function Q(e){if(!e)return;const t=e.getBoundingClientRect(),i={x:t.x,y:t.y,width:t.width,height:t.height};C("__activeStagePosition",i),X(i)}function le(){const e=l("__activeStagePosition"),t=l("__overlaySvg");if(!e)return;if(!t){console.warn("No stage svg found.");return}const i=window.innerWidth,s=window.innerHeight;t.setAttribute("viewBox",`0 0 ${i} ${s}`)}function de(e){const t=pe(e);document.body.appendChild(t),re(t,i=>{i.target.tagName==="path"&&L("overlayClick")}),C("__overlaySvg",t)}function X(e){const t=l("__overlaySvg");if(!t){de(e);return}const i=t.firstElementChild;if(i?.tagName!=="path")throw new Error("no path element found in stage svg");i.setAttribute("d",Z(e))}function pe(e){const t=window.innerWidth,i=window.innerHeight,s=document.createElementNS("http://www.w3.org/2000/svg","svg");s.classList.add("driver-overlay","driver-overlay-animated"),s.setAttribute("viewBox",`0 0 ${t} ${i}`),s.setAttribute("xmlSpace","preserve"),s.setAttribute("xmlnsXlink","http://www.w3.org/1999/xlink"),s.setAttribute("version","1.1"),s.setAttribute("preserveAspectRatio","xMinYMin slice"),s.style.fillRule="evenodd",s.style.clipRule="evenodd",s.style.strokeLinejoin="round",s.style.strokeMiterlimit="2",s.style.zIndex="10000",s.style.position="fixed",s.style.top="0",s.style.left="0",s.style.width="100%",s.style.height="100%";const o=document.createElementNS("http://www.w3.org/2000/svg","path");return o.setAttribute("d",Z(e)),o.style.fill=a("overlayColor")||"rgb(0,0,0)",o.style.opacity=`${a("overlayOpacity")}`,o.style.pointerEvents="auto",o.style.cursor="auto",s.appendChild(o),s}function Z(e){const t=window.innerWidth,i=window.innerHeight,s=a("stagePadding")||0,o=a("stageRadius")||0,h=e.width+s*2,T=e.height+s*2,x=Math.min(o,h/2,T/2),p=Math.floor(Math.max(x,0)),S=e.x-s+p,d=e.y-s,g=h-p*2,f=T-p*2;return`M${t},0L0,0L0,${i}L${t},${i}L${t},0Z
|
||||
M${S},${d} h${g} a${p},${p} 0 0 1 ${p},${p} v${f} a${p},${p} 0 0 1 -${p},${p} h-${g} a${p},${p} 0 0 1 -${p},-${p} v-${f} a${p},${p} 0 0 1 ${p},-${p} z`}function ce(){const e=l("__overlaySvg");e&&e.remove()}function ue(){const e=document.getElementById("driver-dummy-element");if(e)return e;let t=document.createElement("div");return t.id="driver-dummy-element",t.style.width="0",t.style.height="0",t.style.pointerEvents="none",t.style.opacity="0",t.style.position="fixed",t.style.top="50%",t.style.left="50%",document.body.appendChild(t),t}function ee(e){const{element:t}=e;let i=typeof t=="string"?document.querySelector(t):t;i||(i=ue()),he(i,e)}function me(){const e=l("__activeElement"),t=l("__activeStep");e&&(Q(e),le(),ae(e,t))}function he(e,t){const i=Date.now(),s=l("__activeStep"),o=l("__activeElement")||e,h=!o||o===e,T=e.id==="driver-dummy-element",x=o.id==="driver-dummy-element",p=a("animate"),S=t.onHighlightStarted||a("onHighlightStarted"),d=t?.onHighlighted||a("onHighlighted"),g=s?.onDeselected||a("onDeselected"),f=a(),D=l();!h&&g&&g(x?void 0:o,s,{config:f,state:D}),S&&S(T?void 0:e,t,{config:f,state:D});const B=!h&&p;let E=!1;we(),C("previousStep",s),C("previousElement",o),C("activeStep",t),C("activeElement",e);const w=()=>{if(l("__transitionCallback")!==w)return;const I=Date.now()-i,O=400-I<=400/2;t.popover&&O&&!E&&B&&(oe(e,t),E=!0),a("animate")&&I<400?j(I,400,o,e):(Q(e),d&&d(T?void 0:e,t,{config:a(),state:l()}),C("__transitionCallback",void 0),C("__previousStep",s),C("__previousElement",o),C("__activeStep",t),C("__activeElement",e)),window.requestAnimationFrame(w)};C("__transitionCallback",w),window.requestAnimationFrame(w),n(e),!B&&t.popover&&oe(e,t),o.classList.remove("driver-active-element","driver-no-interaction"),o.removeAttribute("aria-haspopup"),o.removeAttribute("aria-expanded"),o.removeAttribute("aria-controls"),a("disableActiveInteraction")&&e.classList.add("driver-no-interaction"),e.classList.add("driver-active-element"),e.setAttribute("aria-haspopup","dialog"),e.setAttribute("aria-expanded","true"),e.setAttribute("aria-controls","driver-popover-content")}function ge(){var e;(e=document.getElementById("driver-dummy-element"))==null||e.remove(),document.querySelectorAll(".driver-active-element").forEach(t=>{t.classList.remove("driver-active-element","driver-no-interaction"),t.removeAttribute("aria-haspopup"),t.removeAttribute("aria-expanded"),t.removeAttribute("aria-controls")})}function U(){const e=l("__resizeTimeout");e&&window.cancelAnimationFrame(e),C("__resizeTimeout",window.requestAnimationFrame(me))}function ve(e){var t;if(!l("isInitialized")||!(e.key==="Tab"||e.keyCode===9))return;const i=l("__activeElement"),s=(t=l("popover"))==null?void 0:t.wrapper,o=r([...s?[s]:[],...i?[i]:[]]),h=o[0],T=o[o.length-1];if(e.preventDefault(),e.shiftKey){const x=o[o.indexOf(document.activeElement)-1]||T;x?.focus()}else{const x=o[o.indexOf(document.activeElement)+1]||h;x?.focus()}}function te(e){var t;((t=a("allowKeyboardControl"))==null||t)&&(e.key==="Escape"?L("escapePress"):e.key==="ArrowRight"?L("arrowRightPress"):e.key==="ArrowLeft"&&L("arrowLeftPress"))}function re(e,t,i){const s=(o,h)=>{const T=o.target;e.contains(T)&&((!i||i(T))&&(o.preventDefault(),o.stopPropagation(),o.stopImmediatePropagation()),h?.(o))};document.addEventListener("pointerdown",s,!0),document.addEventListener("mousedown",s,!0),document.addEventListener("pointerup",s,!0),document.addEventListener("mouseup",s,!0),document.addEventListener("click",o=>{s(o,t)},!0)}function fe(){window.addEventListener("keyup",te,!1),window.addEventListener("keydown",ve,!1),window.addEventListener("resize",U),window.addEventListener("scroll",U)}function ye(){window.removeEventListener("keyup",te),window.removeEventListener("resize",U),window.removeEventListener("scroll",U)}function we(){const e=l("popover");e&&(e.wrapper.style.display="none")}function oe(e,t){var i,s;let o=l("popover");o&&document.body.removeChild(o.wrapper),o=Se(),document.body.appendChild(o.wrapper);const{title:h,description:T,showButtons:x,disableButtons:p,showProgress:S,nextBtnText:d=a("nextBtnText")||"Next →",prevBtnText:g=a("prevBtnText")||"← Previous",progressText:f=a("progressText")||"{current} of {total}"}=t.popover||{};o.nextButton.innerHTML=d,o.previousButton.innerHTML=g,o.progress.innerHTML=f,h?(o.title.innerHTML=h,o.title.style.display="block"):o.title.style.display="none",T?(o.description.innerHTML=T,o.description.style.display="block"):o.description.style.display="none";const D=x||a("showButtons"),B=S||a("showProgress")||!1,E=D?.includes("next")||D?.includes("previous")||B;o.closeButton.style.display=D.includes("close")?"block":"none",E?(o.footer.style.display="flex",o.progress.style.display=B?"block":"none",o.nextButton.style.display=D.includes("next")?"block":"none",o.previousButton.style.display=D.includes("previous")?"block":"none"):o.footer.style.display="none";const w=p||a("disableButtons")||[];w!=null&&w.includes("next")&&(o.nextButton.disabled=!0
Line truncated
|
||||
.driver-popover {
|
||||
background: ${n.backgroundColor} !important;
|
||||
color: ${n.textColor} !important;
|
||||
@@ -154,7 +154,7 @@ ${b}`)}}y.TooltipValidation={validateTooltipDefinition:a,validateTooltipDefiniti
|
||||
<p style="margin: 8px 0 0; font-size: 0.82rem; opacity: 0.8;">Activate in <strong>Admin → License</strong></p>
|
||||
</div>
|
||||
<p style="margin-top: 10px; font-size: 0.85rem; opacity: 0.7;">You can restart this tour anytime from <strong>Admin → Help Tour</strong>.</p>
|
||||
`,position:"bottom",align:"start",showButtons:["previous","close"],showProgress:!0},priority:13}];function A(){return v}function k(u){return v.find(b=>b.id===u)||null}function m(){return v.filter(u=>{if(u.condition&&typeof u.condition=="function")try{return u.condition()}catch(b){return P.logError("[TooltipDefinitions] Condition Eval",b,{function:"evaluateCondition",tooltipId:u.id}),!1}return!0})}function C(){return m().sort((b,$)=>{const L=b.priority||999,R=$.priority||999;return L-R})}function l(){return m().filter(b=>b.isNewFeature===!0).sort((b,$)=>{const L=b.priority||999,R=$.priority||999;return L-R})}y.TooltipDefinitions={TOOLTIP_DEFINITIONS:v,getTooltipDefinitions:A,getTooltipById:k,getActiveTooltips:m,getSortedTooltips:C,getNewFeatureTooltips:l},_("[TooltipDefinitions] Definitions loaded:",v.length,"tooltips")})(window),(function(y){"use strict";const P=(...a)=>{y.DASHCADDY_DEBUG&&console.log(...a)};class _{constructor(c){this.progressTracker=c,this.modal=null,this.onTemplateSelected=null,P("[DnsTemplateSelector] Module loaded")}getDnsTemplates(){return[{id:"technitium",name:"Technitium DNS Server",description:"Modern DNS server with web UI for managing private zones",icon:"\u{1F310}",difficulty:"Easy",features:["Web-based management interface","Private zone management for .sami domain","DHCP server integration","DNS-over-HTTPS and DNS-over-TLS support"],recommended:!0},{id:"bind9",name:"BIND9 DNS Server",description:"Industry-standard DNS server - powerful and flexible",icon:"\u{1F527}",difficulty:"Advanced",features:["Industry standard DNS server","Full RFC compliance","Advanced zone management","DNSSEC support"],recommended:!1},{id:"pihole",name:"Pi-hole",description:"Network-wide ad blocker with DNS capabilities",icon:"\u{1F6E1}\uFE0F",difficulty:"Intermediate",features:["Ad blocking at DNS level","Web interface for management","DHCP server included","Query logging and statistics"],recommended:!1},{id:"powerdns",name:"PowerDNS",description:"High-performance DNS server with SQL backend",icon:"\u26A1",difficulty:"Intermediate",features:["SQL database backend","RESTful API for automation","Geographic load balancing","DNSSEC support"],recommended:!1},{id:"coredns",name:"CoreDNS",description:"Cloud-native DNS server - lightweight and flexible",icon:"\u2601\uFE0F",difficulty:"Intermediate",features:["Plugin-based architecture","Kubernetes-native","Lightweight and fast","Prometheus metrics"],recommended:!1}]}showTemplateSelector(){this.modal||this.createModal(),this.populateTemplates(),this.modal.style.display="flex",document.body.style.overflow="hidden"}createModal(){const c=document.createElement("div");c.id="dns-template-modal",c.className="dns-template-modal",c.innerHTML=`
|
||||
`,position:"bottom",align:"start",showButtons:["previous","close"],showProgress:!0},priority:13}];function A(){return v}function k(u){return v.find(b=>b.id===u)||null}function m(){return v.filter(u=>{if(u.condition&&typeof u.condition=="function")try{return u.condition()}catch(b){return P.logError("[TooltipDefinitions] Condition Eval",b,{function:"evaluateCondition",tooltipId:u.id}),!1}return!0})}function C(){return m().sort((b,$)=>{const L=b.priority||999,R=$.priority||999;return L-R})}function l(){return m().filter(b=>b.isNewFeature===!0).sort((b,$)=>{const L=b.priority||999,R=$.priority||999;return L-R})}y.TooltipDefinitions={TOOLTIP_DEFINITIONS:v,getTooltipDefinitions:A,getTooltipById:k,getActiveTooltips:m,getSortedTooltips:C,getNewFeatureTooltips:l},_("[TooltipDefinitions] Definitions loaded:",v.length,"tooltips")})(window),(function(y){"use strict";const P=(...a)=>{y.DASHCADDY_DEBUG&&console.log(...a)};class _{constructor(c){this.progressTracker=c,this.modal=null,this.onTemplateSelected=null,P("[DnsTemplateSelector] Module loaded")}getDnsTemplates(){return[{id:"technitium",name:"Technitium DNS Server",description:"Modern DNS server with web UI for managing private zones",icon:"\u{1F310}",difficulty:"Easy",features:["Web-based management interface","Private zone management for .sami domain","DHCP server integration","DNS-over-HTTPS and DNS-over-TLS support"],recommended:!0},{id:"bind9",name:"BIND9 DNS Server",description:"Industry-standard DNS server - powerful and flexible",icon:"\u{1F527}",difficulty:"Advanced",features:["Industry standard DNS server","Full RFC compliance","Advanced zone management","DNSSEC support"],recommended:!1},{id:"pihole",name:"Pi-hole",description:"Network-wide ad blocker with DNS capabilities",icon:"\u{1F6E1}\uFE0F",difficulty:"Intermediate",features:["Ad blocking at DNS level","Web interface for management","DHCP server included","Query logging and statistics"],recommended:!1},{id:"powerdns",name:"PowerDNS",description:"High-performance DNS server with SQL backend",icon:"\u26A1",difficulty:"Intermediate",features:["SQL database backend","RESTful API for automation","Geographic load balancing","DNSSEC support"],recommended:!1},{id:"coredns",name:"CoreDNS",description:"Cloud-native DNS server - lightweight and flexible",icon:"\u2601\uFE0F",difficulty:"Intermediate",features:["Plugin-based architecture","Kubernetes-native","Lightweight and fast","Prometheus metrics"],recommended:!1},{id:"cloudflare",name:"Cloudflare DNS",description:"Managed DNS with API access \u2014 no self-hosting needed",icon:"\u{1F536}",difficulty:"Easy",features:["Fully managed, no server needed","API for automated record management","Global anycast network","Free tier available"],recommended:!1,providerId:"cloudflare"},{id:"external",name:"External / Manual DNS",description:"Use your own DNS provider (cPanel, Route53, etc.)",icon:"\u{1F517}",difficulty:"Easy",features:["Works with any DNS provider","DashCaddy shows you what records to create","Propagation checking still works","No API credentials needed"],recommended:!1,providerId:"manual"}]}showTemplateSelector(){this.modal||this.createModal(),this.populateTemplates(),this.modal.style.display="flex",document.body.style.overflow="hidden"}createModal(){const c=document.createElement("div");c.id="dns-template-modal",c.className="dns-template-modal",c.innerHTML=`
|
||||
<div class="dns-template-modal-content">
|
||||
<div class="dns-template-header">
|
||||
<h2>\u{1F310} Choose a DNS Server</h2>
|
||||
|
||||
+63
-3
@@ -43,6 +43,59 @@
|
||||
<input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]">
|
||||
</div>
|
||||
<div class="totp-error" id="totp-error"></div>
|
||||
<div class="totp-recovery-link" id="totp-recovery-link" style="display: none;">
|
||||
<a href="#" id="totp-show-recovery">Lost access? Recover with saved Base32 key →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOTP Recovery Panel (hidden by default, shown via "Lost access?" link on overlay) -->
|
||||
<div id="totp-recovery-panel" class="weather-modal" style="display: none;">
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 540px;">
|
||||
<h3 style="margin: 0 0 12px; font-size: 1.1rem;">Recover TOTP Access</h3>
|
||||
<div id="totp-recovery-status" style="margin-bottom: 12px; padding: 10px 14px; border-radius: 6px; border: 1px solid var(--border); font-size: 0.85rem; line-height: 1.4;"></div>
|
||||
|
||||
<!-- Path A: Paste saved Base32 secret -->
|
||||
<div id="totp-recovery-import">
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 8px;">
|
||||
Paste the Base32 secret you saved when you first set up TOTP (e.g. <code>JBSWY3DPEHPK3PXP</code>).
|
||||
If you don't have it, you'll need to SSH into the server to rotate the encryption key.
|
||||
</p>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
||||
<input type="text" id="totp-recovery-secret" placeholder="Paste your Base32 key"
|
||||
autocomplete="off" spellcheck="false"
|
||||
style="flex: 1; padding: 10px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.9rem; font-family: monospace; letter-spacing: 1px; text-transform: uppercase;" />
|
||||
<button id="totp-recovery-submit"
|
||||
style="padding: 10px 16px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; white-space: nowrap;">
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
<div id="totp-recovery-error" style="color: var(--bad-fg, #ff9aa3); font-size: 0.8rem; min-height: 1.2em; margin-top: 6px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Path B: After successful import, ask user to verify with code -->
|
||||
<div id="totp-recovery-verify" style="display: none;">
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 8px;">
|
||||
Secret accepted. Add it to your authenticator app and enter a 6-digit code to confirm.
|
||||
</p>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
||||
<input type="text" id="totp-recovery-code" maxlength="6" inputmode="numeric" pattern="[0-9]{6}"
|
||||
placeholder="000000" autocomplete="one-time-code"
|
||||
style="flex: 1; padding: 10px; text-align: center; font-size: 1.2rem; font-family: 'Sami Grotesk', monospace; letter-spacing: 4px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px;" />
|
||||
<button id="totp-recovery-confirm"
|
||||
style="padding: 10px 16px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; white-space: nowrap;">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
<div id="totp-recovery-confirm-error" style="color: var(--bad-fg, #ff9aa3); font-size: 0.8rem; min-height: 1.2em; margin-top: 6px;"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 14px; text-align: right;">
|
||||
<button id="totp-recovery-close"
|
||||
style="padding: 8px 18px; background: transparent; color: var(--muted); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 0.85rem;">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -199,7 +252,8 @@
|
||||
<div class="btn-row"><!-- No button for Internet --></div>
|
||||
</div>
|
||||
|
||||
<div class="card" data-app="auth" data-status="off" id="auth-card">
|
||||
<div class="card" data-app="auth" data-status="off" id="auth-card"
|
||||
title="Two-factor authentication (TOTP). On first setup, save the Base32 secret — it's the only way to recover if you ever lose your authenticator.">
|
||||
<span id="auth-dot" class="dot bad at-bl"></span>
|
||||
<div class="row">
|
||||
<div class="logo-wrap">
|
||||
@@ -393,8 +447,14 @@
|
||||
<!-- DNS Server Configuration -->
|
||||
<div>
|
||||
<label class="form-label-accent">
|
||||
🗂️ DNS Server (Technitium)
|
||||
🗂️ DNS Provider
|
||||
</label>
|
||||
<select id="setup-dns-provider" class="form-input-lg" style="margin-bottom: 12px;">
|
||||
<option value="technitium">Technitium DNS (recommended)</option>
|
||||
<option value="cloudflare">Cloudflare DNS</option>
|
||||
<option value="rfc2136">RFC 2136 (BIND / PowerDNS / other)</option>
|
||||
<option value="manual">Manual / External DNS</option>
|
||||
</select>
|
||||
<div style="display: grid; grid-template-columns: 1fr auto; gap: 8px;">
|
||||
<input type="text" id="setup-dns-ip" value="" placeholder="DNS server IP"
|
||||
style="padding: 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem;" />
|
||||
@@ -409,7 +469,7 @@
|
||||
<!-- DNS Admin Token -->
|
||||
<div>
|
||||
<label class="form-label-accent">
|
||||
🔑 Technitium Admin Token
|
||||
🔑 DNS Admin Token / API Key
|
||||
</label>
|
||||
<input type="password" id="setup-dns-token" placeholder="Paste your admin token here"
|
||||
class="form-input-lg" />
|
||||
|
||||
@@ -95,8 +95,6 @@
|
||||
const card = el('div', 'card');
|
||||
card.setAttribute('data-app', s.id);
|
||||
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);
|
||||
|
||||
const dot = el('span', 'dot bad at-bl'); dot.id = 'dot-' + s.id + '-grid'; card.appendChild(dot);
|
||||
@@ -158,16 +156,6 @@
|
||||
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'));
|
||||
|
||||
const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill);
|
||||
@@ -294,9 +282,6 @@
|
||||
|
||||
// Group recipe cards visually after grid is built
|
||||
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) {
|
||||
|
||||
@@ -59,13 +59,11 @@
|
||||
}
|
||||
_dashboardInitialized = true;
|
||||
await window.loadServices();
|
||||
await loadTemplateCategories();
|
||||
window.buildGrid();
|
||||
animateTopCards();
|
||||
window.refreshAll();
|
||||
setInterval(window.refreshAll, DC.POLL.DASHBOARD);
|
||||
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)
|
||||
if (typeof window._updateAuthCard === 'function') {
|
||||
try {
|
||||
@@ -202,55 +200,6 @@
|
||||
window.loadCustomServices = loadCustomServices;
|
||||
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
|
||||
(async () => {
|
||||
try {
|
||||
|
||||
@@ -262,7 +262,6 @@
|
||||
const proxyIp = document.getElementById('external-proxy-ip').value.trim() || SITE.dnsIp || 'localhost';
|
||||
const preserveHost = document.getElementById('external-preserve-host').checked;
|
||||
const followRedirects = document.getElementById('external-follow-redirects').checked;
|
||||
const category = document.getElementById('external-service-category')?.value || '';
|
||||
|
||||
if (!name || !externalUrl) {
|
||||
showNotification('Please fill in Name and External URL', 'warning');
|
||||
@@ -342,8 +341,6 @@
|
||||
isExternal: true,
|
||||
isCustom: true
|
||||
};
|
||||
// Only attach category if user actually picked one
|
||||
if (category) newService.category = category;
|
||||
|
||||
window.APPS.push(newService);
|
||||
results.dashboard = true;
|
||||
@@ -460,13 +457,6 @@
|
||||
const healthCheck = document.getElementById('health-check-input')?.value || '';
|
||||
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');
|
||||
|
||||
if (!name || !port || !ip) {
|
||||
@@ -535,8 +525,6 @@
|
||||
logo: logo || `/assets/${subdomain}.png`,
|
||||
tailscaleOnly: tailscaleOnly || false
|
||||
};
|
||||
// Only include category if user actually picked one
|
||||
if (category) serviceConfig.category = category;
|
||||
|
||||
await window.addServiceToConfig(serviceConfig);
|
||||
results.dashboard = true;
|
||||
|
||||
@@ -19,16 +19,6 @@
|
||||
document.getElementById('edit-tailscale-only').checked = service.tailscaleOnly || false;
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -46,7 +36,6 @@
|
||||
const newIp = document.getElementById('edit-ip').value.trim() || 'localhost';
|
||||
const tailscaleOnly = document.getElementById('edit-tailscale-only').checked;
|
||||
const newLogo = document.getElementById('edit-logo-url').value.trim();
|
||||
const newCategory = document.getElementById('edit-service-category')?.value || '';
|
||||
|
||||
if (!newSubdomain) {
|
||||
showNotification('Subdomain is required', 'warning');
|
||||
@@ -62,7 +51,6 @@
|
||||
if (newIp !== currentEditService.ip) changes.push('ip');
|
||||
if (tailscaleOnly !== (currentEditService.tailscaleOnly || false)) changes.push('tailscale');
|
||||
if (newLogo && newLogo !== currentEditService.logo) changes.push('logo');
|
||||
if (newCategory !== (currentEditService.category || '')) changes.push('category');
|
||||
|
||||
if (changes.length === 0) {
|
||||
closeServiceEditModal();
|
||||
@@ -84,8 +72,7 @@
|
||||
port: newPort || currentEditService.port,
|
||||
ip: newIp,
|
||||
tailscaleOnly,
|
||||
logo: newLogo || undefined,
|
||||
category: newCategory
|
||||
logo: newLogo || undefined
|
||||
})
|
||||
});
|
||||
|
||||
@@ -104,8 +91,7 @@
|
||||
port: newPort || window.APPS[appIndex].port,
|
||||
ip: newIp,
|
||||
tailscaleOnly,
|
||||
logo: newLogo || window.APPS[appIndex].logo,
|
||||
category: newCategory || undefined
|
||||
logo: newLogo || window.APPS[appIndex].logo
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -187,9 +187,6 @@
|
||||
name: serviceConfig.name,
|
||||
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 {
|
||||
const response = await secureFetch('/api/v1/services', {
|
||||
|
||||
@@ -82,16 +82,6 @@
|
||||
Enter a URL or upload an image file (PNG, JPG, SVG)
|
||||
</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 class="weather-modal-buttons" style="margin-top: 24px;">
|
||||
@@ -249,15 +239,6 @@
|
||||
Reload Caddy after adding
|
||||
</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;" />
|
||||
|
||||
<div class="grid-2col">
|
||||
@@ -345,14 +326,6 @@
|
||||
Follow Redirects
|
||||
</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>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -2,50 +2,11 @@
|
||||
(function() {
|
||||
const searchInput = document.getElementById('service-filter-search');
|
||||
const statusSelect = document.getElementById('service-filter-status');
|
||||
const categorySelect = document.getElementById('service-filter-category');
|
||||
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() {
|
||||
refreshCategoryDropdown();
|
||||
const query = searchInput.value.toLowerCase().trim();
|
||||
const statusFilter = statusSelect.value; // 'all', 'on', or 'off'
|
||||
const categoryFilter = categorySelect ? categorySelect.value : 'all';
|
||||
|
||||
const cards = document.querySelectorAll('#cards .card');
|
||||
let visibleCount = 0;
|
||||
@@ -54,13 +15,11 @@
|
||||
const name = card.querySelector('.name')?.textContent?.toLowerCase() || '';
|
||||
const app = card.dataset.app?.toLowerCase() || '';
|
||||
const status = card.dataset.status || 'off'; // 'on' or 'off'
|
||||
const category = card.dataset.category || '';
|
||||
|
||||
const matchesSearch = !query || name.includes(query) || app.includes(query);
|
||||
const matchesStatus = statusFilter === 'all' || status === statusFilter;
|
||||
const matchesCategory = categoryFilter === 'all' || category === categoryFilter;
|
||||
|
||||
if (matchesSearch && matchesStatus && matchesCategory) {
|
||||
if (matchesSearch && matchesStatus) {
|
||||
card.style.display = '';
|
||||
visibleCount++;
|
||||
} else {
|
||||
@@ -85,7 +44,6 @@
|
||||
|
||||
searchInput?.addEventListener('input', debounce(updateFilter, 200));
|
||||
statusSelect?.addEventListener('change', updateFilter);
|
||||
categorySelect?.addEventListener('change', updateFilter);
|
||||
|
||||
// Initial count on page load
|
||||
if (document.readyState === 'loading') {
|
||||
@@ -94,7 +52,6 @@
|
||||
setTimeout(updateFilter, 500);
|
||||
}
|
||||
|
||||
// Expose for external triggers (called after buildGrid to repopulate categories)
|
||||
// Expose for external triggers
|
||||
window.refreshServiceFilter = updateFilter;
|
||||
window.refreshCategoryDropdown = refreshCategoryDropdown;
|
||||
})();
|
||||
@@ -21,6 +21,13 @@
|
||||
const firstInput = overlay.querySelector('.totp-digits input');
|
||||
if (firstInput) setTimeout(() => firstInput.focus(), 100);
|
||||
}
|
||||
// Refresh the "Lost access?" recovery link visibility based on server state.
|
||||
// Hides itself if TOTP is healthy; shows if unreadable/corrupt. The user
|
||||
// can still click it even when healthy — but the panel will explain there's
|
||||
// no recovery needed. Cheaper than gating it.
|
||||
if (typeof window._refreshRecoveryLink === 'function') {
|
||||
window._refreshRecoveryLink();
|
||||
}
|
||||
}
|
||||
|
||||
function hideTotpOverlay() {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// ===== TOTP RECOVERY FLOW =====
|
||||
// Public, unauthenticated recovery path for users who can't log in.
|
||||
// Designed for the "encryption key rotated and lost my authenticator" case.
|
||||
// The flow is:
|
||||
//
|
||||
// 1. On TOTP overlay show, call /api/v1/totp/recovery-info (public).
|
||||
// If status === 'unreadable', show the "Lost access?" link on the overlay.
|
||||
// 2. User clicks link → opens recovery panel.
|
||||
// 3. User pastes Base32 secret → POST /api/v1/totp/setup with {secret: ...}.
|
||||
// Backend stores as totp.pending_secret (encrypted with current key).
|
||||
// 4. Panel switches to "verify" mode. User enters a code from the
|
||||
// newly-added authenticator entry. POST /api/v1/totp/verify-setup
|
||||
// promotes pending → active and starts a session.
|
||||
// 5. hideTotpOverlay() and initializeDashboard() — same as normal login.
|
||||
//
|
||||
// The recovery flow never requires the user to be logged in. It does require
|
||||
// them to have their Base32 secret saved (e.g. password manager, screenshot,
|
||||
// the "Download backup file" we offer at setup time — see totp-settings.js).
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ── Helpers ──
|
||||
async function fetchRecoveryInfo() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/totp/recovery-info', { cache: 'no-store' });
|
||||
return await r.json();
|
||||
} catch (e) {
|
||||
return { success: false, status: 'unknown', hint: 'Could not contact server' };
|
||||
}
|
||||
}
|
||||
|
||||
function showRecoveryLink(show) {
|
||||
const link = document.getElementById('totp-recovery-link');
|
||||
if (link) link.style.display = show ? '' : 'none';
|
||||
}
|
||||
|
||||
function openRecoveryPanel() {
|
||||
const panel = document.getElementById('totp-recovery-panel');
|
||||
if (panel) panel.style.display = '';
|
||||
const statusEl = document.getElementById('totp-recovery-status');
|
||||
const importEl = document.getElementById('totp-recovery-import');
|
||||
const verifyEl = document.getElementById('totp-recovery-verify');
|
||||
if (importEl) importEl.style.display = '';
|
||||
if (verifyEl) verifyEl.style.display = 'none';
|
||||
// Reset state
|
||||
document.getElementById('totp-recovery-error').textContent = '';
|
||||
document.getElementById('totp-recovery-confirm-error').textContent = '';
|
||||
document.getElementById('totp-recovery-secret').value = '';
|
||||
document.getElementById('totp-recovery-code').value = '';
|
||||
// Show current status
|
||||
fetchRecoveryInfo().then(info => {
|
||||
statusEl.textContent = info.hint || '';
|
||||
// Color-code the status banner
|
||||
if (info.status === 'healthy') {
|
||||
statusEl.style.borderColor = 'var(--ok-fg, #7ef2ff)';
|
||||
} else if (info.status === 'unreadable') {
|
||||
statusEl.style.borderColor = 'var(--bad-fg, #ff9aa3)';
|
||||
statusEl.style.background = 'color-mix(in srgb, var(--bad-fg) 6%, transparent)';
|
||||
} else if (info.status === 'not_configured') {
|
||||
statusEl.style.borderColor = 'var(--muted)';
|
||||
} else {
|
||||
statusEl.style.borderColor = 'var(--border)';
|
||||
}
|
||||
});
|
||||
setTimeout(() => {
|
||||
document.getElementById('totp-recovery-secret')?.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function closeRecoveryPanel() {
|
||||
const panel = document.getElementById('totp-recovery-panel');
|
||||
if (panel) panel.style.display = 'none';
|
||||
}
|
||||
|
||||
async function submitRecoverySecret() {
|
||||
const secret = document.getElementById('totp-recovery-secret').value.trim();
|
||||
const errorEl = document.getElementById('totp-recovery-error');
|
||||
errorEl.textContent = '';
|
||||
|
||||
if (!secret) {
|
||||
errorEl.textContent = 'Paste your Base32 key first';
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Za-z2-7\s]+=*$/.test(secret)) {
|
||||
errorEl.textContent = 'Invalid Base32 format — should be letters A-Z and digits 2-7 only';
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /api/v1/totp/setup with {secret} — backend stores as pending
|
||||
try {
|
||||
const r = await fetch('/api/v1/totp/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ secret })
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok || !data.success) {
|
||||
errorEl.textContent = data.error || data.message || 'Restore failed';
|
||||
return;
|
||||
}
|
||||
// Switch panel to verify mode
|
||||
document.getElementById('totp-recovery-import').style.display = 'none';
|
||||
document.getElementById('totp-recovery-verify').style.display = '';
|
||||
setTimeout(() => document.getElementById('totp-recovery-code')?.focus(), 100);
|
||||
} catch (e) {
|
||||
errorEl.textContent = 'Network error — try again';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRecoveryCode() {
|
||||
const code = document.getElementById('totp-recovery-code').value.trim();
|
||||
const errorEl = document.getElementById('totp-recovery-confirm-error');
|
||||
errorEl.textContent = '';
|
||||
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
errorEl.textContent = 'Enter a 6-digit code';
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /api/v1/totp/verify-setup — promotes pending → active + starts session
|
||||
try {
|
||||
const r = await fetch('/api/v1/totp/verify-setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok || !data.success) {
|
||||
errorEl.textContent = data.error || data.message || 'Invalid code';
|
||||
document.getElementById('totp-recovery-code').value = '';
|
||||
document.getElementById('totp-recovery-code')?.focus();
|
||||
return;
|
||||
}
|
||||
// Success — hide everything and initialize dashboard
|
||||
closeRecoveryPanel();
|
||||
const overlay = document.getElementById('totp-overlay');
|
||||
if (overlay) overlay.classList.remove('show');
|
||||
if (typeof window.initializeDashboard === 'function') {
|
||||
window.initializeDashboard();
|
||||
}
|
||||
} catch (e) {
|
||||
errorEl.textContent = 'Network error — try again';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wire up handlers ──
|
||||
document.getElementById('totp-show-recovery')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
openRecoveryPanel();
|
||||
});
|
||||
document.getElementById('totp-recovery-close')?.addEventListener('click', closeRecoveryPanel);
|
||||
document.getElementById('totp-recovery-submit')?.addEventListener('click', submitRecoverySecret);
|
||||
document.getElementById('totp-recovery-confirm')?.addEventListener('click', submitRecoveryCode);
|
||||
|
||||
// Enter key submits in secret field
|
||||
document.getElementById('totp-recovery-secret')?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); submitRecoverySecret(); }
|
||||
});
|
||||
// Enter key submits in code field
|
||||
document.getElementById('totp-recovery-code')?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); submitRecoveryCode(); }
|
||||
});
|
||||
|
||||
// ── Public API ──
|
||||
// Called by totp-auth.js after showing the overlay, so we can decide whether
|
||||
// to show the recovery link. We do this with a public endpoint that doesn't
|
||||
// require auth — perfect for the locked-out state.
|
||||
window._refreshRecoveryLink = async function() {
|
||||
const info = await fetchRecoveryInfo();
|
||||
// Show the link in any non-healthy state (unreadable / corrupt / unknown).
|
||||
// The hint inside the panel tells the user what the actual issue is.
|
||||
if (info && info.success && info.status && info.status !== 'healthy') {
|
||||
showRecoveryLink(true);
|
||||
} else {
|
||||
showRecoveryLink(false);
|
||||
}
|
||||
return info;
|
||||
};
|
||||
})();
|
||||
@@ -38,11 +38,25 @@
|
||||
<div id="totp-qr-section" style="display: none;">
|
||||
<!-- Manual Key (primary - for WinAuth/desktop authenticators) -->
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 8px;">Copy this key into your authenticator app:</p>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 16px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<code id="totp-manual-key" style="flex: 1; display: block; padding: 12px; background: var(--bg, #0b0f1a); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem; font-family: 'Sami Grotesk', monospace; letter-spacing: 2px; word-break: break-all; user-select: all; color: var(--fg);"></code>
|
||||
<button id="totp-copy-key" style="padding: 10px 14px; background: var(--card-base); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 1rem; white-space: nowrap; color: var(--fg);" title="Copy to clipboard">📋</button>
|
||||
</div>
|
||||
|
||||
<!-- Download backup file (recovery aid) -->
|
||||
<div style="margin-bottom: 16px; padding: 10px 12px; background: var(--bg, #0b0f1a); border: 1px solid var(--border); border-radius: 6px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 0.8rem; color: var(--muted); flex: 1;">
|
||||
<strong style="color: var(--fg);">Save a backup file</strong> — if you ever lose your authenticator,
|
||||
this is the only way to recover without SSH access to the server.
|
||||
</span>
|
||||
<button id="totp-download-backup" type="button"
|
||||
style="padding: 8px 14px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; white-space: nowrap;">
|
||||
⬇ Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code (secondary - for mobile apps) -->
|
||||
<details class="mb-16">
|
||||
<summary style="cursor: pointer; color: var(--muted); font-size: 0.8rem;">Show QR code (for mobile authenticator apps)</summary>
|
||||
@@ -119,7 +133,13 @@
|
||||
statusBanner.style.background = 'color-mix(in srgb, var(--ok-fg) 8%, transparent)';
|
||||
statusText.textContent = 'TOTP is active';
|
||||
statusText.style.color = 'var(--ok-fg, #7ef2ff)';
|
||||
setupSection.style.display = 'none';
|
||||
// Keep the setup section visible (collapsed) so the "Import existing
|
||||
// secret" option is always reachable — users may need to re-enroll
|
||||
// their authenticator with the same secret from a backup file.
|
||||
setupSection.style.display = 'block';
|
||||
const setupBtn = document.getElementById('totp-setup-btn');
|
||||
if (setupBtn) setupBtn.textContent = 'Generate New Secret';
|
||||
// Hide the QR section by default in the active state — setupBtn click shows it
|
||||
qrSection.style.display = 'none';
|
||||
durationSection.style.display = 'block';
|
||||
disableSection.style.display = 'block';
|
||||
@@ -233,6 +253,41 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Download backup file — plain JSON so it round-trips through any password
|
||||
// manager, cloud backup, or printed paper. The secret IS recoverable plaintext
|
||||
// (that's the whole point of the backup), so warn the user and rely on
|
||||
// them to keep it safe.
|
||||
document.getElementById('totp-download-backup')?.addEventListener('click', () => {
|
||||
const secret = document.getElementById('totp-manual-key').textContent.trim();
|
||||
if (!secret) return;
|
||||
const payload = {
|
||||
service: 'DashCaddy',
|
||||
type: 'totp-secret',
|
||||
secret: secret,
|
||||
issuer: 'DashCaddy',
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
issued: new Date().toISOString(),
|
||||
// Recovery instructions baked into the file so a year from now the
|
||||
// user (or their future self) knows what this file is and how to use it.
|
||||
recovery_url: `${window.location.origin}/ (login screen → "Lost access?")`,
|
||||
note: 'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `dashcaddy-totp-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
const btn = document.getElementById('totp-download-backup');
|
||||
btn.textContent = '✅ Saved';
|
||||
setTimeout(() => { btn.textContent = '⬇ Download'; }, 2000);
|
||||
});
|
||||
|
||||
// Confirm setup
|
||||
document.getElementById('totp-confirm-setup')?.addEventListener('click', async () => {
|
||||
const code = document.getElementById('totp-setup-code').value;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'dashcaddy-shell-43a872cc40';
|
||||
const CACHE = 'dashcaddy-shell-f6673e7190';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
Reference in new issue
Block a user