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:
parent
6b83b661fa
commit
e4dba9d264
@ -17,7 +17,7 @@
|
|||||||
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
|
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
|
||||||
</style>
|
</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="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="/favicon.svg" type="image/svg+xml" />
|
||||||
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
|
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "amerc-site",
|
"name": "amerc-site",
|
||||||
"version": "2.3.0-villa",
|
"version": "2.4.0-quartermaster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
114
src/Quartermaster.jsx
Normal file
114
src/Quartermaster.jsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
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 { AgentBrowse, LegionDashboard } from './AgentPlatform.jsx';
|
||||||
import Mansion from './Mansion.jsx';
|
import Mansion from './Mansion.jsx';
|
||||||
import Account from './Account.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 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 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 = [
|
const NAV = [
|
||||||
{ id: 'home', label: 'Tavern' },
|
{ id: 'home', label: 'Tavern' },
|
||||||
@ -476,6 +477,7 @@ function StatusPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CHANGELOG = [
|
const CHANGELOG = [
|
||||||
|
{ v: '2.4', date: 'Jun 2026', title: 'The Quartermaster command post', notes: ['The admin Quartermaster is now amerc’s 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.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.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'] },
|
{ 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 === 'status') return <Page setRoute={setRoute}><StatusPage /></Page>;
|
||||||
if (route === 'changelog') return <Page setRoute={setRoute}><ChangelogPage /></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 === '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' }}>
|
if (route === 'admin') return <Page setRoute={setRoute}><Quartermaster auth={auth} /></Page>;
|
||||||
<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>;
|
|
||||||
return <Home setRoute={setRoute} />;
|
return <Home setRoute={setRoute} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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-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; }
|
.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; } }
|
@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; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user