feat(update): add release policy checks and dashboard version verification
This commit is contained in:
@@ -14,8 +14,8 @@ const fs = require('fs');
|
|||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const os = require('os');
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
const zlib = require('zlib');
|
|
||||||
const platformPaths = require('./platform-paths');
|
const platformPaths = require('./platform-paths');
|
||||||
const isWindows = platformPaths.isWindows;
|
const isWindows = platformPaths.isWindows;
|
||||||
|
|
||||||
@@ -31,6 +31,10 @@ const DEFAULTS = {
|
|||||||
MAX_BACKUPS: 3,
|
MAX_BACKUPS: 3,
|
||||||
HEALTH_TIMEOUT: 60000,
|
HEALTH_TIMEOUT: 60000,
|
||||||
DOWNLOAD_TIMEOUT: 120000,
|
DOWNLOAD_TIMEOUT: 120000,
|
||||||
|
CHANNEL: 'stable',
|
||||||
|
INSTANCE_ID_FILE: platformPaths.isWindows
|
||||||
|
? path.join(platformPaths.caddyBase, 'instance-id')
|
||||||
|
: '/etc/dashcaddy/instance-id',
|
||||||
};
|
};
|
||||||
|
|
||||||
class SelfUpdater extends EventEmitter {
|
class SelfUpdater extends EventEmitter {
|
||||||
@@ -48,12 +52,15 @@ class SelfUpdater extends EventEmitter {
|
|||||||
apiSourceDir: options.apiSourceDir || DEFAULTS.API_SOURCE_DIR,
|
apiSourceDir: options.apiSourceDir || DEFAULTS.API_SOURCE_DIR,
|
||||||
frontendDir: options.frontendDir || DEFAULTS.FRONTEND_DIR,
|
frontendDir: options.frontendDir || DEFAULTS.FRONTEND_DIR,
|
||||||
maxBackups: parseInt(options.maxBackups || DEFAULTS.MAX_BACKUPS, 10),
|
maxBackups: parseInt(options.maxBackups || DEFAULTS.MAX_BACKUPS, 10),
|
||||||
|
channel: options.channel || process.env.DASHCADDY_UPDATE_CHANNEL || DEFAULTS.CHANNEL,
|
||||||
|
instanceIdFile: options.instanceIdFile || process.env.DASHCADDY_INSTANCE_ID_FILE || DEFAULTS.INSTANCE_ID_FILE,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.status = 'idle'; // idle | checking | downloading | applying | waiting
|
this.status = 'idle'; // idle | checking | downloading | applying | waiting
|
||||||
this.checkTimer = null;
|
this.checkTimer = null;
|
||||||
this.lastCheckTime = null;
|
this.lastCheckTime = null;
|
||||||
this.lastCheckResult = null;
|
this.lastCheckResult = null;
|
||||||
|
this.instanceId = this._loadOrCreateInstanceId();
|
||||||
|
|
||||||
// Ensure directories exist
|
// Ensure directories exist
|
||||||
this._ensureDirs();
|
this._ensureDirs();
|
||||||
@@ -80,7 +87,7 @@ class SelfUpdater extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Version Info ──
|
// ── Version / Identity Info ──
|
||||||
|
|
||||||
getLocalVersion() {
|
getLocalVersion() {
|
||||||
try {
|
try {
|
||||||
@@ -95,6 +102,18 @@ class SelfUpdater extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getInstanceInfo() {
|
||||||
|
return {
|
||||||
|
instanceId: this.instanceId,
|
||||||
|
channel: this.config.channel,
|
||||||
|
hostname: os.hostname(),
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
isWindows,
|
||||||
|
version: this.getLocalVersion(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
getStatus() {
|
getStatus() {
|
||||||
return this.status;
|
return this.status;
|
||||||
}
|
}
|
||||||
@@ -122,10 +141,18 @@ class SelfUpdater extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const local = this.getLocalVersion();
|
const local = this.getLocalVersion();
|
||||||
const available = this._isNewer(local, remote);
|
const policy = this._evaluateReleasePolicy(local, remote);
|
||||||
|
const available = policy.eligible && policy.newer;
|
||||||
|
|
||||||
this.lastCheckTime = Date.now();
|
this.lastCheckTime = Date.now();
|
||||||
this.lastCheckResult = { available, local, remote, sourceUrl };
|
this.lastCheckResult = {
|
||||||
|
available,
|
||||||
|
local,
|
||||||
|
remote,
|
||||||
|
sourceUrl,
|
||||||
|
policy,
|
||||||
|
instance: this.getInstanceInfo(),
|
||||||
|
};
|
||||||
this.status = 'idle';
|
this.status = 'idle';
|
||||||
|
|
||||||
if (available) {
|
if (available) {
|
||||||
@@ -149,12 +176,17 @@ class SelfUpdater extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const local = this.getLocalVersion();
|
const local = this.getLocalVersion();
|
||||||
|
const policy = this._evaluateReleasePolicy(local, remoteInfo);
|
||||||
|
if (!policy.eligible) {
|
||||||
|
throw new Error(`Release not eligible for this instance: ${policy.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
const stagingDir = path.join(this.config.updatesDir, 'staging');
|
const stagingDir = path.join(this.config.updatesDir, 'staging');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Download (try primary, fallback to mirror)
|
// 1. Download (try primary, fallback to mirror)
|
||||||
this.status = 'downloading';
|
this.status = 'downloading';
|
||||||
this.emit('update-progress', { step: 'downloading', version: remoteInfo.version });
|
this.emit('update-progress', { step: 'downloading', version: remoteInfo.version, policy });
|
||||||
|
|
||||||
const tarballPath = path.join(this.config.updatesDir, remoteInfo.tarball);
|
const tarballPath = path.join(this.config.updatesDir, remoteInfo.tarball);
|
||||||
const primaryUrl = `${this.config.updateUrl}/${remoteInfo.tarball}`;
|
const primaryUrl = `${this.config.updateUrl}/${remoteInfo.tarball}`;
|
||||||
@@ -207,6 +239,8 @@ class SelfUpdater extends EventEmitter {
|
|||||||
stagingDir: hostApiSrc,
|
stagingDir: hostApiSrc,
|
||||||
apiSourceDir: this.config.apiSourceDir,
|
apiSourceDir: this.config.apiSourceDir,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
channel: this.config.channel,
|
||||||
|
instanceId: this.instanceId,
|
||||||
};
|
};
|
||||||
await fsp.writeFile(
|
await fsp.writeFile(
|
||||||
path.join(this.config.updatesDir, 'trigger.json'),
|
path.join(this.config.updatesDir, 'trigger.json'),
|
||||||
@@ -222,6 +256,8 @@ class SelfUpdater extends EventEmitter {
|
|||||||
status: 'pending',
|
status: 'pending',
|
||||||
frontendUpdated: !!frontendSrc,
|
frontendUpdated: !!frontendSrc,
|
||||||
apiUpdated: true,
|
apiUpdated: true,
|
||||||
|
channel: this.config.channel,
|
||||||
|
instanceId: this.instanceId,
|
||||||
});
|
});
|
||||||
} else if (isWindows) {
|
} else if (isWindows) {
|
||||||
// Windows: frontend updated, API needs manual restart
|
// Windows: frontend updated, API needs manual restart
|
||||||
@@ -233,6 +269,8 @@ class SelfUpdater extends EventEmitter {
|
|||||||
frontendUpdated: !!frontendSrc,
|
frontendUpdated: !!frontendSrc,
|
||||||
apiUpdated: false,
|
apiUpdated: false,
|
||||||
note: 'API update requires manual container restart on Windows',
|
note: 'API update requires manual container restart on Windows',
|
||||||
|
channel: this.config.channel,
|
||||||
|
instanceId: this.instanceId,
|
||||||
});
|
});
|
||||||
this.status = 'idle';
|
this.status = 'idle';
|
||||||
}
|
}
|
||||||
@@ -246,6 +284,7 @@ class SelfUpdater extends EventEmitter {
|
|||||||
toVersion: remoteInfo.version,
|
toVersion: remoteInfo.version,
|
||||||
frontendUpdated: !!frontendSrc,
|
frontendUpdated: !!frontendSrc,
|
||||||
apiUpdated: !isWindows && !!apiSrc,
|
apiUpdated: !isWindows && !!apiSrc,
|
||||||
|
policy,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.status = 'idle';
|
this.status = 'idle';
|
||||||
@@ -255,6 +294,8 @@ class SelfUpdater extends EventEmitter {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
error: e.message,
|
error: e.message,
|
||||||
|
channel: this.config.channel,
|
||||||
|
instanceId: this.instanceId,
|
||||||
});
|
});
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@@ -308,6 +349,8 @@ class SelfUpdater extends EventEmitter {
|
|||||||
stagingDir: hostBackupDir,
|
stagingDir: hostBackupDir,
|
||||||
apiSourceDir: this.config.apiSourceDir,
|
apiSourceDir: this.config.apiSourceDir,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
channel: this.config.channel,
|
||||||
|
instanceId: this.instanceId,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.status = 'waiting';
|
this.status = 'waiting';
|
||||||
@@ -322,6 +365,8 @@ class SelfUpdater extends EventEmitter {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
rollback: true,
|
rollback: true,
|
||||||
|
channel: this.config.channel,
|
||||||
|
instanceId: this.instanceId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,20 +407,143 @@ class SelfUpdater extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_evaluateReleasePolicy(local, remote) {
|
||||||
|
const releaseChannel = remote?.channel || remote?.releaseChannel || 'stable';
|
||||||
|
const allowedChannels = Array.isArray(remote?.channels)
|
||||||
|
? remote.channels
|
||||||
|
: Array.isArray(remote?.eligibleChannels)
|
||||||
|
? remote.eligibleChannels
|
||||||
|
: [releaseChannel];
|
||||||
|
|
||||||
|
if (!allowedChannels.includes(this.config.channel)) {
|
||||||
|
return {
|
||||||
|
eligible: false,
|
||||||
|
newer: this._isNewer(local, remote),
|
||||||
|
reason: `channel mismatch (${this.config.channel} not in ${allowedChannels.join(', ')})`,
|
||||||
|
releaseChannel,
|
||||||
|
allowedChannels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remote?.revoked === true) {
|
||||||
|
return {
|
||||||
|
eligible: false,
|
||||||
|
newer: this._isNewer(local, remote),
|
||||||
|
reason: 'release revoked',
|
||||||
|
releaseChannel,
|
||||||
|
allowedChannels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const minUpdaterVersion = remote?.minUpdaterVersion;
|
||||||
|
if (minUpdaterVersion && this._compareVersions(local.version, minUpdaterVersion) < 0) {
|
||||||
|
return {
|
||||||
|
eligible: false,
|
||||||
|
newer: this._isNewer(local, remote),
|
||||||
|
reason: `requires updater >= ${minUpdaterVersion}`,
|
||||||
|
releaseChannel,
|
||||||
|
allowedChannels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rollout = this._normalizeRollout(remote?.rollout);
|
||||||
|
if (rollout < 100) {
|
||||||
|
const bucket = this._getRolloutBucket(this.instanceId);
|
||||||
|
if (bucket >= rollout) {
|
||||||
|
return {
|
||||||
|
eligible: false,
|
||||||
|
newer: this._isNewer(local, remote),
|
||||||
|
reason: `outside rollout (${bucket} >= ${rollout})`,
|
||||||
|
releaseChannel,
|
||||||
|
allowedChannels,
|
||||||
|
rollout,
|
||||||
|
rolloutBucket: bucket,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = remote?.targets;
|
||||||
|
if (targets && typeof targets === 'object') {
|
||||||
|
const platformKey = `${process.platform}-${process.arch}`;
|
||||||
|
const matchedTarget = targets[platformKey] || targets[process.platform] || targets.default;
|
||||||
|
if (!matchedTarget) {
|
||||||
|
return {
|
||||||
|
eligible: false,
|
||||||
|
newer: this._isNewer(local, remote),
|
||||||
|
reason: `no target for ${platformKey}`,
|
||||||
|
releaseChannel,
|
||||||
|
allowedChannels,
|
||||||
|
rollout,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
eligible: true,
|
||||||
|
newer: this._isNewer(local, remote),
|
||||||
|
reason: 'eligible',
|
||||||
|
releaseChannel,
|
||||||
|
allowedChannels,
|
||||||
|
rollout,
|
||||||
|
rolloutBucket: this._getRolloutBucket(this.instanceId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
_isNewer(local, remote) {
|
_isNewer(local, remote) {
|
||||||
if (!remote || !remote.version) return false;
|
if (!remote || !remote.version) return false;
|
||||||
// Compare semver: split into [major, minor, patch]
|
const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version);
|
||||||
const lv = (local.version || '0.0.0').split('.').map(Number);
|
if (versionCompare < 0) return true;
|
||||||
const rv = remote.version.split('.').map(Number);
|
if (versionCompare > 0) return false;
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
if ((rv[i] || 0) > (lv[i] || 0)) return true;
|
|
||||||
if ((rv[i] || 0) < (lv[i] || 0)) return false;
|
|
||||||
}
|
|
||||||
// Same version — check commit hash
|
// Same version — check commit hash
|
||||||
if (remote.commit && local.commit && remote.commit !== local.commit) return true;
|
if (remote.commit && local.commit && remote.commit !== local.commit) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_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);
|
||||||
|
const len = Math.max(av.length, bv.length, 3);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const left = av[i] || 0;
|
||||||
|
const right = bv[i] || 0;
|
||||||
|
if (left > right) return 1;
|
||||||
|
if (left < right) return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_normalizeRollout(value) {
|
||||||
|
if (value == null) return 100;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) return 100;
|
||||||
|
return Math.max(0, Math.min(100, Math.floor(parsed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
_getRolloutBucket(instanceId) {
|
||||||
|
const digest = crypto.createHash('sha256').update(String(instanceId || 'unknown')).digest();
|
||||||
|
return digest[0] % 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadOrCreateInstanceId() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(this.config.instanceIdFile)) {
|
||||||
|
const existing = fs.readFileSync(this.config.instanceIdFile, 'utf8').trim();
|
||||||
|
if (existing) return existing;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Fall through and regenerate
|
||||||
|
}
|
||||||
|
|
||||||
|
const instanceId = crypto.randomUUID();
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(this.config.instanceIdFile), { recursive: true });
|
||||||
|
fs.writeFileSync(this.config.instanceIdFile, `${instanceId}\n`, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[SelfUpdater] Failed to persist instance ID:', error.message);
|
||||||
|
}
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
_addToHistory(entry) {
|
_addToHistory(entry) {
|
||||||
const history = this.getUpdateHistory();
|
const history = this.getUpdateHistory();
|
||||||
history.unshift(entry);
|
history.unshift(entry);
|
||||||
@@ -527,6 +695,9 @@ const selfUpdater = new SelfUpdater({
|
|||||||
hostUpdatesDir: process.env.DASHCADDY_HOST_UPDATES_DIR,
|
hostUpdatesDir: process.env.DASHCADDY_HOST_UPDATES_DIR,
|
||||||
apiSourceDir: process.env.DASHCADDY_API_SOURCE_DIR,
|
apiSourceDir: process.env.DASHCADDY_API_SOURCE_DIR,
|
||||||
frontendDir: process.env.DASHCADDY_FRONTEND_DIR,
|
frontendDir: process.env.DASHCADDY_FRONTEND_DIR,
|
||||||
|
channel: process.env.DASHCADDY_UPDATE_CHANNEL,
|
||||||
|
instanceIdFile: process.env.DASHCADDY_INSTANCE_ID_FILE,
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = selfUpdater;
|
module.exports = selfUpdater;
|
||||||
|
module.exports.SelfUpdater = SelfUpdater;
|
||||||
@@ -167,11 +167,103 @@ button:focus-visible {
|
|||||||
right: 0;
|
right: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reload-caddy-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-version {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 2px;
|
||||||
|
user-select: text;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-version:hover {
|
||||||
|
color: var(--fg);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-info-modal-content {
|
||||||
|
min-width: 420px;
|
||||||
|
max-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-info-subtitle {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-info-status {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-info-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-info-value {
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-info-history h4 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history-entry {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history-status {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history-meta {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.license-status-topbar {
|
.license-status-topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -83,20 +83,36 @@
|
|||||||
|
|
||||||
<!-- License status + Reload Caddy Button - top right corner -->
|
<!-- License status + Reload Caddy Button - top right corner -->
|
||||||
<div class="reload-caddy-container">
|
<div class="reload-caddy-container">
|
||||||
<div class="theme-toggle-group">
|
<div class="reload-caddy-main">
|
||||||
<button id="theme" class="theme-toggle-btn" aria-label="Cycle theme" title="Click to cycle themes">
|
<div class="theme-toggle-group">
|
||||||
<span id="theme-icon">🎨</span> <span id="theme-label">Dark</span>
|
<button id="theme" class="theme-toggle-btn" aria-label="Cycle theme" title="Click to cycle themes">
|
||||||
|
<span id="theme-icon">🎨</span> <span id="theme-label">Dark</span>
|
||||||
|
</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">☆</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>
|
</button>
|
||||||
<button id="theme-customize-btn" class="theme-customize-link" title="Customize theme colors">Customize Theme</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="license-status-topbar" class="license-status-topbar free" title="Click to manage license">
|
<button id="dashboard-version" class="dashboard-version" type="button" title="View DashCaddy verification info" aria-label="View DashCaddy verification info">Version —</button>
|
||||||
<span id="license-topbar-icon">☆</span>
|
</div>
|
||||||
<span id="license-topbar-text">FREE TIER</span>
|
</div>
|
||||||
<span id="license-topbar-time"></span>
|
|
||||||
|
<div id="version-info-modal" class="weather-modal">
|
||||||
|
<div class="weather-modal-content version-info-modal-content">
|
||||||
|
<h3>DashCaddy Verification Info</h3>
|
||||||
|
<p class="version-info-subtitle">Current version details and updater verification status.</p>
|
||||||
|
<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;">
|
||||||
|
<button id="version-info-close">Close</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -538,6 +554,117 @@
|
|||||||
}
|
}
|
||||||
var yr = document.getElementById('footer-year');
|
var yr = document.getElementById('footer-year');
|
||||||
if (yr) yr.textContent = new Date().getFullYear();
|
if (yr) yr.textContent = new Date().getFullYear();
|
||||||
|
|
||||||
|
var versionEl = document.getElementById('dashboard-version');
|
||||||
|
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 versionInfoClose = document.getElementById('version-info-close');
|
||||||
|
|
||||||
|
function formatValue(value) {
|
||||||
|
if (value == null || value === '') return '—';
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value);
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInfoRow(label, value) {
|
||||||
|
return '<div class="version-info-row"><span class="version-info-label">' + label + '</span><span class="version-info-value">' + formatValue(value) + '</span></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistory(history) {
|
||||||
|
if (!Array.isArray(history) || !history.length) {
|
||||||
|
versionInfoHistory.style.display = 'none';
|
||||||
|
versionInfoHistory.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
versionInfoHistory.style.display = '';
|
||||||
|
versionInfoHistory.innerHTML = '<h4>Recent Update History</h4>' + history.slice(0, 5).map(function(entry) {
|
||||||
|
return '<div class="version-history-entry">'
|
||||||
|
+ '<div><strong>' + formatValue(entry.version) + '</strong> <span class="version-history-status">' + formatValue(entry.status) + '</span></div>'
|
||||||
|
+ '<div class="version-history-meta">From ' + formatValue(entry.fromVersion) + ' · ' + formatValue(entry.timestamp) + '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openVersionInfo() {
|
||||||
|
if (!versionInfoModal) return;
|
||||||
|
versionInfoModal.classList.add('show');
|
||||||
|
versionInfoStatus.textContent = 'Loading…';
|
||||||
|
versionInfoGrid.style.display = 'none';
|
||||||
|
versionInfoHistory.style.display = 'none';
|
||||||
|
versionInfoGrid.innerHTML = '';
|
||||||
|
versionInfoHistory.innerHTML = '';
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
fetch('/api/v1/system/version', { cache: 'no-store' }).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) {
|
||||||
|
if (!response.ok) throw new Error('Update status failed');
|
||||||
|
return response.json();
|
||||||
|
}),
|
||||||
|
fetch('/api/v1/system/update-history', { cache: 'no-store' }).then(function(response) {
|
||||||
|
if (!response.ok) throw new Error('Update history failed');
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
]).then(function(results) {
|
||||||
|
var versionData = results[0] || {};
|
||||||
|
var statusData = results[1] || {};
|
||||||
|
var historyData = results[2] || {};
|
||||||
|
var lastResult = statusData.lastResult || {};
|
||||||
|
var lastPolicy = lastResult.policy || {};
|
||||||
|
|
||||||
|
versionInfoStatus.textContent = '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('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('');
|
||||||
|
|
||||||
|
renderHistory(historyData.history);
|
||||||
|
}).catch(function(error) {
|
||||||
|
versionInfoStatus.textContent = 'Could not load verification info: ' + error.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
});
|
||||||
|
|
||||||
|
versionEl.addEventListener('click', openVersionInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionInfoClose && versionInfoModal) {
|
||||||
|
versionInfoClose.addEventListener('click', function() {
|
||||||
|
versionInfoModal.classList.remove('show');
|
||||||
|
});
|
||||||
|
versionInfoModal.addEventListener('click', function(event) {
|
||||||
|
if (event.target === versionInfoModal) {
|
||||||
|
versionInfoModal.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user