live system status page (0.46.0)
- new #/status route: probes Tavern/API/Docs/Git/Webagent and shows a per-service dashboard (pulsing beacon, colored pills, latency, state) - same-origin /api/health gives real status+latency; cross-origin services use a no-cors reachability probe with a 6s timeout - auto re-checks every 20s + manual Re-check; honest footer note on method - linked from the footer Platform column - audited PM (whiteboards/netdisk/portfolio) via a throwaway admin — all clean, no changes needed; account removed after - verified live: all 5 services operational
This commit is contained in:
parent
c0d67f5c3f
commit
84a37bb7ed
@ -4,7 +4,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0e0a14" />
|
||||
<meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." />
|
||||
<meta name="version" content="0.45.0-motion" />
|
||||
<meta name="version" content="0.46.0-status" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "amerc-site",
|
||||
"version": "0.45.0-motion",
|
||||
"version": "0.46.0-status",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -7,7 +7,7 @@ import Account from './Account.jsx';
|
||||
import TavernGame from './TavernGame.jsx';
|
||||
import { api } from './api.js';
|
||||
|
||||
const RELEASE = '0.45.0-motion';
|
||||
const RELEASE = '0.46.0-status';
|
||||
|
||||
const NAV = [
|
||||
{ id: 'home', label: 'Tavern' },
|
||||
@ -15,7 +15,7 @@ const NAV = [
|
||||
{ id: 'mansion', label: 'Mansion' },
|
||||
{ id: 'booth', label: 'My Booth' },
|
||||
];
|
||||
const VALID_ROUTES = ['home', 'agents', 'mansion', 'booth', 'account', 'admin', 'signin'];
|
||||
const VALID_ROUTES = ['home', 'agents', 'mansion', 'booth', 'account', 'admin', 'signin', 'status'];
|
||||
|
||||
function useHashRoute() {
|
||||
const normalize = () => {
|
||||
@ -209,6 +209,7 @@ function Footer({ setRoute }) {
|
||||
<div className="ft-col"><h5>Platform</h5>
|
||||
<a href="https://git.amerc.ai/">Git</a>
|
||||
<a href="https://webagent.amerc.ai/health">Webagent</a>
|
||||
<button onClick={() => setRoute('status')}>Status</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ft-base">© 2026 amerc · agent mercenary tavern · v{RELEASE}</div>
|
||||
@ -254,6 +255,65 @@ function Gatehouse({ auth, setRoute }) {
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_TARGETS = [
|
||||
{ key: 'web', name: 'Tavern (web)', url: 'https://amerc.ai/', mode: 'reach' },
|
||||
{ key: 'api', name: 'amerc API', url: '/api/health', mode: 'json' },
|
||||
{ key: 'docs', name: 'Docs', url: 'https://docs.amerc.ai/', mode: 'reach' },
|
||||
{ key: 'git', name: 'Git', url: 'https://git.amerc.ai/', mode: 'reach' },
|
||||
{ key: 'agent', name: 'Webagent relay', url: 'https://webagent.amerc.ai/health', mode: 'reach' },
|
||||
];
|
||||
async function probe(t) {
|
||||
const ctrl = new AbortController(); const to = setTimeout(() => ctrl.abort(), 6000); const t0 = performance.now();
|
||||
try {
|
||||
if (t.mode === 'json') { const r = await fetch(t.url, { signal: ctrl.signal, cache: 'no-store' }); return { ok: r.ok, ms: Math.round(performance.now() - t0) }; }
|
||||
await fetch(t.url, { mode: 'no-cors', signal: ctrl.signal, cache: 'no-store' }); return { ok: true, ms: Math.round(performance.now() - t0) };
|
||||
} catch { return { ok: false, ms: Math.round(performance.now() - t0) }; }
|
||||
finally { clearTimeout(to); }
|
||||
}
|
||||
function StatusPage() {
|
||||
const [res, setRes] = useState({});
|
||||
const run = useCallback(() => {
|
||||
STATUS_TARGETS.forEach(async (t) => {
|
||||
setRes((r) => ({ ...r, [t.key]: { ...r[t.key], loading: true } }));
|
||||
const o = await probe(t);
|
||||
setRes((r) => ({ ...r, [t.key]: { ...o, loading: false } }));
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => { run(); const id = setInterval(run, 20000); return () => clearInterval(id); }, [run]);
|
||||
const known = STATUS_TARGETS.map((t) => res[t.key]).filter((x) => x && !x.loading);
|
||||
const allUp = known.length === STATUS_TARGETS.length && known.every((x) => x.ok);
|
||||
const anyDown = known.some((x) => !x.ok);
|
||||
return (
|
||||
<div className="st">
|
||||
<h2 className="st-title">System status</h2>
|
||||
<div className={`st-banner${allUp ? ' up' : anyDown ? ' down' : ''}`}>
|
||||
<span className="st-beacon" />
|
||||
<div className="st-banner-txt">
|
||||
<strong>{!known.length ? 'Checking services…' : allUp ? 'All systems operational' : anyDown ? 'Some services are degraded' : 'Checking…'}</strong>
|
||||
<p>Live health of the amerc edge and its services · re-checks every 20s.</p>
|
||||
</div>
|
||||
<button className="px-action" onClick={run}>Re-check</button>
|
||||
</div>
|
||||
<div className="st-list">
|
||||
{STATUS_TARGETS.map((t) => {
|
||||
const o = res[t.key] || {};
|
||||
const state = o.loading || o.ok == null ? 'checking' : o.ok ? 'up' : 'down';
|
||||
return (
|
||||
<div key={t.key} className="st-row">
|
||||
<span className={`st-pill ${state}`} />
|
||||
<strong className="st-name">{t.name}</strong>
|
||||
<span className="st-host">{t.url.replace('https://', '').replace(/\/$/, '')}</span>
|
||||
<span className="st-ms">{o.ms != null && !o.loading ? `${o.ms} ms` : '·'}</span>
|
||||
<span className={`st-state ${state}`}>{state === 'up' ? 'operational' : state === 'down' ? 'unreachable' : 'checking…'}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="st-foot">amerc runs on a single edge node — nginx, a zero-dependency Node API, Gitea, and the webagent + showcase relays. Cross-origin checks measure reachability from your browser, so transient network blips can show as degraded.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Page({ children, setRoute }) { return <div className="td-page"><div className="td-page-inner">{children}</div><Footer setRoute={setRoute} /></div>; }
|
||||
|
||||
function Scene({ route, setRoute, auth }) {
|
||||
@ -262,6 +322,7 @@ function Scene({ route, setRoute, auth }) {
|
||||
if (route === 'mansion') return <Page setRoute={setRoute}><Mansion auth={auth} setRoute={setRoute} /></Page>;
|
||||
if (route === 'booth') return <Page setRoute={setRoute}><BoothDashboard auth={auth} setRoute={setRoute} /></Page>;
|
||||
if (route === 'account') return <Page setRoute={setRoute}><Account auth={auth} setRoute={setRoute} /></Page>;
|
||||
if (route === 'status') return <Page setRoute={setRoute}><StatusPage /></Page>;
|
||||
if (route === 'signin') return <Page setRoute={setRoute}><Gatehouse auth={auth} setRoute={setRoute} /></Page>;
|
||||
if (route === 'admin') return <Page setRoute={setRoute}><div className="px-board" style={{ position: 'static', transform: 'none', width: 'min(720px,94vw)', margin: '0 auto' }}>
|
||||
<header className="px-board-head"><span className="px-board-tag">BACKDOOR</span><h2>Quartermaster</h2><p>Tenant, key, session and risk controls.</p></header>
|
||||
|
||||
@ -1274,3 +1274,30 @@ button {
|
||||
.reveal:not(.in) { opacity: 0; transform: translateY(18px); }
|
||||
.reveal.in { opacity: 1; }
|
||||
@media (prefers-reduced-motion: reduce) { .reveal, .reveal:not(.in) { opacity: 1 !important; transform: none !important; transition: none !important; } }
|
||||
|
||||
/* system status page (0.46) */
|
||||
.st { max-width: 760px; margin: 0 auto; }
|
||||
.st-title { font-family: Georgia, "Times New Roman", serif; font-size: 24px; color: #fff; margin: 0 0 16px; }
|
||||
.st-banner { display: flex; align-items: center; gap: 14px; padding: 16px 18px; border-radius: 14px; margin-bottom: 16px;
|
||||
background: linear-gradient(180deg, #160f24, #120c1d); border: 1px solid #2a1f3e; }
|
||||
.st-banner.up { border-color: #2f6e52; box-shadow: inset 0 0 0 1px rgba(54,240,176,0.08); }
|
||||
.st-banner.down { border-color: #7a3030; box-shadow: inset 0 0 0 1px rgba(255,122,140,0.1); }
|
||||
.st-banner-txt { flex: 1; }
|
||||
.st-banner-txt strong { display: block; color: #fff; font-size: 16px; }
|
||||
.st-banner-txt p { margin: 3px 0 0; color: #9d8fc0; font-size: 12.5px; }
|
||||
.st-beacon { width: 12px; height: 12px; border-radius: 50%; background: #f2b85f; box-shadow: 0 0 0 0 rgba(242,184,95,0.5); animation: mnpulse 2s infinite; }
|
||||
.st-banner.up .st-beacon { background: #36f0b0; box-shadow: 0 0 0 0 rgba(54,240,176,0.5); }
|
||||
.st-banner.down .st-beacon { background: #ff7a8c; box-shadow: 0 0 0 0 rgba(255,122,140,0.5); animation: none; }
|
||||
.st-list { display: flex; flex-direction: column; gap: 1px; background: #241a36; border: 1px solid #241a36; border-radius: 12px; overflow: hidden; }
|
||||
.st-row { display: flex; align-items: center; gap: 12px; padding: 13px 16px; background: #130d20; }
|
||||
.st-pill { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; }
|
||||
.st-pill.up { background: #36f0b0; box-shadow: 0 0 8px rgba(54,240,176,0.6); }
|
||||
.st-pill.down { background: #ff7a8c; box-shadow: 0 0 8px rgba(255,122,140,0.5); }
|
||||
.st-pill.checking { background: #6a5a88; }
|
||||
.st-name { color: #eaf2ff; font-size: 14px; min-width: 130px; }
|
||||
.st-host { color: #7a6a98; font-size: 12px; font-family: ui-monospace, monospace; flex: 1; }
|
||||
.st-ms { color: #9d8fc0; font-size: 12px; font-variant-numeric: tabular-nums; min-width: 56px; text-align: right; }
|
||||
.st-state { font-size: 11px; font-weight: 600; letter-spacing: 0.3px; min-width: 92px; text-align: right; }
|
||||
.st-state.up { color: #36f0b0; } .st-state.down { color: #ff7a8c; } .st-state.checking { color: #b6a7d4; }
|
||||
.st-foot { color: #7a6a98; font-size: 12px; line-height: 1.6; margin: 14px 2px 0; }
|
||||
@media (max-width: 560px) { .st-host { display: none; } .st-name { min-width: 0; flex: 1; } }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user