diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 3f4f336..a38c19c 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -97,7 +97,7 @@ jobs: if fixes: sections.append('### Bug Fixes\n\n' + '\n'.join(fixes)) notes = '\n\n'.join(sections) or '_No changes_' - body = notes + '\n\n### Image\n\n' + img + ':' + v + body = notes + '\n\n### Image\n\n`' + img + ':' + v + '`' payload = {'tag_name': 'v'+v, 'name': 'Catalyst v'+v, 'body': body, 'draft': False, 'prerelease': False} open('/tmp/release_body.json', 'w').write(json.dumps(payload)) PYEOF diff --git a/css/app.css b/css/app.css index 3779d19..096a54a 100644 --- a/css/app.css +++ b/css/app.css @@ -712,3 +712,55 @@ select:focus { border-color: var(--accent); } 0%, 100% { opacity: 1; } 50% { opacity: 0; } } + +/* ── MOBILE ── */ +@media (max-width: 640px) { + /* Reset desktop zoom — mobile browsers handle scaling themselves */ + html { zoom: 1; } + + /* Nav */ + nav { padding: 0 16px; } + + /* Dashboard header */ + .dash-header { padding: 18px 16px 14px; } + + /* Stats bar */ + .stat-cell { padding: 10px 16px; } + + /* Toolbar — search full-width on first row, filters + button below */ + .toolbar { flex-wrap: wrap; padding: 10px 16px; gap: 8px; } + .search-wrap { max-width: 100%; } + .toolbar-right { margin-left: 0; width: 100%; justify-content: flex-end; } + + /* Instance grid — single column */ + .instance-grid { + grid-template-columns: 1fr; + padding: 12px 16px; + gap: 8px; + } + + /* Detail page */ + .detail-page { padding: 16px; } + + /* Detail header — stack title block above actions */ + .detail-header { flex-direction: column; align-items: flex-start; gap: 14px; } + + /* Detail sub — wrap items when they don't fit */ + .detail-sub { flex-wrap: wrap; row-gap: 4px; } + + /* Detail grid — single column */ + .detail-grid { grid-template-columns: 1fr; } + + /* Toggle grid — 2 columns instead of 3 */ + .toggle-grid { grid-template-columns: 1fr 1fr; } + + /* Confirm box — no fixed width on mobile */ + .confirm-box { width: auto; max-width: calc(100vw - 32px); padding: 18px; } + + /* History timeline — stack timestamp above event */ + .tl-item { flex-direction: column; align-items: flex-start; gap: 3px; } + .tl-time { order: -1; } + + /* Toast — stretch across bottom */ + .toast { right: 16px; left: 16px; bottom: 16px; } +} diff --git a/js/ui.js b/js/ui.js index 865ec77..09b8807 100644 --- a/js/ui.js +++ b/js/ui.js @@ -384,11 +384,11 @@ async function importDB() { document.getElementById('confirm-ok').onclick = async () => { closeConfirm(); try { - const { instances } = JSON.parse(await file.text()); + const { instances, history = [] } = JSON.parse(await file.text()); const res = await fetch('/api/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ instances }), + body: JSON.stringify({ instances, history }), }); const data = await res.json(); if (!res.ok) { showToast(data.error ?? 'Import failed', 'error'); return; } diff --git a/js/version.js b/js/version.js index cfcbe6a..7f8fb21 100644 --- a/js/version.js +++ b/js/version.js @@ -1 +1 @@ -const VERSION = "1.3.1"; +const VERSION = "1.4.0"; diff --git a/package.json b/package.json index 0862dbb..252f229 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "catalyst", - "version": "1.3.1", + "version": "1.4.0", "type": "module", "scripts": { "start": "node server/server.js", diff --git a/server/db.js b/server/db.js index 1eaf6ab..645f852 100644 --- a/server/db.js +++ b/server/db.js @@ -155,7 +155,7 @@ export function deleteInstance(vmid) { db.prepare('DELETE FROM instances WHERE vmid = ?').run(vmid); } -export function importInstances(rows) { +export function importInstances(rows, historyRows = []) { db.exec('BEGIN'); db.exec('DELETE FROM instance_history'); db.exec('DELETE FROM instances'); @@ -168,6 +168,12 @@ export function importInstances(rows) { @tailscale, @andromeda, @tailscale_ip, @hardware_acceleration) `); for (const row of rows) insert.run(row); + if (historyRows.length) { + const insertHist = db.prepare( + `INSERT INTO instance_history (vmid, field, old_value, new_value, changed_at) VALUES (?, ?, ?, ?, ?)` + ); + for (const h of historyRows) insertHist.run(h.vmid, h.field, h.old_value ?? null, h.new_value ?? null, h.changed_at); + } db.exec('COMMIT'); } @@ -177,6 +183,10 @@ export function getInstanceHistory(vmid) { ).all(vmid); } +export function getAllHistory() { + return db.prepare('SELECT * FROM instance_history ORDER BY vmid, changed_at').all(); +} + // ── Test helpers ────────────────────────────────────────────────────────────── export function _resetForTest() { diff --git a/server/routes.js b/server/routes.js index c48ef93..eb77abf 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1,7 +1,7 @@ import { Router } from 'express'; import { getInstances, getInstance, getDistinctStacks, - createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, + createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, getAllHistory, } from './db.js'; export const router = Router(); @@ -116,14 +116,15 @@ router.put('/instances/:vmid', (req, res) => { // GET /api/export router.get('/export', (_req, res) => { const instances = getInstances(); + const history = getAllHistory(); const date = new Date().toISOString().slice(0, 10); res.setHeader('Content-Disposition', `attachment; filename="catalyst-backup-${date}.json"`); - res.json({ version: 1, exported_at: new Date().toISOString(), instances }); + res.json({ version: 2, exported_at: new Date().toISOString(), instances, history }); }); // POST /api/import router.post('/import', (req, res) => { - const { instances } = req.body ?? {}; + const { instances, history = [] } = req.body ?? {}; if (!Array.isArray(instances)) { return res.status(400).json({ error: 'body must contain an instances array' }); } @@ -134,7 +135,7 @@ router.post('/import', (req, res) => { } if (errors.length) return res.status(400).json({ errors }); try { - importInstances(instances.map(normalise)); + importInstances(instances.map(normalise), Array.isArray(history) ? history : []); res.json({ imported: instances.length }); } catch (e) { console.error('POST /api/import', e); diff --git a/tests/api.test.js b/tests/api.test.js index 66852ce..2e9d8f5 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -275,6 +275,18 @@ describe('GET /api/export', () => { const res = await request(app).get('/api/export') expect(res.body.instances).toEqual([]) }) + + it('returns version 2', async () => { + const res = await request(app).get('/api/export') + expect(res.body.version).toBe(2) + }) + + it('includes a history array', async () => { + await request(app).post('/api/instances').send(base) + const res = await request(app).get('/api/export') + expect(res.body.history).toBeInstanceOf(Array) + expect(res.body.history.some(e => e.field === 'created')).toBe(true) + }) }) // ── POST /api/import ────────────────────────────────────────────────────────── @@ -309,6 +321,26 @@ describe('POST /api/import', () => { .send({ instances: [{ ...base, name: undefined, vmid: 1 }] }) expect(res.status).toBe(400) }) + + it('restores history when history array is provided', async () => { + await request(app).post('/api/instances').send(base) + const exp = await request(app).get('/api/export') + await request(app).post('/api/instances').send({ ...base, vmid: 999, name: 'other' }) + const res = await request(app).post('/api/import').send({ + instances: exp.body.instances, + history: exp.body.history, + }) + expect(res.status).toBe(200) + const hist = await request(app).get('/api/instances/100/history') + expect(hist.body.some(e => e.field === 'created')).toBe(true) + }) + + it('succeeds with a v1 backup that has no history key', async () => { + const res = await request(app).post('/api/import') + .send({ instances: [{ ...base, vmid: 1, name: 'legacy' }] }) + expect(res.status).toBe(200) + expect(res.body.imported).toBe(1) + }) }) // ── Static assets & SPA routing ─────────────────────────────────────────────── diff --git a/tests/db.test.js b/tests/db.test.js index ee64d3a..fee0400 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -202,6 +202,15 @@ describe('importInstances', () => { importInstances([{ ...base, name: 'new', vmid: 2 }]); expect(getInstanceHistory(1)).toHaveLength(0); }); + + it('restores history rows when provided', () => { + importInstances( + [{ ...base, name: 'a', vmid: 1 }], + [{ vmid: 1, field: 'created', old_value: null, new_value: null, changed_at: '2026-01-01 00:00:00' }] + ); + const h = getInstanceHistory(1); + expect(h.some(e => e.field === 'created')).toBe(true); + }); }); // ── instance history ─────────────────────────────────────────────────────────