// state.js — minimal pub/sub store + hash router + localStorage-backed // job metadata cache. Vanilla, no framework. const JOB_META_KEY = "webgui:jobMeta"; const JOB_META_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days const listeners = new Set(); const state = { remotes: [], // [{ name, type }] jobs: new Map(), // jobid -> { jobid, status, progress, action, src, dst } providers: null, // cached /config/providers response // jobMeta is persisted to localStorage so the jobs view can show // src/dst for jobs after a page reload. Keyed by jobid (number). jobMeta: loadJobMeta(), }; function loadJobMeta() { try { const raw = localStorage.getItem(JOB_META_KEY); if (!raw) return new Map(); const arr = JSON.parse(raw); if (!Array.isArray(arr)) return new Map(); const now = Date.now(); const map = new Map(); for (const [id, meta] of arr) { if (meta && typeof meta.submittedAt === "number") { if (now - meta.submittedAt < JOB_META_TTL_MS) { map.set(Number(id), meta); } } } return map; } catch { return new Map(); } } function saveJobMeta() { try { const arr = Array.from(state.jobMeta.entries()); localStorage.setItem(JOB_META_KEY, JSON.stringify(arr)); } catch { // localStorage might be unavailable (private mode, quota); ignore. } } export function getState() { return state; } export function setState(patch) { Object.assign(state, patch); for (const l of listeners) { try { l(state); } catch (e) { console.error("state listener error", e); } } } export function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); } // --- Job metadata API --- export function rememberJob(jobid, { action, src, dst }) { state.jobMeta.set(Number(jobid), { action, src, dst, submittedAt: Date.now(), }); saveJobMeta(); } export function getJobMeta(jobid) { return state.jobMeta.get(Number(jobid)); } export function listJobMeta() { return Array.from(state.jobMeta.entries()) .map(([jobid, meta]) => ({ jobid: Number(jobid), ...meta })) .sort((a, b) => b.jobid - a.jobid); } export function forgetJob(jobid) { state.jobMeta.delete(Number(jobid)); saveJobMeta(); } // --- Router --- // Hash-based routes: // #/remotes // #/browse// // #/jobs // #/jobs/new // #/configure/new // #/configure/new/ // #/configure/edit/ const routeListeners = new Set(); export function onRoute(fn) { routeListeners.add(fn); return () => routeListeners.delete(fn); } function parseHash(hash) { const raw = hash.replace(/^#\/?/, ""); const parts = raw.split("/").filter(Boolean); if (parts.length === 0) { return { name: "remotes", params: {} }; } switch (parts[0]) { case "remotes": return { name: "remotes", params: {} }; case "browse": return { name: "browse", params: { remote: decodeURIComponent(parts[1] || ""), path: parts.slice(2).map(decodeURIComponent).join("/"), }, }; case "jobs": if (parts[1] === "new") { return { name: "jobs-new", params: {} }; } return { name: "jobs", params: {} }; case "configure": if (parts[1] === "new") { return { name: "configure-new", params: { provider: parts[2] ? decodeURIComponent(parts[2]) : "" }, }; } if (parts[1] === "edit") { return { name: "configure-edit", params: { remote: decodeURIComponent(parts[2] || "") }, }; } return { name: "configure-new", params: { provider: "" } }; default: return { name: "remotes", params: {} }; } } export function navigate(hash) { if (location.hash !== hash) { location.hash = hash; } else { dispatch(); } } function dispatch() { const route = parseHash(location.hash); for (const l of routeListeners) { try { l(route); } catch (e) { console.error("route listener error", e); } } } window.addEventListener("hashchange", dispatch); window.addEventListener("load", dispatch); // --- Toast --- export function toast(message, kind = "default", ttl = 4000) { const stack = document.getElementById("toast-stack"); if (!stack) return; const el = document.createElement("div"); el.className = `toast toast-${kind}`; el.textContent = message; stack.appendChild(el); setTimeout(() => el.remove(), ttl); } // --- Format helpers --- export function formatBytes(n) { if (n == null || isNaN(n)) return "—"; if (n < 1024) return `${n} B`; const units = ["KB", "MB", "GB", "TB", "PB"]; let v = n / 1024; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return `${v.toFixed(1)} ${units[i]}`; } export function formatSpeed(bytesPerSec) { return formatBytes(bytesPerSec) + "/s"; } export function formatDuration(seconds) { if (seconds == null || !isFinite(seconds)) return "—"; const s = Math.round(seconds); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m ${sec}s`; return `${sec}s`; } export function formatTime(iso) { if (!iso) return "—"; try { const d = new Date(iso); return d.toLocaleString(); } catch { return iso; } }