quartermaster: fold PM + company tools into one command post (2.4.0)

- new Quartermaster.jsx: the admin panel is now amerc's internal station —
  a command-post dashboard (live member/agent/online/session counts +
  quick links to git, docs, pm workspace, webagent, tavern) plus tabs for
  Tenants/keys/company (AdminConsole), Whiteboards, and Netdisk
- Scene2D #/admin renders Quartermaster (AdminConsole no longer imported
  directly); pm.amerc.ai stays as a standalone too
- verified locally: first signup=admin, all four tabs render

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
artheru 2026-06-11 09:35:34 +08:00
parent 6b83b661fa
commit e4dba9d264
5 changed files with 146 additions and 8 deletions

View File

@ -17,7 +17,7 @@
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
</style>
<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="2.3.0-villa" />
<meta name="version" content="2.4.0-quartermaster" />
<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" />

View File

@ -1,6 +1,6 @@
{
"name": "amerc-site",
"version": "2.3.0-villa",
"version": "2.4.0-quartermaster",
"private": true,
"type": "module",
"scripts": {

114
src/Quartermaster.jsx Normal file
View File

@ -0,0 +1,114 @@
import React, { useCallback, useEffect, useState } from 'react';
import { AdminConsole } from './Auth.jsx';
import Netdisk from './Netdisk.jsx';
import Whiteboard from './Whiteboard.jsx';
import { api } from './api.js';
// The Quartermaster amerc's internal management station. One panel for
// members, agents, companies & products, whiteboards, netdisk, and quick
// links to every company tool (git, docs, pm, webagent).
const LINKS = [
{ ico: '🗡️', label: 'Git', sub: 'source · Gitea', href: 'https://git.amerc.ai/' },
{ ico: '📄', label: 'Docs', sub: 'guides + API reference', href: 'https://docs.amerc.ai/' },
{ ico: '🧠', label: 'PM workspace', sub: 'standalone pm.amerc.ai', href: 'https://pm.amerc.ai/' },
{ ico: '🤖', label: 'Webagent', sub: 'relay health', href: 'https://webagent.amerc.ai/health' },
{ ico: '🏰', label: 'Tavern', sub: 'the public site', href: 'https://amerc.ai/' },
];
function Boards() {
const [boards, setBoards] = useState([]);
const [open, setOpen] = useState(null);
const [err, setErr] = useState('');
const load = useCallback(async () => { try { const d = await api('/boards'); setBoards(d.boards || []); } catch (e) { setErr(e.message); } }, []);
useEffect(() => { load(); }, [load]);
const create = async () => {
const name = window.prompt('Whiteboard name', 'New mindmap'); if (!name) return;
try { const r = await api('/boards', { method: 'POST', body: { name, data: JSON.stringify({ nodes: [], edges: [] }) } }); await load(); setOpen(r.id); } catch (e) { setErr(e.message); }
};
const del = async (id) => { if (!window.confirm('Delete board?')) return; try { await api(`/boards/${id}`, { method: 'DELETE' }); load(); } catch (e) { setErr(e.message); } };
if (open) return <Whiteboard boardId={open} onClose={() => { setOpen(null); load(); }} />;
return (
<div className="pm-cards">
<div className="pm-cards-head"><h2>Whiteboards</h2><button className="cs-btn cs-primary" onClick={create}>+ New mindmap</button></div>
{err && <p className="cs-err">{err}</p>}
<div className="pm-grid">
{boards.map((b) => (
<div key={b.id} className="pm-card" onClick={() => setOpen(b.id)}>
<div className="pm-card-ico">🧠</div>
<strong>{b.name}</strong>
<span>{(JSON.parse(b.links || '[]') || []).length} linked files</span>
<small>updated {new Date(b.updated_at).toLocaleString()} · {b.updated_by}</small>
<button className="pm-card-del" onClick={(e) => { e.stopPropagation(); del(b.id); }}></button>
</div>
))}
{!boards.length && <p className="cs-hint">No whiteboards yet. Create a mindmap and link netdisk files to its nodes.</p>}
</div>
</div>
);
}
function CommandPost() {
const [stats, setStats] = useState(null);
const [members, setMembers] = useState(null);
useEffect(() => {
api('/agents/stats').then(setStats).catch(() => {});
api('/admin/users').then((d) => setMembers((d.users || []).length)).catch(() => {});
}, []);
const S = [
['members', members], ['agent classes', stats?.classes], ['online now', stats?.online], ['live sessions', stats?.sessions],
];
return (
<div className="qm-post">
<div className="qm-stats">
{S.map(([label, v]) => <div key={label} className="qm-stat"><b>{v ?? '·'}</b><span>{label}</span></div>)}
</div>
<h4 className="ap-h4">Company tools</h4>
<div className="qm-links">
{LINKS.map((l) => (
<a key={l.label} className="qm-link" href={l.href} target="_blank" rel="noreferrer">
<span className="qm-link-ico" aria-hidden="true">{l.ico}</span>
<div className="qm-link-t"><strong>{l.label}</strong><span>{l.sub}</span></div>
<span className="qm-link-go" aria-hidden="true"></span>
</a>
))}
</div>
</div>
);
}
const QTABS = [
{ key: 'post', label: '⚜ Command post' },
{ key: 'ops', label: '🛡 Tenants · keys · company' },
{ key: 'boards', label: '🧠 Whiteboards' },
{ key: 'disk', label: '🗄 Netdisk' },
];
export default function Quartermaster({ auth }) {
const [tab, setTab] = useState('post');
if (!auth.ready) return <div className="ap"><p>Loading</p></div>;
if (!auth.user) return (
<div className="px-admin px-admin-gate">
<h3>Backdoor sealed</h3><p>Quartermaster credentials required.</p>
<button className="px-action" onClick={() => { window.location.hash = '#/signin'; }}>Go to Login</button>
</div>
);
if (auth.user.role !== 'admin') return <div className="px-admin px-admin-gate"><h3>Not authorised</h3><p>{auth.user.handle} is a member, not Quartermaster.</p></div>;
return (
<div className="qm">
<div className="qm-head">
<span className="qm-tag"> QUARTERMASTER</span>
<h2>amerc command post</h2>
<p>The company's internal station members, agents, companies &amp; products, whiteboards, netdisk, and every tool in one place.</p>
</div>
<div className="qm-tabs">
{QTABS.map((t) => <button key={t.key} className={tab === t.key ? 'on' : ''} onClick={() => setTab(t.key)} type="button">{t.label}</button>)}
</div>
<div className="qm-body">
{tab === 'post' && <CommandPost />}
{tab === 'ops' && <AdminConsole auth={auth} />}
{tab === 'boards' && <Boards />}
{tab === 'disk' && <div className="pm-disk"><h2>Netdisk</h2><Netdisk /></div>}
</div>
</div>
);
}

View File

@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useAuth, AuthPanel, AdminConsole } from './Auth.jsx';
import { useAuth, AuthPanel } from './Auth.jsx';
import Quartermaster from './Quartermaster.jsx';
import { AgentBrowse, LegionDashboard } from './AgentPlatform.jsx';
import Mansion from './Mansion.jsx';
import Account from './Account.jsx';
@ -11,7 +12,7 @@ import { api } from './api.js';
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
const RELEASE = '2.3.0-villa';
const RELEASE = '2.4.0-quartermaster';
const NAV = [
{ id: 'home', label: 'Tavern' },
@ -476,6 +477,7 @@ function StatusPage() {
}
const CHANGELOG = [
{ v: '2.4', date: 'Jun 2026', title: 'The Quartermaster command post', notes: ['The admin Quartermaster is now amercs internal management station — members, agents, companies & products, whiteboards and netdisk all in one panel', 'A command-post dashboard with live counts and quick links to git, docs, the PM workspace and the webagent relay'] },
{ v: '2.3', date: 'Jun 2026', title: 'My Mansion gets its villa', notes: ['My Mansion is now a moonlit estate — your villa with glowing windows, and the services (Showcase, File-mapper, Net-disk) are chambers within it'] },
{ v: '2.2', date: 'Jun 2026', title: 'My Legion becomes a war camp', notes: ['Your classes are banners and each instance is a soldier — alive and bobbing when online, standing by when pending, fallen when down', 'Tap a soldier to command it: chat with your own running agent, watch its live terminal, or stand it down', 'Statuses refresh on their own so the camp stays current'] },
{ v: '2.1', date: 'Jun 2026', title: 'The roster comes through the door', notes: ['Browse Agents is now a doorway — the mercenaries descend and hang on wooden sign-bubbles showing what each one does', 'Open one and its detail opens in an ornate war-banner dialog; instances are shown as a squad — living soldiers when online, fallen when down'] },
@ -557,10 +559,7 @@ function Scene({ route, setRoute, auth }) {
if (route === 'status') return <Page setRoute={setRoute}><StatusPage /></Page>;
if (route === 'changelog') return <Page setRoute={setRoute}><ChangelogPage /></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>
<AdminConsole auth={auth} />
</div></Page>;
if (route === 'admin') return <Page setRoute={setRoute}><Quartermaster auth={auth} /></Page>;
return <Home setRoute={setRoute} />;
}

View File

@ -1831,3 +1831,28 @@ button {
.mn-villa-body p { margin: 0 0 6px; color: #9b8fb8; font-size: 13.5px; line-height: 1.5; max-width: 540px; }
.mn-wing-title { font-family: Georgia, serif; color: #cbb8e8; font-size: 16px; margin: 4px 2px 12px; letter-spacing: 0.5px; }
@media (max-width: 680px) { .mn-villa { flex-direction: column; align-items: center; } .mn-villa-scene { width: 100%; max-width: 320px; } .mn-villa-body { align-items: center; text-align: center; } .mn-villa-tag { align-self: center; } }
/* ===== Quartermaster = the command post (2.4) ===== */
.qm { max-width: 940px; margin: 0 auto; }
.qm-head { margin-bottom: 14px; }
.qm-tag { font-size: 10px; letter-spacing: 1.6px; color: #ffd75e; background: rgba(255,215,94,0.08); border: 1px solid #5a4a1c; padding: 3px 10px; border-radius: 999px; }
.qm-head h2 { margin: 8px 0 4px; font-family: Georgia, serif; font-size: 24px; color: #f3e9d2; }
.qm-head p { margin: 0; color: #9b8fb8; font-size: 13px; max-width: 640px; }
.qm-tabs { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 16px; border-bottom: 1px solid #2a2440; padding-bottom: 12px; }
.qm-tabs button { background: #11101e; border: 1px solid #2a2440; color: #b6a7d4; border-radius: 9px; padding: 7px 14px; font-size: 12.5px; cursor: pointer; font-family: inherit; }
.qm-tabs button.on { background: #2a2360; border-color: #6a5a9a; color: #fff; }
.qm-tabs button:hover:not(.on) { border-color: #6a5a9a; }
.qm-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; margin-bottom: 22px; }
.qm-stat { background: linear-gradient(180deg, #15102a, #0d0a18); border: 1px solid #2a2440; border-radius: 12px; padding: 16px; text-align: center; }
.qm-stat b { display: block; font-family: Georgia, serif; font-size: 30px; color: #7fe9ff; }
.qm-stat span { color: #8a7bb0; font-size: 11.5px; }
.qm-links { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 11px; margin-top: 8px; }
.qm-link { display: flex; align-items: center; gap: 12px; background: #11101e; border: 1px solid #2a2440; border-radius: 11px; padding: 12px 14px; text-decoration: none; transition: border-color 0.14s, transform 0.14s; }
.qm-link:hover { border-color: #46c8e0; transform: translateY(-2px); }
.qm-link-ico { font-size: 24px; }
.qm-link-t { flex: 1; display: flex; flex-direction: column; gap: 1px; }
.qm-link-t strong { color: #eee6ff; font-size: 13.5px; }
.qm-link-t span { color: #8a7bb0; font-size: 11px; }
.qm-link-go { color: #46c8e0; font-size: 15px; }
.qm-body .px-admin { position: static; transform: none; width: 100%; margin: 0; }
.qm-body .pm-disk { padding: 0; }