diff --git a/js/db.js b/js/db.js
index e3c69b3..ea0c527 100644
--- a/js/db.js
+++ b/js/db.js
@@ -55,3 +55,8 @@ async function updateInstance(vmid, data) {
async function deleteInstance(vmid) {
await api(`/instances/${vmid}`, { method: 'DELETE' });
}
+
+async function getInstanceHistory(vmid) {
+ const res = await fetch(`${BASE}/instances/${vmid}/history`);
+ return res.json();
+}
diff --git a/js/ui.js b/js/ui.js
index 70f70de..865ec77 100644
--- a/js/ui.js
+++ b/js/ui.js
@@ -3,6 +3,34 @@ let editingVmid = null;
let currentVmid = null;
let toastTimer = null;
+// ── Timezone ──────────────────────────────────────────────────────────────────
+
+const TIMEZONES = [
+ { label: 'UTC', tz: 'UTC' },
+ { label: 'Hawaii (HST)', tz: 'Pacific/Honolulu' },
+ { label: 'Alaska (AKT)', tz: 'America/Anchorage' },
+ { label: 'Pacific (PT)', tz: 'America/Los_Angeles' },
+ { label: 'Mountain (MT)', tz: 'America/Denver' },
+ { label: 'Central (CT)', tz: 'America/Chicago' },
+ { label: 'Eastern (ET)', tz: 'America/New_York' },
+ { label: 'Atlantic (AT)', tz: 'America/Halifax' },
+ { label: 'London (GMT/BST)', tz: 'Europe/London' },
+ { label: 'Paris / Berlin (CET)', tz: 'Europe/Paris' },
+ { label: 'Helsinki (EET)', tz: 'Europe/Helsinki' },
+ { label: 'Istanbul (TRT)', tz: 'Europe/Istanbul' },
+ { label: 'Dubai (GST)', tz: 'Asia/Dubai' },
+ { label: 'India (IST)', tz: 'Asia/Kolkata' },
+ { label: 'Singapore (SGT)', tz: 'Asia/Singapore' },
+ { label: 'China (CST)', tz: 'Asia/Shanghai' },
+ { label: 'Japan / Korea (JST/KST)', tz: 'Asia/Tokyo' },
+ { label: 'Sydney (AEST)', tz: 'Australia/Sydney' },
+ { label: 'Auckland (NZST)', tz: 'Pacific/Auckland' },
+];
+
+function getTimezone() {
+ return localStorage.getItem('catalyst_tz') || 'UTC';
+}
+
// ── Helpers ───────────────────────────────────────────────────────────────────
function esc(str) {
@@ -11,17 +39,25 @@ function esc(str) {
return d.innerHTML;
}
+// SQLite datetime('now') → 'YYYY-MM-DD HH:MM:SS' (UTC, no timezone marker).
+// Appending 'Z' tells JS to parse it as UTC rather than local time.
+function parseUtc(d) {
+ if (typeof d !== 'string') return new Date(d);
+ const hasZone = d.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(d);
+ return new Date(hasZone ? d : d.replace(' ', 'T') + 'Z');
+}
+
function fmtDate(d) {
if (!d) return '—';
try {
- return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
+ return parseUtc(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: getTimezone() });
} catch (e) { return d; }
}
function fmtDateFull(d) {
if (!d) return '—';
try {
- return new Date(d).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
+ return parseUtc(d).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: getTimezone(), timeZoneName: 'short' });
} catch (e) { return d; }
}
@@ -39,7 +75,6 @@ async function renderDashboard() {
deployed
${states['deployed'] || 0}
testing
${states['testing'] || 0}
degraded
${states['degraded'] || 0}
-
stacks
${(await getDistinctStacks()).length}
`;
await populateStackFilter();
@@ -100,10 +135,39 @@ async function filterInstances() {
// ── Detail Page ───────────────────────────────────────────────────────────────
+const BOOL_FIELDS = ['atlas','argus','semaphore','patchmon','tailscale','andromeda','hardware_acceleration'];
+
+const FIELD_LABELS = {
+ name: 'name',
+ state: 'state',
+ stack: 'stack',
+ vmid: 'vmid',
+ tailscale_ip: 'tailscale ip',
+ atlas: 'atlas',
+ argus: 'argus',
+ semaphore: 'semaphore',
+ patchmon: 'patchmon',
+ tailscale: 'tailscale',
+ andromeda: 'andromeda',
+ hardware_acceleration: 'hw acceleration',
+};
+
+function stateClass(field, val) {
+ if (field !== 'state') return '';
+ return { deployed: 'tl-deployed', testing: 'tl-testing', degraded: 'tl-degraded' }[val] ?? '';
+}
+
+function fmtHistVal(field, val) {
+ if (val == null || val === '') return '—';
+ if (BOOL_FIELDS.includes(field)) return val === '1' ? 'on' : 'off';
+ return esc(val);
+}
+
async function renderDetailPage(vmid) {
- const inst = await getInstance(vmid);
+ const [inst, history, all] = await Promise.all([getInstance(vmid), getInstanceHistory(vmid), getInstances()]);
if (!inst) { navigate('dashboard'); return; }
currentVmid = vmid;
+ document.getElementById('nav-count').textContent = `${all.length} instance${all.length !== 1 ? 's' : ''}`;
document.getElementById('detail-vmid-crumb').textContent = vmid;
document.getElementById('detail-name').textContent = inst.name;
@@ -114,7 +178,7 @@ async function renderDetailPage(vmid) {
document.getElementById('detail-identity').innerHTML = `
name${esc(inst.name)}
state${esc(inst.state)}
-
stack${esc(inst.stack) || '—'}
+
stack${esc(inst.stack) || '—'}
vmid${inst.vmid}
internal id${inst.id}
`;
@@ -134,10 +198,30 @@ async function renderDetailPage(vmid) {
`).join('');
- document.getElementById('detail-timestamps').innerHTML = `
-