Files
Duriin-API/public/admin/assets/js/ops/core.js
T
ImBenjiandClaude Opus 5 93108a27fe fix: convert style strings for react in the ops console
htm passes props through untouched and react rejects a style string with error
#62, so every view rendered an empty page. Converting once in the createElement
wrapper keeps plain css in the templates instead of style objects everywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnNxwxfXSbeNtjvtz5gayb
2026-09-01 20:20:27 +01:00

137 lines
5.2 KiB
JavaScript

// Shared plumbing: htm binding, hash router, polling fetch, formatters.
const { createElement, useState, useEffect, useRef, useCallback } = React;
// htm hands props straight through, and React refuses a style string (error #62).
// Writing style objects everywhere in tagged templates is miserable, so convert
// once here instead and let the views keep using plain css.
function cssToObject(css) {
const style = {};
for (const declaration of String(css).split(';')) {
const split = declaration.indexOf(':');
if (split === -1) continue;
const property = declaration.slice(0, split).trim();
const value = declaration.slice(split + 1).trim();
if (!property || !value) continue;
// custom properties have to stay verbatim, the rest camelCase
style[property.startsWith('--') ? property : property.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value;
}
return style;
}
function h(type, props, ...children) {
if (props && typeof props.style === 'string') {
return createElement(type, { ...props, style: cssToObject(props.style) }, ...children);
}
return createElement(type, props, ...children);
}
export const html = htm.bind(h);
export { useState, useEffect, useRef, useCallback };
export async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
body: options.body ? JSON.stringify(options.body) : undefined,
});
const text = await response.text();
let parsed = null;
try { parsed = text ? JSON.parse(text) : null; } catch (error) {
// A non JSON body from an API that always speaks JSON means something upstream
// failed, so surface the raw text rather than a parse error nobody can act on.
console.error('[ops] non-JSON response from', path, error.message);
throw new Error(text.slice(0, 200) || `HTTP ${response.status}`);
}
if (!response.ok) throw new Error((parsed && parsed.error) || `HTTP ${response.status}`);
return parsed;
}
// Poll without the spinner flash: keep showing the previous payload while the next
// one is in flight, and never let a slow response overwrite a newer one.
export function usePoll(path, intervalMs = 5000) {
const [state, setState] = useState({ data: null, error: null, loading: true, at: null });
const seq = useRef(0);
const alive = useRef(true);
const refresh = useCallback(async () => {
const mine = ++seq.current;
try {
const data = await api(path);
if (!alive.current || mine !== seq.current) return;
setState({ data, error: null, loading: false, at: Date.now() });
} catch (error) {
if (!alive.current || mine !== seq.current) return;
console.error('[ops] poll failed for', path, error.message);
setState((prev) => ({ ...prev, error: error.message, loading: false }));
}
}, [path]);
useEffect(() => {
alive.current = true;
refresh();
const timer = setInterval(refresh, intervalMs);
const onVisible = () => { if (!document.hidden) refresh(); };
document.addEventListener('visibilitychange', onVisible);
return () => {
alive.current = false;
clearInterval(timer);
document.removeEventListener('visibilitychange', onVisible);
};
}, [refresh, intervalMs]);
return { ...state, refresh };
}
export function useHashRoute(fallback) {
const read = () => (location.hash || '').replace(/^#\/?/, '') || fallback;
const [route, setRoute] = useState(read);
useEffect(() => {
const onHash = () => setRoute(read());
addEventListener('hashchange', onHash);
return () => removeEventListener('hashchange', onHash);
}, []);
return route;
}
/* ---------- formatting ---------- */
export const num = (value) => (value === null || value === undefined || Number.isNaN(Number(value))
? '—' : Number(value).toLocaleString());
export function compact(value) {
const n = Number(value);
if (!Number.isFinite(n)) return '—';
if (Math.abs(n) >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(1) + 'k';
return String(n);
}
export const pct = (value, digits = 1) => (Number.isFinite(Number(value))
? `${(Number(value) * 100).toFixed(digits)}%` : '—');
export function ago(minutes) {
if (minutes === null || minutes === undefined || !Number.isFinite(Number(minutes))) return 'unknown';
const m = Number(minutes);
if (m < 1) return 'just now';
if (m < 60) return `${Math.round(m)}m ago`;
if (m < 60 * 24) return `${(m / 60).toFixed(1)}h ago`;
return `${(m / 1440).toFixed(1)}d ago`;
}
export function whenDate(value) {
if (!value) return '—';
const stamp = String(value).slice(0, 10);
const days = Math.round((Date.parse(`${stamp}T00:00:00Z`) - Date.now()) / 86400000);
if (!Number.isFinite(days)) return stamp;
if (days === 0) return `${stamp} (today)`;
return days > 0 ? `${stamp} (in ${days}d)` : `${stamp} (${-days}d ago)`;
}
export function health(minutes, threshold) {
if (minutes === null || minutes === undefined) return 'idle';
if (!Number.isFinite(Number(threshold))) return 'ok';
const m = Number(minutes);
if (m <= threshold) return 'ok';
return m <= threshold * 3 ? 'warn' : 'bad';
}