# Conflicts:
#	status/dist/features.js
This commit is contained in:
Sami
2026-05-16 22:52:07 -07:00
33 changed files with 1785 additions and 476 deletions
+24 -34
View File
@@ -49,7 +49,7 @@ class SelfUpdater extends EventEmitter {
// hostUpdatesDir is the HOST path that maps to updatesDir inside the container.
// Used when writing trigger.json so the host-side script can find staging files.
hostUpdatesDir: options.hostUpdatesDir || (platformPaths.isWindows ? options.updatesDir || DEFAULTS.UPDATES_DIR : '/opt/dashcaddy/updates'),
apiSourceDir: options.apiSourceDir || process.env.DASHCADDY_API_SOURCE_DIR || DEFAULTS.API_SOURCE_DIR,
apiSourceDir: options.apiSourceDir || DEFAULTS.API_SOURCE_DIR,
frontendDir: options.frontendDir || DEFAULTS.FRONTEND_DIR,
maxBackups: parseInt(options.maxBackups || DEFAULTS.MAX_BACKUPS, 10),
channel: options.channel || process.env.DASHCADDY_UPDATE_CHANNEL || DEFAULTS.CHANNEL,
@@ -311,15 +311,25 @@ class SelfUpdater extends EventEmitter {
// Delete the result file so we don't process it again
await fsp.unlink(resultPath).catch(() => {});
// Update history
// Update the matching history entry, preferring the newest pending item
// for the same target version. Fall back to the newest pending item if
// older result files lack enough metadata to match more precisely.
const history = this.getUpdateHistory();
const updated = history.filter(h => h.status === 'pending');
if (updated.length > 0) {
for (const pending of updated) {
pending.status = result.success ? 'success' : 'rolled-back';
pending.duration = result.duration;
if (result.error) pending.error = result.error;
}
const pendingIndex = history.findIndex(
h => h.status === 'pending' && (!result.version || h.version === result.version)
);
const fallbackIndex = pendingIndex === -1
? history.findIndex(h => h.status === 'pending')
: -1;
const historyIndex = pendingIndex !== -1 ? pendingIndex : fallbackIndex;
if (historyIndex !== -1) {
const pending = history[historyIndex];
pending.status = result.success ? 'success' : 'rolled-back';
pending.duration = result.duration;
if (result.error) pending.error = result.error;
if (result.version) pending.version = result.version;
if (result.timestamp) pending.completedAt = result.timestamp;
this._saveHistory(history);
}
@@ -400,17 +410,10 @@ class SelfUpdater extends EventEmitter {
async _autoCheckAndApply() {
try {
const result = await this.checkForUpdate();
if (!result.available || !result.remote) return;
// Belt-and-suspenders: never auto-apply a same-version update. A bug here
// creates an infinite rebuild loop (each fresh container has commit='unknown'
// so it keeps comparing as different from remote forever).
if (this._compareVersions(result.local.version, result.remote.version) >= 0) {
console.log('[SelfUpdater] Skipping auto-apply: local %s is not older than remote %s',
result.local.version, result.remote.version);
return;
if (result.available && result.remote) {
console.log('[SelfUpdater] Update available: %s → %s', result.local.version, result.remote.version);
await this.applyUpdate(result.remote);
}
console.log('[SelfUpdater] Update available: %s → %s', result.local.version, result.remote.version);
await this.applyUpdate(result.remote);
} catch (e) {
console.error('[SelfUpdater] Auto-update error:', e.message);
}
@@ -503,24 +506,11 @@ class SelfUpdater extends EventEmitter {
const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version);
if (versionCompare < 0) return true;
if (versionCompare > 0) return false;
// Same version — only flag as newer if BOTH commits are known and differ.
// If local.commit is missing or 'unknown' (Dockerfile default when no build arg
// is passed), we can't distinguish builds, so trust the version number and
// treat them as equivalent. Otherwise the updater loops forever applying
// the same version because every fresh container build has commit='unknown'.
const localCommit = this._normalizeCommit(local.commit);
const remoteCommit = this._normalizeCommit(remote.commit);
if (localCommit && remoteCommit && localCommit !== remoteCommit) return true;
// Same version — check commit hash
if (remote.commit && local.commit && remote.commit !== local.commit) return true;
return false;
}
_normalizeCommit(value) {
if (!value) return null;
const str = String(value).trim().toLowerCase();
if (!str || str === 'unknown' || str === 'null' || str === 'undefined') return null;
return str;
}
_compareVersions(a, b) {
const av = String(a || '0.0.0').split('.').map(part => parseInt(part, 10) || 0);
const bv = String(b || '0.0.0').split('.').map(part => parseInt(part, 10) || 0);
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
a8a670311c60172ef38098819436a6ff081e5379da9e3ab18a291c74a1ea4f5c latest.tar.gz
+9
View File
@@ -0,0 +1,9 @@
{
"version": "1.2.0",
"commit": "a216dd8",
"channel": "stable",
"url": "https://get.dashcaddy.net/release/latest.tar.gz",
"sha256": "a8a670311c60172ef38098819436a6ff081e5379da9e3ab18a291c74a1ea4f5c",
"publishedAt": "2026-05-06T23:06:32Z",
"changelog": "Release published via scripts/publish-release.sh"
}
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
STATUS_DIR="${ROOT_DIR}/status"
API_DIR="${ROOT_DIR}/dashcaddy-api"
log() { echo "[prepare-release] $*"; }
fail() { echo "[prepare-release] ERROR: $*" >&2; exit 1; }
[[ -f "${STATUS_DIR}/package.json" ]] || fail "Missing status/package.json"
[[ -f "${API_DIR}/package.json" ]] || fail "Missing dashcaddy-api/package.json"
log "Building dashboard frontend..."
cd "${STATUS_DIR}"
npm run build
log "Verifying CSP hash present in status/index.html..."
grep -q "Content-Security-Policy" "${STATUS_DIR}/index.html" || fail "Missing CSP meta tag"
grep -q "sha256-" "${STATUS_DIR}/index.html" || fail "Missing CSP script hash"
grep -q "license-topbar-version" "${STATUS_DIR}/index.html" || fail "Expected version-chip markup missing"
log "Verifying API version metadata..."
node -e "const fs=require('fs'); const pkg=JSON.parse(fs.readFileSync('${API_DIR}/package.json','utf8')); if(!pkg.version) process.exit(1); console.log('[prepare-release] API version', pkg.version);"
log "Release prep complete. Package from repo root after this step."
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
API_DIR="${ROOT_DIR}/dashcaddy-api"
STATUS_DIR="${ROOT_DIR}/status"
PREP_SCRIPT="${ROOT_DIR}/scripts/prepare-release.sh"
OUT_DIR="${1:-${ROOT_DIR}/release}"
CHANNEL="${CHANNEL:-stable}"
BASE_URL="${BASE_URL:-https://get.dashcaddy.net/release}"
CHANGELOG_FILE="${CHANGELOG_FILE:-}"
log() { echo "[publish-release] $*"; }
fail() { echo "[publish-release] ERROR: $*" >&2; exit 1; }
[[ -x "$PREP_SCRIPT" ]] || fail "Missing prepare script: $PREP_SCRIPT"
[[ -f "${API_DIR}/package.json" ]] || fail "Missing API package.json"
VERSION="$(node -pe "require('${API_DIR}/package.json').version")"
COMMIT="$(git -C "${ROOT_DIR}" rev-parse --short HEAD)"
TARBALL_NAME="latest.tar.gz"
VERSION_JSON="version.json"
SHA256_FILE="latest.tar.gz.sha256"
mkdir -p "$OUT_DIR"
rm -f "${OUT_DIR:?}/${TARBALL_NAME}" "${OUT_DIR}/${VERSION_JSON}" "${OUT_DIR}/${SHA256_FILE}"
log "Preparing release inputs..."
"$PREP_SCRIPT"
TMP_DIR="$(mktemp -d)"
cleanup() { rm -rf "$TMP_DIR"; }
trap cleanup EXIT
STAGE_DIR="${TMP_DIR}/dashcaddy"
mkdir -p "$STAGE_DIR"
log "Staging API and dashboard files..."
cp -a "${API_DIR}" "$STAGE_DIR/dashcaddy-api"
cp -a "${STATUS_DIR}" "$STAGE_DIR/status"
cp -a "${ROOT_DIR}/ca" "$STAGE_DIR/ca" 2>/dev/null || true
cp -a "${ROOT_DIR}/dashcaddy-installer" "$STAGE_DIR/dashcaddy-installer" 2>/dev/null || true
cp -a "${ROOT_DIR}/README.md" "$STAGE_DIR/README.md" 2>/dev/null || true
find "$STAGE_DIR" \( -name node_modules -o -name .git -o -name coverage -o -name .nyc_output \) -prune -exec rm -rf {} +
find "$STAGE_DIR" -type f \( -name '*.test.js' -o -name '*.spec.js' \) -delete
log "Creating tarball..."
tar -czf "${OUT_DIR}/${TARBALL_NAME}" -C "$TMP_DIR" dashcaddy
SHA256="$(sha256sum "${OUT_DIR}/${TARBALL_NAME}" | awk '{print $1}')"
printf '%s %s\n' "$SHA256" "$TARBALL_NAME" > "${OUT_DIR}/${SHA256_FILE}"
CHANGELOG=""
if [[ -n "$CHANGELOG_FILE" && -f "$CHANGELOG_FILE" ]]; then
CHANGELOG="$(python3 - <<'PY' "$CHANGELOG_FILE"
from pathlib import Path
import json, sys
print(json.dumps(Path(sys.argv[1]).read_text().strip()))
PY
)"
else
CHANGELOG='"Release published via scripts/publish-release.sh"'
fi
log "Writing version manifest..."
cat > "${OUT_DIR}/${VERSION_JSON}" <<EOF
{
"version": "${VERSION}",
"commit": "${COMMIT}",
"channel": "${CHANNEL}",
"url": "${BASE_URL}/${TARBALL_NAME}",
"sha256": "${SHA256}",
"publishedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"changelog": ${CHANGELOG}
}
EOF
log "Release artifacts created in ${OUT_DIR}"
log " - ${TARBALL_NAME}"
log " - ${SHA256_FILE}"
log " - ${VERSION_JSON}"
+36 -1
View File
@@ -1,9 +1,11 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const esbuild = require('esbuild');
const JS = (...parts) => path.join(__dirname, 'js', ...parts);
const DIST = path.join(__dirname, 'dist');
const INDEX_HTML = path.join(__dirname, 'index.html');
// Bundle definitions — files are concatenated in order, then minified
const bundles = {
@@ -23,6 +25,8 @@ const bundles = {
JS('core', 'service-crud.js'),
JS('core', 'service-create.js'),
JS('live-events.js'),
JS('service-filter.js'),
JS('batch-operations.js'),
],
'features.js': [
JS('logo-customization.js'),
@@ -31,6 +35,8 @@ const bundles = {
JS('recipes.js'),
JS('import-export.js'),
JS('error-logs.js'),
JS('container-logs.js'),
JS('snapshot.js'),
JS('smart-arr-connect.js'),
JS('notification-settings.js'),
JS('panel-tabs.js'),
@@ -64,6 +70,32 @@ const bundles = {
],
};
function updateInlineScriptCspHash() {
const html = fs.readFileSync(INDEX_HTML, 'utf8');
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)];
const target = scripts.find(match => {
const block = match[1] || '';
return block.includes("license-topbar-version") || block.includes("openVersionInfo") || block.includes("widget-");
});
if (!target) {
throw new Error('Could not find inline dashboard bootstrap script in index.html');
}
const scriptContent = target[1];
const hash = crypto.createHash('sha256').update(scriptContent).digest('base64');
const updatedHtml = html.replace(
/script-src 'self' 'sha256-[^']+';/,
`script-src 'self' 'sha256-${hash}';`
);
if (updatedHtml !== html) {
fs.writeFileSync(INDEX_HTML, updatedHtml);
}
return hash;
}
async function build() {
// Ensure dist/ exists
if (!fs.existsSync(DIST)) fs.mkdirSync(DIST);
@@ -96,6 +128,8 @@ async function build() {
results[outName] = { rawSize, minSize, fileCount: files.length };
}
const cspHash = updateInlineScriptCspHash();
// Summary
console.log('\n DashCaddy Frontend Build\n');
console.log(' Bundle Files Raw Min');
@@ -108,7 +142,8 @@ async function build() {
}
console.log(' ─────────────────────────────────────────');
console.log(` ${'Total'.padEnd(18)} ${totalRaw.toFixed(1).padStart(6)} KB ${totalMin.toFixed(1).padStart(6)} KB`);
console.log(`\n Output: ${DIST}\n`);
console.log(`\n Output: ${DIST}`);
console.log(` CSP Hash: sha256-${cspHash}\n`);
}
// Watch mode
+39 -2
View File
@@ -170,13 +170,20 @@ button:focus-visible {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.reload-caddy-main {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.license-version-row {
display: flex;
align-items: center;
gap: 18px;
gap: 8px;
margin-top: 12px;
}
.dashboard-version {
@@ -196,6 +203,36 @@ button:focus-visible {
opacity: 0.9;
}
.dashboard-version-inline {
white-space: nowrap;
align-self: center;
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 4px;
}
.dashboard-version-inline.has-update {
color: var(--accent);
}
.dashboard-version-inline.has-update .dashboard-version-bullet,
.dashboard-version-inline.has-update #license-topbar-version-text {
color: var(--accent) !important;
}
.dashboard-version-bullet {
color: var(--muted);
font-weight: 400;
}
#license-topbar-version-text {
color: var(--fg) !important;
font-weight: 700;
font-size: 0.82rem;
letter-spacing: 0.01em;
}
.version-info-modal-content {
min-width: 420px;
max-width: 620px;
+99 -99
View File
File diff suppressed because one or more lines are too long
+302 -191
View File
File diff suppressed because one or more lines are too long
+7 -7
View File
@@ -1,4 +1,4 @@
(function(){function k(){const a=safeGet("custom-services");if(a)try{JSON.parse(a).forEach(i=>{window.APPS.find(r=>r.id===i.id)||window.APPS.push(i)})}catch(c){console.warn("Failed to load custom services:",c)}}k();function n(){const a=document.querySelectorAll(".top .card");a.forEach((c,i)=>{c.style.transitionDelay=`${Math.min(i*60,300)}ms`}),requestAnimationFrame(()=>{a.forEach(c=>c.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(c=>{console.warn("[init] Service worker registration failed:",c)})};document.readyState==="complete"?a():window.addEventListener("load",a,{once:!0})}let l=!1;async function m(){if(l){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(l=!0,await window.loadServices(),window.buildGrid(),n(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window._updateAuthCard=="function")try{const c=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();c.success&&window._updateAuthCard(c.config.enabled&&c.config.isSetUp,c.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}p(),q()&&v()}function v(){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 c=document.createElement("link");c.rel="stylesheet",c.href="/css/driver.min.css",document.head.appendChild(c)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const c=document.createElement("link");c.rel="stylesheet",c.href="/css/onboarding.css",document.head.appendChild(c)}}function q(){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 g(){const a=document.querySelectorAll(".tools-section");if(!a.length)return;let c={};try{c=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}a.forEach(i=>{const r=i.dataset.section,h=i.querySelector(".tools-section-header");h&&(c[r]&&(i.classList.add("open"),h.setAttribute("aria-expanded","true")),h.addEventListener("click",S=>{S.preventDefault();const y=i.classList.toggle("open");h.setAttribute("aria-expanded",y?"true":"false");const w={};document.querySelectorAll(".tools-section").forEach(f=>{w[f.dataset.section]=f.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(w))}))})}g();function p(){if(document.getElementById("restart-tour-btn"))return;let a=typeof SITE<"u"&&SITE.onboardingCompleted;try{const r=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));a=a||!!(r&&r.tourCompleted)}catch{}const c=a?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!c)return;const i=document.createElement("button");i.id="restart-tour-btn",i.textContent=a?"Help Tour":"\u{1F393} Help Tour",i.title="Restart the onboarding tour",i.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{v();const r=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(r),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(r),5e3)}},c.appendChild(i)}window.initializeDashboard=m,window.loadCustomServices=k,u(),(async()=>{try{const c=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(c.success&&c.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)}m()})()})(),(function(){"use strict";const 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 n=null,u=null,l=null;function m(){try{v(),document.addEventListener("keydown",q),console.log("[Keyboard Shortcuts] Initialized"),console.log("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),console.log("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function v(){n=document.createElement("div"),n.id="quick-search-modal",n.className="quick-search-modal",n.innerHTML=`
(function(){function p(){const a=safeGet("custom-services");if(a)try{JSON.parse(a).forEach(i=>{window.APPS.find(r=>r.id===i.id)||window.APPS.push(i)})}catch(c){console.warn("Failed to load custom services:",c)}}p();function y(){const a=document.querySelectorAll(".top .card");a.forEach((c,i)=>{c.style.transitionDelay=`${Math.min(i*60,300)}ms`}),requestAnimationFrame(()=>{a.forEach(c=>c.classList.add("loaded"))})}function n(){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(c=>{console.warn("[init] Service worker registration failed:",c)})};document.readyState==="complete"?a():window.addEventListener("load",a,{once:!0})}let u=!1;async function l(){if(u){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(u=!0,await window.loadServices(),window.buildGrid(),y(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window._updateAuthCard=="function")try{const c=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();c.success&&window._updateAuthCard(c.config.enabled&&c.config.isSetUp,c.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}k(),w()&&m()}function m(){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 c=document.createElement("link");c.rel="stylesheet",c.href="/css/driver.min.css",document.head.appendChild(c)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const c=document.createElement("link");c.rel="stylesheet",c.href="/css/onboarding.css",document.head.appendChild(c)}}function w(){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 b(){const a=document.querySelectorAll(".tools-section");if(!a.length)return;let c={};try{c=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}a.forEach(i=>{const r=i.dataset.section,h=i.querySelector(".tools-section-header");h&&(c[r]&&(i.classList.add("open"),h.setAttribute("aria-expanded","true")),h.addEventListener("click",q=>{q.preventDefault();const S=i.classList.toggle("open");h.setAttribute("aria-expanded",S?"true":"false");const f={};document.querySelectorAll(".tools-section").forEach(v=>{f[v.dataset.section]=v.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(f))}))})}b();function k(){if(document.getElementById("restart-tour-btn"))return;let a=typeof SITE<"u"&&SITE.onboardingCompleted;try{const r=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));a=a||!!(r&&r.tourCompleted)}catch{}const c=a?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!c)return;const i=document.createElement("button");i.id="restart-tour-btn",i.textContent=a?"Help Tour":"\u{1F393} Help Tour",i.title="Restart the onboarding tour",i.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{m();const r=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(r),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(r),5e3)}},c.appendChild(i)}window.initializeDashboard=l,window.loadCustomServices=p,n(),(async()=>{try{const c=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(c.success&&c.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)}l()})()})(),(function(){"use strict";const p=(...t)=>{window.DASHCADDY_DEBUG&&console.log(...t)},y=["#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 n=null,u=null,l=null;function m(){try{w(),document.addEventListener("keydown",b),p("[Keyboard Shortcuts] Initialized"),p("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),p("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function w(){n=document.createElement("div"),n.id="quick-search-modal",n.className="quick-search-modal",n.innerHTML=`
<div class="quick-search-content">
<div class="quick-search-input-wrapper">
<span class="quick-search-icon">\u{1F50D}</span>
@@ -160,7 +160,7 @@
font-family: monospace;
margin-right: 4px;
}
`,document.head.appendChild(t),document.body.appendChild(n),u=document.getElementById("quick-search-input"),l=document.getElementById("quick-search-results"),u.addEventListener("input",r),u.addEventListener("keydown",w),n.addEventListener("click",e=>{e.target===n&&p()})}function q(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),g();return}if(t.key==="Escape"){if(n&&n.classList.contains("show")){p();return}a()}}catch(e){console.warn("[Keyboard Shortcuts] Error handling keydown:",e.message)}}function g(){try{n.classList.add("show"),u.value="",u.focus(),c()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function p(){try{n.classList.remove("show"),u.value="",l.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function a(){for(const t of k){const e=document.querySelector(t);if(e&&(e.classList.contains("show")||e.style.display==="flex"))return e.classList.remove("show"),e.style.display="none",!0}return!1}function c(){const t=`
`,document.head.appendChild(t),document.body.appendChild(n),u=document.getElementById("quick-search-input"),l=document.getElementById("quick-search-results"),u.addEventListener("input",h),u.addEventListener("keydown",v),n.addEventListener("click",e=>{e.target===n&&a()})}function b(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),k();return}if(t.key==="Escape"){if(n&&n.classList.contains("show")){a();return}c()}}catch(e){console.warn("[Keyboard Shortcuts] Error handling keydown:",e.message)}}function k(){try{n.classList.add("show"),u.value="",u.focus(),i()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function a(){try{n.classList.remove("show"),u.value="",l.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function c(){for(const t of y){const e=document.querySelector(t);if(e&&(e.classList.contains("show")||e.style.display==="flex"))return e.classList.remove("show"),e.style.display="none",!0}return!1}function i(){const t=`
<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>
@@ -192,9 +192,9 @@
</div>
<div class="quick-search-category">Services</div>
${i()}
`;l.innerHTML=t,y()}function i(){const t=document.querySelectorAll(".card[data-app], #cards .card");let e="";return t.forEach(s=>{const d=s.querySelector(".name")?.textContent||"Unknown",o=s.dataset.status||"unknown",b=s.dataset.app||"";d&&d!=="--"&&(e+=`
<div class="quick-search-item" data-action="open-service" data-service="${b}">
${r()}
`;l.innerHTML=t,f()}function r(){const t=document.querySelectorAll(".card[data-app], #cards .card");let e="";return t.forEach(s=>{const d=s.querySelector(".name")?.textContent||"Unknown",o=s.dataset.status||"unknown",g=s.dataset.app||"";d&&d!=="--"&&(e+=`
<div class="quick-search-item" data-action="open-service" data-service="${g}">
<span class="quick-search-item-icon">${o==="on"?"\u{1F7E2}":"\u{1F534}"}</span>
<div class="quick-search-item-content">
<div class="quick-search-item-title">${d}</div>
@@ -202,7 +202,7 @@
</div>
<span class="quick-search-item-badge">${o.toUpperCase()}</span>
</div>
`)}),e||'<div class="quick-search-empty">No services found</div>'}function r(t){try{const e=t.target.value.toLowerCase().trim();if(!e){c();return}const s=h(e);S(s)}catch(e){console.warn("[Keyboard Shortcuts] Error handling search input:",e.message)}}function h(t){const e={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(o=>{(o.title.toLowerCase().includes(t)||o.keywords.includes(t))&&e.actions.push(o)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(o=>{const b=o.querySelector(".name")?.textContent||"",x=o.dataset.app||"",E=o.dataset.status||"unknown";(b.toLowerCase().includes(t)||x.toLowerCase().includes(t))&&e.services.push({id:x,title:b,status:E,icon:E==="on"?"\u{1F7E2}":"\u{1F534}"})}),e}function S(t){let e="";t.actions.length>0&&(e+='<div class="quick-search-category">Actions</div>',t.actions.forEach(s=>{e+=`
`)}),e||'<div class="quick-search-empty">No services found</div>'}function h(t){try{const e=t.target.value.toLowerCase().trim();if(!e){i();return}const s=q(e);S(s)}catch(e){console.warn("[Keyboard Shortcuts] Error handling search input:",e.message)}}function q(t){const e={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(o=>{(o.title.toLowerCase().includes(t)||o.keywords.includes(t))&&e.actions.push(o)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(o=>{const g=o.querySelector(".name")?.textContent||"",E=o.dataset.app||"",C=o.dataset.status||"unknown";(g.toLowerCase().includes(t)||E.toLowerCase().includes(t))&&e.services.push({id:E,title:g,status:C,icon:C==="on"?"\u{1F7E2}":"\u{1F534}"})}),e}function S(t){let e="";t.actions.length>0&&(e+='<div class="quick-search-category">Actions</div>',t.actions.forEach(s=>{e+=`
<div class="quick-search-item" data-action="${s.id}">
<span class="quick-search-item-icon">${s.icon}</span>
<div class="quick-search-item-content">
@@ -217,4 +217,4 @@
</div>
<span class="quick-search-item-badge">${s.status.toUpperCase()}</span>
</div>
`})),e||(e='<div class="quick-search-empty">No results found</div>'),l.innerHTML=e,y()}function y(){l.querySelectorAll(".quick-search-item").forEach((e,s)=>{e.addEventListener("click",()=>f(e)),s===0&&e.classList.add("selected")})}function w(t){try{const e=l.querySelectorAll(".quick-search-item"),s=l.querySelector(".quick-search-item.selected"),d=Array.from(e).indexOf(s);if(t.key==="ArrowDown"){t.preventDefault(),s&&s.classList.remove("selected");const o=(d+1)%e.length;e[o]?.classList.add("selected"),e[o]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),s&&s.classList.remove("selected");const o=d<=0?e.length-1:d-1;e[o]?.classList.add("selected"),e[o]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),s&&f(s))}catch(e){console.warn("[Keyboard Shortcuts] Error handling search navigation:",e.message)}}function f(t){try{const e=t.dataset.action,s=t.dataset.service;switch(p(),e){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 d=document.querySelector(`[data-app="${s}"] [id$="-open"], [data-app="${s}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(d)d.click();else{const o=document.querySelector(`[data-app="${s}"]`);o&&o.click()}}break;default:console.log("[Keyboard Shortcuts] Unknown action:",e)}}catch(e){console.warn("[Keyboard Shortcuts] Error executing action:",e.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m(),window.DashCaddyKeyboardShortcuts={openQuickSearch:g,closeQuickSearch:p}})();
`})),e||(e='<div class="quick-search-empty">No results found</div>'),l.innerHTML=e,f()}function f(){l.querySelectorAll(".quick-search-item").forEach((e,s)=>{e.addEventListener("click",()=>x(e)),s===0&&e.classList.add("selected")})}function v(t){try{const e=l.querySelectorAll(".quick-search-item"),s=l.querySelector(".quick-search-item.selected"),d=Array.from(e).indexOf(s);if(t.key==="ArrowDown"){t.preventDefault(),s&&s.classList.remove("selected");const o=(d+1)%e.length;e[o]?.classList.add("selected"),e[o]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),s&&s.classList.remove("selected");const o=d<=0?e.length-1:d-1;e[o]?.classList.add("selected"),e[o]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),s&&x(s))}catch(e){console.warn("[Keyboard Shortcuts] Error handling search navigation:",e.message)}}function x(t){try{const e=t.dataset.action,s=t.dataset.service;switch(a(),e){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 d=document.querySelector(`[data-app="${s}"] [id$="-open"], [data-app="${s}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(d)d.click();else{const o=document.querySelector(`[data-app="${s}"]`);o&&o.click()}}break;default:p("[Keyboard Shortcuts] Unknown action:",e)}}catch(e){console.warn("[Keyboard Shortcuts] Error executing action:",e.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m(),window.DashCaddyKeyboardShortcuts={openQuickSearch:k,closeQuickSearch:a}})();
+36 -36
View File
File diff suppressed because one or more lines are too long
+198 -34
View File
@@ -8,7 +8,7 @@
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'sha256-6JZtsKK/PZthh+stCmmCvC2QxCiyk6SwZCBjXE+kYr0='; style-src 'self' 'unsafe-inline'; img-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self' https://api.open-meteo.com https://geocoding-api.open-meteo.com; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'sha256-Nv8xzCSztfdYOL663VgPKWQn6v0lnM0ACWxkxpFfcfY='; style-src 'self' 'unsafe-inline'; img-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self' https://api.open-meteo.com https://geocoding-api.open-meteo.com; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'">
<link rel="icon" href="/assets/dashcaddy-favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="192x192" href="/assets/icon-192.png">
@@ -90,16 +90,18 @@
</button>
<button id="theme-customize-btn" class="theme-customize-link" title="Customize theme colors">Customize Theme</button>
</div>
<div id="license-status-topbar" class="license-status-topbar free" title="Click to manage license">
<span id="license-topbar-icon">&#9734;</span>
<span id="license-topbar-text">FREE TIER</span>
<span id="license-topbar-time"></span>
</div>
<button id="reload-caddy-top" aria-label="Reload Caddy configuration" style="padding: 10px 20px; font-size: 0.95rem; font-weight: 600; background: linear-gradient(135deg, #3498db 0%, #2980b9 100%); border: none; border-radius: 6px; color: white; cursor: pointer; box-shadow: 0 2px 6px rgba(52, 152, 219, 0.3); transition: all 0.2s ease;">
🔄 Reload Caddy
</button>
<div class="license-version-row">
<div id="license-status-topbar" class="license-status-topbar free" title="Click to manage license">
<span id="license-topbar-icon">&#9734;</span>
<span id="license-topbar-text">FREE TIER</span>
<span id="license-topbar-time"></span>
</div>
<button id="license-topbar-version" class="dashboard-version dashboard-version-inline" type="button" title="View DashCaddy verification info" aria-label="View DashCaddy verification info"><span class="dashboard-version-bullet" id="license-topbar-version-bullet">·</span> <span id="license-topbar-version-text">Loading…</span></button>
</div>
</div>
<button id="dashboard-version" class="dashboard-version" type="button" title="View DashCaddy verification info" aria-label="View DashCaddy verification info">Version —</button>
</div>
</div>
@@ -110,7 +112,9 @@
<div id="version-info-status" class="version-info-status">Loading…</div>
<div id="version-info-grid" class="version-info-grid" style="display:none;"></div>
<div id="version-info-history" class="version-info-history" style="display:none;"></div>
<div class="weather-modal-buttons" style="margin-top: 16px;">
<div id="version-info-actions" class="weather-modal-buttons" style="margin-top: 16px; display:none; justify-content: space-between; gap: 10px;">
<button id="version-info-update">Update Now</button>
<button id="version-info-open-updates">Open Updates</button>
<button id="version-info-close">Close</button>
</div>
</div>
@@ -144,7 +148,8 @@
<span class="tools-section-label">Tools</span>
</button>
<div class="tools-section-items">
<button id="view-error-logs" aria-label="View error logs">📋 Logs</button>
<button id="view-error-logs" aria-label="View error logs">📋 Error Logs</button>
<button id="view-container-logs" aria-label="View container logs">📜 Container Logs</button>
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
<button id="docker-resources-btn" aria-label="Docker resources">🐳 Docker</button>
@@ -159,9 +164,10 @@
<div class="tools-section-items">
<button id="manage-tokens" aria-label="Manage API tokens">🔑 Tokens</button>
<button id="backup-restore-btn" aria-label="Backup and restore">💾 Backup</button>
<button id="license-btn" aria-label="License management" onclick="window.openLicenseModal && window.openLicenseModal()">🔑 License</button>
<button id="api-docs-btn" aria-label="API documentation" onclick="window.open('/api/docs', '_blank')">📖 API</button>
<button id="help-errors-btn" aria-label="Troubleshooting guide" onclick="window.open('/help-errors.html', '_blank')">❓ Help</button>
<button id="snapshot-btn" aria-label="Container snapshots">💾 Snapshots</button>
<button id="license-btn" aria-label="License management">🔑 License</button>
<button id="api-docs-btn" aria-label="API documentation">📖 API</button>
<button id="help-errors-btn" aria-label="Troubleshooting guide">❓ Help</button>
</div>
</div>
</div>
@@ -242,6 +248,29 @@
</div>
</div>
<!-- Service Filter Bar -->
<div id="service-filter-bar" style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px; padding: 12px 16px; background: var(--card-base); border: 1px solid var(--border); border-radius: var(--radius); flex-wrap: wrap;">
<input type="text" id="service-filter-search" placeholder="🔍 Filter services..." style="flex: 1; min-width: 180px; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;" />
<select id="service-filter-status" style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
<option value="all">All Status</option>
<option value="on">🟢 Online</option>
<option value="off">🔴 Offline</option>
</select>
<button id="batch-operations-btn" class="btn-sm" style="padding: 8px 12px;">☰ Batch Operations</button>
<span id="service-filter-count" style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;"></span>
</div>
<!-- Batch Operations Bar (hidden by default) -->
<div id="batch-action-bar" style="display: none; margin-bottom: 16px; padding: 12px 16px; background: var(--accent-bg, #1a3a5c); border: 1px solid var(--accent, #3498db); border-radius: var(--radius);">
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
<span id="batch-selected-count" style="font-weight: 500;">0 selected</span>
<button id="batch-start-btn" class="btn-sm" style="background: var(--ok-fg, #27ae60);">▶ Start All</button>
<button id="batch-stop-btn" class="btn-sm" style="background: var(--bad-fg, #e74c3c);">⬛ Stop All</button>
<button id="batch-restart-btn" class="btn-sm" style="background: #f39c12;">🔄 Restart All</button>
<button id="batch-cancel-btn" class="btn-sm">Cancel</button>
</div>
</div>
<!-- App/service grid -->
<div id="cards" class="grid"></div>
@@ -555,12 +584,18 @@
var yr = document.getElementById('footer-year');
if (yr) yr.textContent = new Date().getFullYear();
var versionEl = document.getElementById('dashboard-version');
var versionEl = document.getElementById('license-topbar-version');
var versionTextEl = document.getElementById('license-topbar-version-text');
var versionBulletEl = document.getElementById('license-topbar-version-bullet');
var versionInfoModal = document.getElementById('version-info-modal');
var versionInfoStatus = document.getElementById('version-info-status');
var versionInfoGrid = document.getElementById('version-info-grid');
var versionInfoHistory = document.getElementById('version-info-history');
var versionInfoActions = document.getElementById('version-info-actions');
var versionInfoUpdate = document.getElementById('version-info-update');
var versionInfoOpenUpdates = document.getElementById('version-info-open-updates');
var versionInfoClose = document.getElementById('version-info-close');
var latestUpdateCheck = null;
function formatValue(value) {
if (value == null || value === '') return '—';
@@ -588,71 +623,197 @@
}).join('');
}
function setVersionLabel(text) {
if (versionTextEl) versionTextEl.textContent = text;
}
function setVersionUpdateState(hasUpdate) {
if (!versionEl || !versionBulletEl) return;
versionEl.classList.toggle('has-update', !!hasUpdate);
versionEl.title = hasUpdate
? 'Update available — click for details or to update'
: 'View DashCaddy verification info';
versionEl.setAttribute('aria-label', hasUpdate
? 'DashCaddy update available — click for details or to update'
: 'View DashCaddy verification info');
versionBulletEl.textContent = hasUpdate ? '⬆' : '·';
}
function checkVersionUpdateAvailability() {
return fetch('/api/v1/system/update-check', { cache: 'no-store', credentials: 'same-origin' })
.then(function(response) {
if (response.status === 401) throw new Error('AUTH_PENDING');
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
})
.then(function(data) {
latestUpdateCheck = data;
setVersionUpdateState(!!(data && data.success && data.available && data.remote));
return data;
})
.catch(function(error) {
if (!(error && error.message === 'AUTH_PENDING')) {
setVersionUpdateState(false);
}
throw error;
});
}
function loadVersionLabel(retryCount) {
if (!versionEl) return;
fetch('/api/v1/system/version', { cache: 'no-store', credentials: 'same-origin' })
.then(function(response) {
if (response.status === 401) {
throw new Error('AUTH_PENDING');
}
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
})
.then(function(data) {
if (data && data.success && data.version) {
setVersionLabel('v' + data.version);
checkVersionUpdateAvailability().catch(function() {});
return;
}
setVersionLabel('version unknown');
setVersionUpdateState(false);
})
.catch(function(error) {
if (error && error.message === 'AUTH_PENDING') {
setVersionLabel('signing in…');
if ((retryCount || 0) < 12) {
setTimeout(function() {
loadVersionLabel((retryCount || 0) + 1);
}, 1500);
}
return;
}
setVersionLabel('version unavailable');
setVersionUpdateState(false);
});
}
function openUpdatesPanel() {
var updatesBtn = document.getElementById('updates-btn');
var updatesDashcaddyTab = document.getElementById('updates-dashcaddy-tab');
if (updatesBtn) updatesBtn.click();
setTimeout(function() {
if (updatesDashcaddyTab) updatesDashcaddyTab.click();
}, 50);
}
function applyVersionUpdate() {
if (window.dcApplyUpdate) {
return window.dcApplyUpdate();
}
openUpdatesPanel();
}
function openVersionInfo() {
if (!versionInfoModal) return;
versionInfoModal.classList.add('show');
versionInfoStatus.textContent = 'Loading…';
versionInfoGrid.style.display = 'none';
versionInfoHistory.style.display = 'none';
if (versionInfoActions) versionInfoActions.style.display = 'none';
if (versionInfoUpdate) {
versionInfoUpdate.disabled = true;
versionInfoUpdate.textContent = 'Update Now';
}
versionInfoGrid.innerHTML = '';
versionInfoHistory.innerHTML = '';
Promise.all([
fetch('/api/v1/system/version', { cache: 'no-store' }).then(function(response) {
fetch('/api/v1/system/version', { cache: 'no-store', credentials: 'same-origin' }).then(function(response) {
if (!response.ok) throw new Error('Version check failed');
return response.json();
}),
fetch('/api/v1/system/update-status', { cache: 'no-store' }).then(function(response) {
fetch('/api/v1/system/update-status', { cache: 'no-store', credentials: 'same-origin' }).then(function(response) {
if (!response.ok) throw new Error('Update status failed');
return response.json();
}),
fetch('/api/v1/system/update-history', { cache: 'no-store' }).then(function(response) {
fetch('/api/v1/system/update-history', { cache: 'no-store', credentials: 'same-origin' }).then(function(response) {
if (!response.ok) throw new Error('Update history failed');
return response.json();
})
}),
checkVersionUpdateAvailability().catch(function() { return latestUpdateCheck || {}; })
]).then(function(results) {
var versionData = results[0] || {};
var statusData = results[1] || {};
var historyData = results[2] || {};
var lastResult = statusData.lastResult || {};
var updateCheckData = results[3] || latestUpdateCheck || {};
var lastResult = statusData.lastResult || updateCheckData || {};
var lastPolicy = lastResult.policy || {};
var hasUpdate = !!(updateCheckData && updateCheckData.success && updateCheckData.available && updateCheckData.remote);
versionInfoStatus.textContent = 'Verification info loaded.';
versionInfoStatus.textContent = hasUpdate
? 'Update available — you can install it from here.'
: 'Verification info loaded.';
versionInfoGrid.style.display = 'grid';
versionInfoGrid.innerHTML = [
renderInfoRow('Version', versionData.version),
renderInfoRow('Commit', versionData.commit),
renderInfoRow('Updater Status', statusData.status),
renderInfoRow('Last Check', statusData.lastCheck ? new Date(statusData.lastCheck).toLocaleString() : 'Never'),
renderInfoRow('Update Available', lastResult.available),
renderInfoRow('Update Available', hasUpdate),
renderInfoRow('Available Version', updateCheckData.remote && updateCheckData.remote.version),
renderInfoRow('Eligible', lastPolicy.eligible),
renderInfoRow('Policy Reason', lastPolicy.reason),
renderInfoRow('Channel', lastPolicy.releaseChannel || (lastResult.instance && lastResult.instance.channel)),
renderInfoRow('Instance ID', lastResult.instance && lastResult.instance.instanceId)
].join('');
if (versionInfoActions) versionInfoActions.style.display = 'flex';
if (versionInfoUpdate) versionInfoUpdate.disabled = !hasUpdate;
renderHistory(historyData.history);
}).catch(function(error) {
versionInfoStatus.textContent = 'Could not load verification info: ' + error.message;
if (versionInfoActions) versionInfoActions.style.display = 'flex';
});
}
if (versionEl) {
fetch('/api/v1/system/version', { cache: 'no-store' })
.then(function(response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
})
.then(function(data) {
if (data && data.success && data.version) {
versionEl.textContent = 'Version ' + data.version;
}
})
.catch(function() {
versionEl.textContent = 'Version unavailable';
});
loadVersionLabel(0);
versionEl.addEventListener('click', openVersionInfo);
versionEl.addEventListener('click', function(event) {
event.stopPropagation();
openVersionInfo();
});
}
var licenseBtn = document.getElementById('license-btn');
var apiDocsBtn = document.getElementById('api-docs-btn');
var helpErrorsBtn = document.getElementById('help-errors-btn');
if (licenseBtn) {
licenseBtn.addEventListener('click', function() {
if (window.openLicenseModal) window.openLicenseModal();
});
}
if (apiDocsBtn) {
apiDocsBtn.addEventListener('click', function() {
window.open('/api/docs', '_blank');
});
}
if (helpErrorsBtn) {
helpErrorsBtn.addEventListener('click', function() {
window.open('/help-errors.html', '_blank');
});
}
if (versionInfoUpdate) {
versionInfoUpdate.addEventListener('click', function() {
applyVersionUpdate();
});
}
if (versionInfoOpenUpdates) {
versionInfoOpenUpdates.addEventListener('click', function() {
versionInfoModal.classList.remove('show');
openUpdatesPanel();
});
}
if (versionInfoClose && versionInfoModal) {
@@ -665,6 +826,9 @@
}
});
}
window.openVersionInfo = openVersionInfo;
window.applyVersionUpdate = applyVersionUpdate;
})();
</script>
+5 -4
View File
@@ -1,5 +1,6 @@
// App Selector System
(function () {
const errorHandler = new ErrorHandler();
injectModal('app-selector-modal', `<div id="app-selector-modal" class="weather-modal">
<div class="app-selector-content">
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
@@ -230,7 +231,7 @@
return true;
}
} catch (e) {
console.error('Failed to fetch app templates:', e);
errorHandler.logError('[AppSelector] Fetch Templates', e, { function: 'fetchApiTemplates' });
}
return false;
}
@@ -242,7 +243,7 @@
const data = await response.json();
return data;
} catch (e) {
console.error('Failed to check port:', e);
errorHandler.logError('[AppSelector] Check Port', e, { function: 'checkPortAvailability' });
return { available: true }; // Assume available on error
}
}
@@ -256,7 +257,7 @@
return data.suggestedPort;
}
} catch (e) {
console.error('Failed to get suggested port:', e);
errorHandler.logError('[AppSelector] Get Suggested Port', e, { function: 'getSuggestedPort' });
}
return basePort;
}
@@ -842,7 +843,7 @@
throw new Error(result.error || 'Deployment failed');
}
} catch (error) {
console.error('Deployment error:', error);
errorHandler.logError('[AppSelector] Deployment', error, { function: 'deploy' });
showNotification(
`Failed to deploy ${appTemplate.name}: ${error.message}`,
'error',
+138
View File
@@ -0,0 +1,138 @@
// ========== BATCH CONTAINER OPERATIONS ==========
(function() {
const batchBtn = document.getElementById('batch-operations-btn');
const batchBar = document.getElementById('batch-action-bar');
const batchCount = document.getElementById('batch-selected-count');
const startBtn = document.getElementById('batch-start-btn');
const stopBtn = document.getElementById('batch-stop-btn');
const restartBtn = document.getElementById('batch-restart-btn');
const cancelBtn = document.getElementById('batch-cancel-btn');
let batchMode = false;
let selectedContainers = new Set();
function enterBatchMode() {
batchMode = true;
selectedContainers.clear();
batchBar.style.display = '';
batchBtn.textContent = '✓ Exit Batch Mode';
updateSelectedCount();
// Add checkboxes to all cards with containerId
const cards = document.querySelectorAll('#cards .card[data-app]');
cards.forEach(card => {
const containerId = card.dataset.containerId;
if (!containerId) return;
// Remove existing checkbox if any
const existing = card.querySelector('.batch-checkbox');
if (existing) existing.remove();
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'batch-checkbox';
checkbox.dataset.containerId = containerId;
checkbox.dataset.serviceName = card.querySelector('.name')?.textContent || containerId;
checkbox.style.cssText = 'position: absolute; top: 8px; left: 8px; z-index: 10; width: 18px; height: 18px; cursor: pointer;';
checkbox.addEventListener('change', (e) => {
e.stopPropagation();
if (checkbox.checked) {
selectedContainers.add(containerId);
} else {
selectedContainers.delete(containerId);
}
updateSelectedCount();
});
card.style.position = 'relative';
card.insertBefore(checkbox, card.firstChild);
});
}
function exitBatchMode() {
batchMode = false;
selectedContainers.clear();
batchBar.style.display = 'none';
batchBtn.textContent = '☰ Batch Operations';
// Remove all checkboxes
document.querySelectorAll('.batch-checkbox').forEach(cb => cb.remove());
}
function updateSelectedCount() {
const count = selectedContainers.size;
batchCount.textContent = `${count} selected`;
startBtn.disabled = count === 0;
stopBtn.disabled = count === 0;
restartBtn.disabled = count === 0;
}
async function batchAction(action) {
if (selectedContainers.size === 0) return;
const containers = Array.from(selectedContainers);
const actionLabel = { start: 'Starting', stop: 'Stopping', restart: 'Restarting' }[action];
if (!confirm(`${actionLabel} ${containers.length} container(s)? This cannot be undone.`)) return;
const btns = [startBtn, stopBtn, restartBtn];
btns.forEach(b => { b.disabled = true; b.textContent = '...'; });
let success = 0;
let failed = 0;
const errors = [];
for (const containerId of containers) {
try {
const res = await fetch(`/api/v1/containers/${encodeURIComponent(containerId)}/${action}`, {
method: 'POST'
});
if (res.ok) {
success++;
} else {
failed++;
const data = await res.json().catch(() => ({}));
errors.push(`${containerId}: ${data.error || res.statusText}`);
}
} catch (e) {
failed++;
errors.push(`${containerId}: ${e.message}`);
}
}
// Restore buttons
btns[0].textContent = '▶ Start All';
btns[1].textContent = '⬛ Stop All';
btns[2].textContent = '🔄 Restart All';
updateSelectedCount();
// Show results
if (failed === 0) {
if (typeof showNotification === 'function') {
showNotification(`${actionLabel} completed: ${success} container(s)`, 'success');
}
} else {
if (typeof showNotification === 'function') {
showNotification(`${actionLabel}: ${success} succeeded, ${failed} failed`, 'warning');
}
console.error('Batch operation errors:', errors);
}
// Refresh dashboard after a short delay
setTimeout(() => {
if (typeof refreshAll === 'function') refreshAll();
}, 1500);
}
batchBtn?.addEventListener('click', () => {
if (batchMode) {
exitBatchMode();
} else {
enterBatchMode();
}
});
startBtn?.addEventListener('click', () => batchAction('start'));
stopBtn?.addEventListener('click', () => batchAction('stop'));
restartBtn?.addEventListener('click', () => batchAction('restart'));
cancelBtn?.addEventListener('click', exitBatchMode);
})();
+429
View File
@@ -0,0 +1,429 @@
// ========== CONTAINER LOG VIEWER ==========
(function() {
// Inject modal HTML
injectModal('container-logs-modal', `<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
<div>
<h3 style="margin: 0;">📜 Container Logs</h3>
<p class="modal-subtitle" style="margin: 4px 0 0 0; font-size: 0.85rem; opacity: 0.7;">View and stream Docker container logs</p>
</div>
<div class="logs-controls" style="display: flex; gap: 8px; align-items: center;">
<select id="cl-container-select" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); min-width: 200px;">
<option value="">Select a container...</option>
</select>
<input type="text" id="cl-log-search" placeholder="Search logs..." style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); width: 150px;" />
<select id="cl-log-tail" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);">
<option value="50">Last 50 lines</option>
<option value="100" selected>Last 100 lines</option>
<option value="500">Last 500 lines</option>
<option value="1000">Last 1000 lines</option>
<option value="all">All logs</option>
</select>
<button id="cl-refresh" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">🔄 Refresh</button>
<button id="cl-stream" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;"> Stream</button>
<button id="cl-download" style="padding: 6px 14px; font-size: 0.85rem;">💾 Download</button>
<button id="cl-clear-search" style="padding: 6px 10px; font-size: 0.85rem;" title="Clear search"></button>
<button id="cl-close" class="close-btn" style="padding: 6px 10px;"></button>
</div>
</div>
<div id="cl-container-info" style="display: flex; gap: 16px; margin-bottom: 12px; padding: 8px 12px; background: var(--bg-secondary, var(--bg)); border-radius: 6px; font-size: 0.82rem;">
<span><strong>Image:</strong> <span id="cl-image">-</span></span>
<span><strong>Status:</strong> <span id="cl-status">-</span></span>
<span><strong>Created:</strong> <span id="cl-created">-</span></span>
</div>
<div id="cl-log-content" class="logs-content scroll-container" style="flex: 1; min-height: 0; background: #1a1a1a; border-radius: 6px; padding: 12px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.78rem; overflow: auto;">
<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>
</div>
<div id="cl-stream-status" style="display: none; padding: 8px 12px; background: var(--ok-bg, #1a3a1a); color: var(--ok-fg, #4ade80); border-radius: 6px; margin-top: 8px; font-size: 0.82rem;">
<span id="cl-stream-indicator">🔴</span> <span id="cl-stream-text">Disconnected</span>
</div>
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);">
<div style="display: flex; gap: 8px; font-size: 0.78rem; color: var(--muted);">
<span id="cl-line-count">0 lines</span>
<span>|</span>
<span id="cl-filter-count">0 filtered</span>
</div>
<button id="cl-close-btn" class="btn-secondary">Close</button>
</div>
</div>
</div>`);
const modal = document.getElementById('container-logs-modal');
const containerSelect = document.getElementById('cl-container-select');
const logContent = document.getElementById('cl-log-content');
const logSearch = document.getElementById('cl-log-search');
const logTail = document.getElementById('cl-log-tail');
const refreshBtn = document.getElementById('cl-refresh');
const streamBtn = document.getElementById('cl-stream');
const downloadBtn = document.getElementById('cl-download');
const clearSearchBtn = document.getElementById('cl-clear-search');
const closeBtn = document.getElementById('cl-close');
const closeBtn2 = document.getElementById('cl-close-btn');
const streamStatus = document.getElementById('cl-stream-status');
const streamIndicator = document.getElementById('cl-stream-indicator');
const streamText = document.getElementById('cl-stream-text');
const lineCount = document.getElementById('cl-line-count');
const filterCount = document.getElementById('cl-filter-count');
// Container info elements
const imageEl = document.getElementById('cl-image');
const statusEl = document.getElementById('cl-status');
const createdEl = document.getElementById('cl-created');
let currentContainerId = null;
let currentLogs = [];
let filteredLogs = [];
let eventSource = null;
let isStreaming = false;
let searchTimeout = null;
// Format date
function formatDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleString();
}
// Escape HTML
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Format log entry for display
function formatLogEntry(log, index) {
const streamClass = log.stream === 'stderr' ? 'log-stderr' : 'log-stdout';
const streamIcon = log.stream === 'stderr' ? '⚠️' : '📤';
return `
<div class="log-entry ${streamClass}" data-index="${index}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${index + 1}</span>
<span class="log-stream-icon" style="color: ${log.stream === 'stderr' ? '#f59e0b' : '#22c55e'};">${streamIcon}</span>
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${escapeHtml(log.text)}</span>
</div>
`;
}
// Render logs to the content area
function renderLogs(logs, searchTerm = '') {
if (!logs || logs.length === 0) {
logContent.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>';
lineCount.textContent = '0 lines';
filterCount.textContent = '0 filtered';
return;
}
currentLogs = logs;
filteredLogs = searchTerm ? logs.filter(log =>
log.text && log.text.toLowerCase().includes(searchTerm.toLowerCase())
) : logs;
lineCount.textContent = `${logs.length} lines`;
filterCount.textContent = searchTerm ? `${filteredLogs.length} of ${logs.length} shown` : `${logs.length} shown`;
if (filteredLogs.length === 0) {
logContent.innerHTML = `<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${escapeHtml(searchTerm)}"</div>`;
return;
}
logContent.innerHTML = filteredLogs.map((log, i) => formatLogEntry(log, i)).join('');
// Scroll to bottom
logContent.scrollTop = logContent.scrollHeight;
}
// Load container list
async function loadContainers() {
try {
const data = await getJSON('/api/v1/logs/containers');
const containers = data.containers || [];
// Store current selection
const currentVal = containerSelect.value;
containerSelect.innerHTML = '<option value="">Select a container...</option>';
containers.forEach(c => {
const option = document.createElement('option');
option.value = c.id;
option.textContent = `${c.name} (${c.image.split(':')[0]}) - ${c.status}`;
option.dataset.name = c.name;
option.dataset.image = c.image;
option.dataset.status = c.status;
option.dataset.created = c.created;
containerSelect.appendChild(option);
});
// Restore selection if still valid
if (currentVal && containerSelect.querySelector(`option[value="${currentVal}"]`)) {
containerSelect.value = currentVal;
loadContainerInfo(currentVal);
}
} catch (err) {
console.error('Failed to load containers:', err);
}
}
// Load container info
function loadContainerInfo(containerId) {
const option = containerSelect.querySelector(`option[value="${containerId}"]`);
if (option) {
imageEl.textContent = option.dataset.image || '-';
statusEl.textContent = option.dataset.status || '-';
statusEl.style.color = option.dataset.status === 'running' ? 'var(--ok-fg, #4ade80)' : 'var(--bad-fg, #ef4444)';
createdEl.textContent = formatDate(option.dataset.created);
}
}
// Load logs for selected container
async function loadLogs() {
const containerId = containerSelect.value;
if (!containerId) {
logContent.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';
return;
}
// Stop any existing stream
stopStream();
currentContainerId = containerId;
loadContainerInfo(containerId);
const tail = logTail.value;
const searchTerm = logSearch.value.trim();
logContent.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';
try {
const url = `/api/v1/logs/container/${containerId}${tail !== 'all' ? `?tail=${tail}` : ''}`;
const data = await getJSON(url);
if (data.logs && data.logs.length > 0) {
renderLogs(data.logs, searchTerm);
} else {
logContent.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>';
lineCount.textContent = '0 lines';
filterCount.textContent = '0 filtered';
}
} catch (err) {
logContent.innerHTML = `<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${escapeHtml(err.message)}</div>`;
}
}
// Start streaming logs
function startStream() {
const containerId = containerSelect.value;
if (!containerId) return;
// Stop any existing stream
stopStream();
isStreaming = true;
streamBtn.textContent = '⏹ Stop';
streamStatus.style.display = 'flex';
streamIndicator.textContent = '🟢';
streamText.textContent = 'Connecting...';
const url = `/api/v1/logs/stream/${containerId}`;
eventSource = new EventSource(url);
eventSource.onopen = () => {
streamIndicator.textContent = '🟢';
streamText.textContent = 'Connected - streaming logs';
};
eventSource.onmessage = (event) => {
try {
const log = JSON.parse(event.data);
if (log.error) {
streamIndicator.textContent = '🔴';
streamText.textContent = `Error: ${log.error}`;
return;
}
// Add to current logs
currentLogs.push(log);
filteredLogs.push(log);
// Update counts
lineCount.textContent = `${currentLogs.length} lines`;
filterCount.textContent = `${filteredLogs.length} shown`;
// Append new log entry
const searchTerm = logSearch.value.trim();
if (!searchTerm || (log.text && log.text.toLowerCase().includes(searchTerm.toLowerCase()))) {
const entry = document.createElement('div');
entry.innerHTML = formatLogEntry(log, filteredLogs.length - 1);
const entryDiv = entry.firstElementChild;
entryDiv.style.background = '#1a3a1a';
logContent.appendChild(entryDiv);
// Auto-scroll to bottom
logContent.scrollTop = logContent.scrollHeight;
}
} catch (e) {
console.error('Error parsing log:', e);
}
};
eventSource.onerror = () => {
streamIndicator.textContent = '🔴';
streamText.textContent = 'Disconnected';
isStreaming = false;
streamBtn.textContent = '▶ Stream';
};
// Store the EventSource for cleanup
modal._eventSource = eventSource;
}
// Stop streaming logs
function stopStream() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (modal._eventSource) {
modal._eventSource.close();
modal._eventSource = null;
}
isStreaming = false;
streamBtn.textContent = '▶ Stream';
streamStatus.style.display = 'none';
}
// Download logs as file
function downloadLogs() {
if (!currentLogs || currentLogs.length === 0) {
showNotification('No logs to download', 'error');
return;
}
const containerName = containerSelect.querySelector(`option[value="${currentContainerId}"]`)?.dataset.name || currentContainerId;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `${containerName}-logs-${timestamp}.txt`;
const content = currentLogs.map(log => {
const timestamp = log.timestamp || '';
const stream = log.stream === 'stderr' ? '[ERR]' : '[OUT]';
return `${timestamp ? timestamp + ' ' : ''}${stream} ${log.text}`;
}).join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification(`Downloaded ${currentLogs.length} log lines`, 'success');
}
// Event listeners
containerSelect?.addEventListener('change', () => {
loadLogs();
});
logTail?.addEventListener('change', () => {
loadLogs();
});
refreshBtn?.addEventListener('click', () => {
loadLogs();
});
streamBtn?.addEventListener('click', () => {
if (isStreaming) {
stopStream();
} else {
startStream();
}
});
downloadBtn?.addEventListener('click', () => {
downloadLogs();
});
clearSearchBtn?.addEventListener('click', () => {
logSearch.value = '';
renderLogs(currentLogs, '');
});
logSearch?.addEventListener('input', () => {
// Debounce search
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
renderLogs(currentLogs, logSearch.value.trim());
}, 300);
});
logSearch?.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
logSearch.value = '';
renderLogs(currentLogs, '');
}
});
// Open modal
const openBtn = document.getElementById('view-container-logs');
openBtn?.addEventListener('click', () => {
modal.classList.add('show');
loadContainers();
});
// Close modal handlers
function closeModal() {
stopStream();
modal.classList.remove('show');
}
closeBtn?.addEventListener('click', closeModal);
closeBtn2?.addEventListener('click', closeModal);
// Close on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.classList.contains('show')) {
closeModal();
}
});
// Wire modal (close on backdrop click)
wireModal(modal, null, closeModal);
// Expose for use by service card buttons (grid.js calls openContainerLogsModal)
window.openContainerLogsModal = function(containerId, containerName) {
modal.classList.add('show');
loadContainers().then(() => {
// Try to find and select the container
const option = Array.from(containerSelect.options).find(opt =>
opt.value === containerId || opt.dataset.name === containerName
);
if (option) {
containerSelect.value = option.value;
loadContainerInfo(option.value);
loadLogs();
} else if (containerId) {
// If container not found in list but we have an ID, try loading directly
currentContainerId = containerId;
imageEl.textContent = containerName || containerId;
statusEl.textContent = '-';
createdEl.textContent = '-';
loadLogs();
} else {
// No container ID, just show modal with container list
logContent.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';
}
});
};
})();
+8 -4
View File
@@ -6,12 +6,16 @@
(function(window) {
'use strict';
const debug = (...args) => {
if (window.DASHCADDY_DEBUG) console.log(...args);
};
class DnsTemplateSelector {
constructor(progressTracker) {
this.progressTracker = progressTracker;
this.modal = null;
this.onTemplateSelected = null;
console.log('[DnsTemplateSelector] Module loaded');
debug('[DnsTemplateSelector] Module loaded');
}
/**
@@ -216,7 +220,7 @@
* @private
*/
handleTemplateSelection(template) {
console.log(`[DnsTemplateSelector] Template selected: ${template.id}`);
debug(`[DnsTemplateSelector] Template selected: ${template.id}`);
// Close modal
this.close();
@@ -235,7 +239,7 @@
* @private
*/
handleSetupLater() {
console.log('[DnsTemplateSelector] DNS setup deferred');
debug('[DnsTemplateSelector] DNS setup deferred');
// Mark as deferred in progress tracker
if (this.progressTracker) {
@@ -316,6 +320,6 @@
}
window.DnsTemplateSelector = DnsTemplateSelector;
console.log('[DnsTemplateSelector] Module loaded');
debug('[DnsTemplateSelector] Module loaded');
})(window);
+5 -2
View File
@@ -24,6 +24,9 @@ const DC = {
},
};
// Error handler for tracking issues
const errorHandler = new ErrorHandler();
// ===== GLOBAL SITE CONFIG (loaded from server, cached in localStorage) =====
// Only non-sensitive display preferences are cached; DNS IPs/topology are fetched from API
const _cachedCfg = JSON.parse(localStorage.getItem('dashcaddy_site_config') || 'null');
@@ -160,7 +163,7 @@ async function getCSRFToken() {
csrfToken = data.token;
return csrfToken;
} catch (error) {
console.error('Failed to get CSRF token:', error);
errorHandler.logError('[CSRF] Get Token', error, { function: 'getCSRFToken' });
throw error;
}
}
@@ -185,7 +188,7 @@ async function secureFetch(url, options = {}) {
'X-CSRF-Token': token
};
} catch (error) {
console.error('Failed to add CSRF token to request:', error);
errorHandler.logError('[CSRF] Add to Request', error, { function: 'secureFetch' });
}
}
+8 -4
View File
@@ -6,6 +6,10 @@
(function() {
'use strict';
const debug = (...args) => {
if (window.DASHCADDY_DEBUG) { console.log(...args); }
};
// All modal selectors that can be closed with Escape
const MODAL_SELECTORS = [
'#app-selector-modal',
@@ -39,9 +43,9 @@
// Add global keyboard listener
document.addEventListener('keydown', handleKeyDown);
console.log('[Keyboard Shortcuts] Initialized');
console.log('[Keyboard Shortcuts] Press Ctrl+K to open quick search');
console.log('[Keyboard Shortcuts] Press Escape to close modals');
debug('[Keyboard Shortcuts] Initialized');
debug('[Keyboard Shortcuts] Press Ctrl+K to open quick search');
debug('[Keyboard Shortcuts] Press Escape to close modals');
} catch (e) {
console.warn('[Keyboard Shortcuts] Failed to initialize:', e.message);
}
@@ -599,7 +603,7 @@
}
break;
default:
console.log('[Keyboard Shortcuts] Unknown action:', action);
debug('[Keyboard Shortcuts] Unknown action:', action);
}
} catch (e) {
console.warn('[Keyboard Shortcuts] Error executing action:', e.message);
+1 -1
View File
@@ -11,7 +11,7 @@
es.addEventListener('connected', () => {
reconnectDelay = 1000; // reset backoff
console.log('[SSE] Connected to event stream');
debug('[SSE] Connected to event stream');
});
// Health status changes → update card dots/badges in real time
+3 -2
View File
@@ -1,5 +1,6 @@
// ========== NOTIFICATION SETTINGS ==========
(function() {
const errorHandler = new ErrorHandler();
// Inject modal HTML
injectModal('notifications-modal', `<div id="notifications-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
@@ -255,7 +256,7 @@
document.getElementById('event-deploy-failed').checked = config.events?.deploymentFailed !== false;
}
} catch (error) {
console.error('Failed to load notification config:', error);
errorHandler.logError('[Notifications] Load Config', error, { function: 'loadConfig' });
}
}
@@ -289,7 +290,7 @@
container.innerHTML = '<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>';
}
} catch (error) {
console.error('Failed to load notification history:', error);
errorHandler.logError('[Notifications] Load History', error, { function: 'loadHistory' });
}
}
+22 -18
View File
@@ -9,6 +9,12 @@
(function() {
'use strict';
const debug = (...args) => {
if (window.DASHCADDY_DEBUG) {
console.log(...args);
}
};
let progressTracker;
let themeAdapter;
let tourManager;
@@ -20,7 +26,7 @@
*/
async function initializeOnboarding() {
try {
console.log('[Onboarding] Initializing system...');
debug('[Onboarding] Initializing system...');
if (window.__dashcaddySiteConfigLoaded) {
try {
@@ -30,27 +36,27 @@
// Initialize Error Handler first
errorHandler = new ErrorHandler();
console.log('[Onboarding] Error Handler initialized');
debug('[Onboarding] Error Handler initialized');
// Initialize Progress Tracker
progressTracker = new ProgressTracker('dashcaddy_onboarding');
console.log('[Onboarding] Progress Tracker initialized');
debug('[Onboarding] Progress Tracker initialized');
// Initialize Theme Adapter
themeAdapter = new ThemeAdapter();
console.log('[Onboarding] Theme Adapter initialized');
debug('[Onboarding] Theme Adapter initialized');
// Initialize DNS Template Selector
dnsTemplateSelector = new DnsTemplateSelector(progressTracker);
console.log('[Onboarding] DNS Template Selector initialized');
debug('[Onboarding] DNS Template Selector initialized');
// Initialize Tour Manager
tourManager = new TourManager(progressTracker, themeAdapter, dnsTemplateSelector);
console.log('[Onboarding] Tour Manager initialized');
debug('[Onboarding] Tour Manager initialized');
// Check if tour should auto-start
if (tourManager.shouldAutoStart()) {
console.log('[Onboarding] Auto-starting tour for first-time install');
debug('[Onboarding] Auto-starting tour for first-time install');
await progressTracker.markInstallOnboardingCompleted();
// Wait a bit for page to fully load
setTimeout(() => {
@@ -59,11 +65,11 @@
} else {
const tourCompleted = progressTracker.isTourCompleted();
const currentStep = progressTracker.getCurrentStep();
console.log(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`);
debug(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`);
// If tour is in progress, offer to resume
if (!tourCompleted && currentStep > 0) {
console.log('[Onboarding] Tour in progress, can be resumed manually');
debug('[Onboarding] Tour in progress, can be resumed manually');
}
}
@@ -81,13 +87,10 @@
getErrorStats: () => errorHandler.getStatistics()
};
console.log('[Onboarding] System initialized successfully');
debug('[Onboarding] System initialized successfully');
} catch (error) {
console.error('[Onboarding] Initialization error:', error);
// Use error handler if available
if (errorHandler) {
errorHandler.logError('Initialization', error);
errorHandler.logError('[Onboarding] Initialization', error);
}
// Graceful degradation - don't break the dashboard
@@ -104,10 +107,12 @@
const clickHandler = () => {
if (tourManager) {
console.log('[Onboarding] Starting tour via button click');
debug('[Onboarding] Starting tour via button click');
tourManager.restartTour();
} else {
console.error('[Onboarding] Tour manager not initialized');
if (errorHandler) {
errorHandler.logError('[Onboarding] Tour Manager Not Initialized', new Error('Tour manager not initialized'));
}
alert('Tour is not available. Check browser console for errors.\n\nPossible issues:\n- Driver.js library failed to load\n- JavaScript errors during initialization');
}
};
@@ -157,7 +162,6 @@
setTimeout(attemptInit, 500);
} else {
// Max retries reached, show fallback
console.error('[Onboarding] Driver.js failed to load after multiple attempts');
if (errorHandler) {
errorHandler.handleDriverLoadFailure();
} else {
@@ -179,6 +183,6 @@
waitForDriver();
}
console.log('[Onboarding] System loaded');
debug('[Onboarding] System loaded');
})();
+11 -5
View File
@@ -18,6 +18,12 @@
(function(window) {
'use strict';
const errorHandler = new ErrorHandler();
const debug = (...args) => {
if (window.DASHCADDY_DEBUG) { console.log(...args); }
};
/**
* ProgressTracker class
* Manages persistent storage of onboarding progress
@@ -68,7 +74,7 @@
const data = localStorage.getItem(this.storageKey);
return data ? JSON.parse(data) : null;
} catch (error) {
console.error('[ProgressTracker] Error reading from storage:', error);
errorHandler.logError('[ProgressTracker] Read Storage', error, { function: '_getStorage' });
return null;
}
}
@@ -82,7 +88,7 @@
try {
localStorage.setItem(this.storageKey, JSON.stringify(state));
} catch (error) {
console.error('[ProgressTracker] Error writing to storage:', error);
errorHandler.logError('[ProgressTracker] Write Storage', error, { function: '_setStorage' });
// Handle quota exceeded or storage unavailable
// Fall back to session storage or in-memory storage
this._handleStorageError(error);
@@ -100,7 +106,7 @@
sessionStorage.setItem(this.storageKey, JSON.stringify(this._getStorage()));
console.warn('[ProgressTracker] Falling back to session storage');
} catch (sessionError) {
console.error('[ProgressTracker] Session storage also unavailable:', sessionError);
errorHandler.logError('[ProgressTracker] Session Storage Unavailable', sessionError, { function: '_handleStorageError' });
// Could implement in-memory fallback here if needed
}
}
@@ -186,7 +192,7 @@
body: JSON.stringify({ onboardingCompleted: true })
});
} catch (error) {
console.error('[ProgressTracker] Failed to persist install onboarding state:', error);
errorHandler.logError('[ProgressTracker] Persist Install Onboarding', error, { function: 'markInstallOnboardingCompleted' });
}
}
@@ -308,6 +314,6 @@
// Export to global scope
window.ProgressTracker = ProgressTracker;
console.log('[ProgressTracker] Module loaded');
debug('[ProgressTracker] Module loaded');
})(window);
+3 -2
View File
@@ -1,5 +1,6 @@
// ===== SERVICE CREDENTIALS =====
(function() {
const errorHandler = 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>📂 Browse for Media Folders</h3>
@@ -433,7 +434,7 @@
await loadServiceCreds(currentService);
} catch (e) {
console.error('Failed to save credentials:', e);
errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' });
showError('Failed to save: ' + (e.message || 'Unknown error'));
}
saveBtn.textContent = 'Save';
@@ -460,7 +461,7 @@
if (btn) btn.classList.remove('has-creds');
await loadServiceCreds(currentService);
} catch (e) {
console.error('Failed to clear credentials:', e);
errorHandler.logError('[ServiceCredentials] Clear', e, { function: 'clearCredentials' });
showError('Failed to clear: ' + (e.message || 'Unknown error'));
}
});
+57
View File
@@ -0,0 +1,57 @@
// ========== SERVICE FILTER ==========
(function() {
const searchInput = document.getElementById('service-filter-search');
const statusSelect = document.getElementById('service-filter-status');
const countSpan = document.getElementById('service-filter-count');
function updateFilter() {
const query = searchInput.value.toLowerCase().trim();
const statusFilter = statusSelect.value; // 'all', 'on', or 'off'
const cards = document.querySelectorAll('#cards .card');
let visibleCount = 0;
cards.forEach(card => {
const name = card.querySelector('.name')?.textContent?.toLowerCase() || '';
const app = card.dataset.app?.toLowerCase() || '';
const status = card.dataset.status || 'off'; // 'on' or 'off'
const matchesSearch = !query || name.includes(query) || app.includes(query);
const matchesStatus = statusFilter === 'all' || status === statusFilter;
if (matchesSearch && matchesStatus) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
if (countSpan) {
const total = cards.length;
countSpan.textContent = `${visibleCount} of ${total} services`;
}
}
// Debounce helper
function debounce(fn, delay) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => fn.apply(this, args), delay);
};
}
searchInput?.addEventListener('input', debounce(updateFilter, 200));
statusSelect?.addEventListener('change', updateFilter);
// Initial count on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(updateFilter, 500));
} else {
setTimeout(updateFilter, 500);
}
// Expose for external triggers
window.refreshServiceFilter = updateFilter;
})();
+4 -2
View File
@@ -1,4 +1,6 @@
// Shared timezone utility — used by setup wizard and settings modal
const errorHandler = new ErrorHandler();
window.populateTimezoneSelect = function(selectEl, selectedTz) {
const timezones = Intl.supportedValuesOf('timeZone');
const detected = selectedTz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
@@ -150,11 +152,11 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
await response.json();
return true;
} else {
console.error('Failed to save config to server:', response.status);
errorHandler.logError('[SetupWizard] Save Config', new Error(`Server returned ${response.status}`), { function: 'saveConfigToServer' });
return false;
}
} catch (error) {
console.error('Error saving config to server:', error);
errorHandler.logError('[SetupWizard] Save Config', error, { function: 'saveConfigToServer' });
return false;
}
}
+180
View File
@@ -0,0 +1,180 @@
// ========== CONTAINER SNAPSHOT / CHECKPOINT ==========
(function() {
// Inject modal HTML
injectModal('snapshot-modal', `<div id="snapshot-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 850px;">
<h3>💾 Container Snapshots</h3>
<p class="modal-subtitle">
Create and manage Docker container checkpoints for instant state recovery.
</p>
<div id="snapshot-container-select-wrapper" style="margin-bottom: 16px;">
<label style="font-size: 0.85rem; color: var(--muted);">Select Container:</label>
<select id="snapshot-container-select" style="width: 100%; padding: 8px 12px; margin-top: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
<option value="">-- Select a container --</option>
</select>
</div>
<div id="snapshot-details" style="display: none; margin-bottom: 16px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--card-hover);">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 0.85rem;">
<div><span style="color: var(--muted);">Image:</span> <span id="snapshot-image"></span></div>
<div><span style="color: var(--muted);">Status:</span> <span id="snapshot-status"></span></div>
<div><span style="color: var(--muted);">Created:</span> <span id="snapshot-created"></span></div>
<div><span style="color: var(--muted);">Container ID:</span> <span id="snapshot-id" style="font-family: monospace;"></span></div>
</div>
</div>
<div class="panel-tabs" style="margin-bottom: 12px;">
<button class="panel-tab active" data-panel="snapshot-create">Create Snapshot</button>
<button class="panel-tab" data-panel="snapshot-list">Manage Snapshots</button>
</div>
<div id="snapshot-create" class="panel-section active">
<div style="margin-bottom: 12px;">
<label style="font-size: 0.85rem; color: var(--muted);">Snapshot Name:</label>
<input type="text" id="snapshot-name" placeholder="e.g., before-update-2024"
style="width: 100%; padding: 8px 12px; margin-top: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem; box-sizing: border-box;" />
</div>
<div style="margin-bottom: 12px;">
<label class="checkbox-label" style="font-size: 0.85rem;">
<input type="checkbox" id="snapshot-leave-running" checked />
Leave container running after checkpoint (resume without restart)
</label>
</div>
<button id="snapshot-create-btn" class="btn-accent-solid" style="width: 100%;">💾 Create Snapshot</button>
<div id="snapshot-create-status" style="margin-top: 12px; text-align: center; font-size: 0.85rem;"></div>
</div>
<div id="snapshot-list" class="panel-section" style="display: none;">
<div id="snapshot-list-container" class="scroll-container" style="max-height: 300px;">
<div class="panel-empty"><span class="empty-icon">💾</span>Select a container to view its snapshots</div>
</div>
</div>
<div class="weather-modal-buttons modal-footer-bar">
<button id="snapshot-close">Close</button>
</div>
</div>
</div>`);
const modal = document.getElementById('snapshot-modal');
const openBtn = document.getElementById('snapshot-btn');
const closeBtn = document.getElementById('snapshot-close');
const containerSelect = document.getElementById('snapshot-container-select');
const detailsDiv = document.getElementById('snapshot-details');
const createBtn = document.getElementById('snapshot-create-btn');
const createStatus = document.getElementById('snapshot-create-status');
let currentContainerId = null;
async function loadContainers() {
try {
const res = await fetch('/api/v1/containers');
const data = await res.json();
if (!data.success || !data.containers) return;
containerSelect.innerHTML = '<option value="">-- Select a container --</option>';
for (const c of data.containers) {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = `${c.name || c.id} (${c.image || 'unknown'})`;
opt.dataset.name = c.name;
opt.dataset.image = c.image;
opt.dataset.status = c.status;
opt.dataset.created = c.created;
containerSelect.appendChild(opt);
}
} catch (e) {
console.error('Failed to load containers:', e);
}
}
function showContainerDetails(opt) {
if (!opt || !opt.value) {
detailsDiv.style.display = 'none';
currentContainerId = null;
return;
}
currentContainerId = opt.value;
document.getElementById('snapshot-image').textContent = opt.dataset.image || '-';
document.getElementById('snapshot-status').textContent = opt.dataset.status || '-';
document.getElementById('snapshot-created').textContent = opt.dataset.created ? new Date(opt.dataset.created * 1000).toLocaleString() : '-';
document.getElementById('snapshot-id').textContent = opt.value.substring(0, 12);
detailsDiv.style.display = '';
}
async function createSnapshot() {
if (!currentContainerId) {
createStatus.textContent = 'Please select a container first';
createStatus.style.color = 'var(--bad-fg)';
return;
}
const name = document.getElementById('snapshot-name').value.trim();
if (!name) {
createStatus.textContent = 'Please enter a snapshot name';
createStatus.style.color = 'var(--bad-fg)';
return;
}
const leaveRunning = document.getElementById('snapshot-leave-running').checked;
createBtn.disabled = true;
createBtn.textContent = 'Creating...';
createStatus.textContent = '';
try {
const res = await fetch(`/api/v1/containers/${encodeURIComponent(currentContainerId)}/checkpoint`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, leaveRunning })
});
const data = await res.json();
if (data.success) {
createStatus.textContent = `✓ Snapshot "${name}" created successfully`;
createStatus.style.color = 'var(--ok-fg)';
document.getElementById('snapshot-name').value = '';
} else {
createStatus.textContent = `✗ Failed: ${data.error || 'Unknown error'}`;
createStatus.style.color = 'var(--bad-fg)';
}
} catch (e) {
createStatus.textContent = `✗ Error: ${e.message}`;
createStatus.style.color = 'var(--bad-fg)';
} finally {
createBtn.disabled = false;
createBtn.textContent = '💾 Create Snapshot';
}
}
function openModal() {
modal.classList.add('show');
loadContainers();
}
function closeModal() {
modal.classList.remove('show');
detailsDiv.style.display = 'none';
currentContainerId = null;
containerSelect.selectedIndex = 0;
}
openBtn?.addEventListener('click', openModal);
closeBtn?.addEventListener('click', closeModal);
wireModal(modal, closeBtn);
containerSelect?.addEventListener('change', (e) => {
const opt = containerSelect.options[containerSelect.selectedIndex];
showContainerDetails(opt);
});
createBtn?.addEventListener('click', createSnapshot);
// Tab switching
modal?.querySelectorAll('.panel-tab').forEach(tab => {
tab.addEventListener('click', () => {
modal.querySelectorAll('.panel-tab').forEach(t => t.classList.remove('active'));
modal.querySelectorAll('.panel-section').forEach(s => s.classList.remove('active'));
tab.classList.add('active');
modal.querySelector(`#${tab.dataset.panel}`).classList.add('active');
});
});
})();
+11 -5
View File
@@ -7,6 +7,12 @@
(function(window) {
'use strict';
const errorHandler = new ErrorHandler();
const debug = (...args) => {
if (window.DASHCADDY_DEBUG) { console.log(...args); }
};
/**
* Theme configuration mapping for Driver.js
* Maps dashboard themes to Driver.js styling
@@ -170,7 +176,7 @@
attributeFilter: ['class']
});
console.log('[ThemeAdapter] Theme change listener initialized');
debug('[ThemeAdapter] Theme change listener initialized');
}
/**
@@ -180,13 +186,13 @@
* @param {string} oldTheme - Old theme name
*/
_notifyThemeChange(newTheme, oldTheme) {
console.log(`[ThemeAdapter] Theme changed: ${oldTheme}${newTheme}`);
debug(`[ThemeAdapter] Theme changed: ${oldTheme}${newTheme}`);
this.themeChangeCallbacks.forEach(callback => {
try {
callback(newTheme, oldTheme);
} catch (error) {
console.error('[ThemeAdapter] Error in theme change callback:', error);
errorHandler.logError('[ThemeAdapter] Theme Change Callback', error, { function: '_notifyThemeChange' });
}
});
}
@@ -207,7 +213,7 @@
// Note: Driver.js v1.0+ uses CSS variables, so we inject a style element
this._injectDriverStyles(themeConfig);
console.log('[ThemeAdapter] Theme applied to driver:', this.currentTheme);
debug('[ThemeAdapter] Theme applied to driver:', this.currentTheme);
}
/**
@@ -302,6 +308,6 @@
// Export to global scope
window.ThemeAdapter = ThemeAdapter;
console.log('[ThemeAdapter] Module loaded');
debug('[ThemeAdapter] Module loaded');
})(window);
+10 -4
View File
@@ -6,6 +6,12 @@
(function(window) {
'use strict';
const errorHandler = new ErrorHandler();
const debug = (...args) => {
if (window.DASHCADDY_DEBUG) { console.log(...args); }
};
/**
* Validate a tooltip definition
* @param {Object} tooltip - The tooltip definition to validate
@@ -147,7 +153,7 @@
`${e.tooltip}: ${e.errors.join(', ')}`
).join('\n');
console.error('[TooltipDefinitions] Validation errors:', errorMessages);
errorHandler.logError('[TooltipDefinitions] Validation', errorMessages, { function: 'validateTooltip' });
throw new TooltipError(`Tooltip validation failed:\n${errorMessages}`);
}
}
@@ -160,7 +166,7 @@
TooltipError
};
console.log('[TooltipDefinitions] Validation module loaded');
debug('[TooltipDefinitions] Validation module loaded');
})(window);
@@ -489,7 +495,7 @@ function getActiveTooltips() {
try {
return tooltip.condition();
} catch (error) {
console.error(`[TooltipDefinitions] Error evaluating condition for ${tooltip.id}:`, error);
errorHandler.logError('[TooltipDefinitions] Condition Eval', error, { function: 'evaluateCondition', tooltipId: tooltip.id });
return false;
}
}
@@ -534,5 +540,5 @@ window.TooltipDefinitions = {
getNewFeatureTooltips
};
console.log('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips');
debug('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips');
+5 -4
View File
@@ -1,5 +1,6 @@
// ===== TOTP SETTINGS =====
(function() {
const errorHandler = new ErrorHandler();
injectModal('totp-settings-modal', `<div id="totp-settings-modal" class="weather-modal">
<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>
@@ -185,7 +186,7 @@
document.getElementById('totp-setup-code').focus();
}
} catch (e) {
console.error('TOTP setup failed:', e);
errorHandler.logError('[TOTP] Setup Failed', e, { function: 'setupTOTP' });
}
});
@@ -274,7 +275,7 @@
});
loadTotpSettings(); // Refresh modal + card (handles "never" disabling TOTP)
} catch (err) {
console.error('Failed to update session duration:', err);
errorHandler.logError('[TOTP] Update Session Duration', err, { function: 'updateSessionDuration' });
}
});
@@ -290,7 +291,7 @@
const data = await res.json();
if (data.success) loadTotpSettings();
} catch (e) {
console.error('Failed to disable TOTP:', e);
errorHandler.logError('[TOTP] Disable Failed', e, { function: 'disableTOTP' });
}
});
@@ -322,7 +323,7 @@
const active = data.config.enabled && data.config.isSetUp;
updateAuthCard(active, data.config.sessionDuration);
}
} catch (e) { console.error('[AuthCard] Failed to update:', e); }
} catch (e) { errorHandler.logError('[TOTP] AuthCard Update', e, { function: 'authCardUpdate' }); }
})();
})();
+18 -12
View File
@@ -6,6 +6,12 @@
(function(window) {
'use strict';
const errorHandler = new ErrorHandler();
const debug = (...args) => {
if (window.DASHCADDY_DEBUG) { console.log(...args); }
};
class TourManager {
constructor(progressTracker, themeAdapter, dnsTemplateSelector) {
this.progressTracker = progressTracker;
@@ -26,7 +32,7 @@
const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver;
if (typeof driverFactory !== 'function') {
console.error('[TourManager] Driver.js not loaded or invalid. window.driver:', window.driver);
errorHandler.logError('[TourManager] Driver.js Not Loaded', new Error('Driver.js not loaded or invalid'), { windowDriver: typeof window.driver });
return false;
}
@@ -92,7 +98,7 @@
const activeTooltips = allTooltips.filter(t => !completedIds.includes(t.id));
if (activeTooltips.length === 0) {
console.log('[TourManager] No tooltips to show');
debug('[TourManager] No tooltips to show');
this.progressTracker.markTourCompleted();
return;
}
@@ -131,7 +137,7 @@
// Add custom handlers for DNS tooltip
if (tooltip.id === 'dns-priority' && this.dnsTemplateSelector) {
step.popover.onSetupNowClick = () => {
console.log('[TourManager] Opening DNS template selector');
debug('[TourManager] Opening DNS template selector');
this.dnsTemplateSelector.showTemplateSelector();
// Mark tooltip as completed and move to next
this.progressTracker.markTooltipCompleted(tooltip.id);
@@ -141,7 +147,7 @@
};
step.popover.onLaterClick = () => {
console.log('[TourManager] DNS setup deferred');
debug('[TourManager] DNS setup deferred');
this.progressTracker.markDnsSetupDeferred();
// Mark tooltip as completed and move to next
this.progressTracker.markTooltipCompleted(tooltip.id);
@@ -198,7 +204,7 @@
async showTooltip(tooltipId) {
const tooltip = window.TooltipDefinitions.getTooltipById(tooltipId);
if (!tooltip) {
console.error(`[TourManager] Tooltip not found: ${tooltipId}`);
errorHandler.logError('[TourManager] Tooltip Not Found', new Error(`Tooltip not found: ${tooltipId}`), { tooltipId });
return;
}
@@ -232,11 +238,11 @@
const newFeatureTooltips = window.TooltipDefinitions.getNewFeatureTooltips();
if (newFeatureTooltips.length === 0) {
console.log('[TourManager] No new features to show');
debug('[TourManager] No new features to show');
return;
}
console.log(`[TourManager] Showing ${newFeatureTooltips.length} new features`);
debug(`[TourManager] Showing ${newFeatureTooltips.length} new features`);
// Convert to Driver.js steps
const steps = newFeatureTooltips.map((tooltip, index) => {
@@ -280,7 +286,7 @@
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
if (this.isActive && this.driver) {
console.log('[TourManager] Window resized, repositioning tooltip');
debug('[TourManager] Window resized, repositioning tooltip');
this.driver.refresh();
}
}, 150); // Debounce for 150ms
@@ -289,7 +295,7 @@
// Layout change handler (for theme changes, DOM mutations)
this.layoutChangeHandler = () => {
if (this.isActive && this.driver) {
console.log('[TourManager] Layout changed, repositioning tooltip');
debug('[TourManager] Layout changed, repositioning tooltip');
// Small delay to allow layout to settle
setTimeout(() => {
if (this.driver) {
@@ -347,7 +353,7 @@
onTourComplete() {
this.progressTracker.markTourCompleted();
this.isActive = false;
console.log('[TourManager] Tour completed');
debug('[TourManager] Tour completed');
}
/**
@@ -355,12 +361,12 @@
*/
onTourSkip() {
// Save current progress but don't mark as completed
console.log('[TourManager] Tour skipped');
debug('[TourManager] Tour skipped');
this.isActive = false;
}
}
window.TourManager = TourManager;
console.log('[TourManager] Module loaded');
debug('[TourManager] Module loaded');
})(window);
+7 -2
View File
@@ -397,7 +397,7 @@
}
async function dcApplyUpdate() {
if (!confirm('Apply DashCaddy update? The API container will restart.')) return;
if (!confirm('Apply DashCaddy update? The API container will restart.')) return false;
dcApplyBtn.textContent = 'Updating...';
dcApplyBtn.disabled = true;
dcShowStatus('Downloading and applying update...', 'info');
@@ -409,6 +409,7 @@
dcApplyBtn.textContent = 'Applied!';
// Remove notification dots
document.querySelectorAll('.update-dot').forEach(d => d.remove());
return true;
} else {
throw new Error(data.error || 'Update failed');
}
@@ -416,6 +417,7 @@
dcShowStatus('Update failed: ' + e.message, 'error');
dcApplyBtn.textContent = 'Update Now';
dcApplyBtn.disabled = false;
throw e;
}
}
@@ -487,7 +489,7 @@
}
dcCheckBtn?.addEventListener('click', () => dcCheckForUpdate(false));
dcApplyBtn?.addEventListener('click', dcApplyUpdate);
dcApplyBtn?.addEventListener('click', () => dcApplyUpdate().catch(() => {}));
dcRollbackBtn?.addEventListener('click', dcShowRollback);
checkBtn?.addEventListener('click', checkForUpdates);
@@ -506,6 +508,9 @@
if (!dcLastCheck) dcCheckForUpdate(true);
});
window.dcApplyUpdate = dcApplyUpdate;
window.dcCheckForUpdate = dcCheckForUpdate;
// Non-blocking check on page load — just adds notification dot if update available
setTimeout(() => dcCheckForUpdate(true), 5000);
})();
+2 -1
View File
@@ -1,5 +1,6 @@
// ========== WEATHER WIDGET ==========
(function() {
const errorHandler = new ErrorHandler();
// Inject modal HTML
injectModal('weather-modal', `<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
<label for="weather-location-input">Location:</label>
@@ -166,7 +167,7 @@
weatherWidget.icon.innerHTML = `<span class="weather-emoji">${escapeHtml(weather.icon)}</span>`;
}
} catch (error) {
console.error('Weather update error:', error);
errorHandler.logError('[Weather] Update Error', error, { function: 'updateWeather' });
weatherWidget.location.textContent = 'Weather Error';
weatherWidget.temp.textContent = 'Error';
weatherWidget.condition.textContent = 'Failed to load';