Test env NATS cluster cut-over
This commit is contained in:
commit
445a1216c9
62 changed files with 3936 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
data/
|
||||||
|
machines.secrets.json
|
||||||
|
|
||||||
15
NATS.md
Normal file
15
NATS.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# NATS — verae-fleet
|
||||||
|
|
||||||
|
Fleet control **does not subscribe** to JetStream. It only records which workers should.
|
||||||
|
|
||||||
|
| Service | NATS in | NATS out |
|
||||||
|
|---------|---------|----------|
|
||||||
|
| nats (external) | — | — (the broker) |
|
||||||
|
| middleware-http | jobs.events | jobs.watch |
|
||||||
|
| job-poller | jobs.watch | jobs.events |
|
||||||
|
| webhook-deliver | webhooks.deliver, jobs.events | HTTPS to Zapier |
|
||||||
|
| archive-aggregator | archive.reply.* | archive.query (broadcast) |
|
||||||
|
| archive-worm | archive.put, archive.query | archive.reply.* on bloom hit |
|
||||||
|
| tree-node | archive.put, archive.query | archive.reply.* on bloom hit |
|
||||||
|
|
||||||
|
NS1 nats-server stays on `127.0.0.1:4222`. `fleet.json` `nats.publicBind` is false.
|
||||||
48
README.md
Normal file
48
README.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# verae-fleet
|
||||||
|
|
||||||
|
Operator control plane for Verae Time × Zapier **runtime** services:
|
||||||
|
|
||||||
|
- a **list** of every service and its config file
|
||||||
|
- a **central replica spec** (`fleet.json`) — how many copies must be up
|
||||||
|
- a **monitor** of active / paused / unhealthy replicas
|
||||||
|
- **restart** when a copy is offline or fails `/health`
|
||||||
|
- **on / off / pause / resume** per service or per instance
|
||||||
|
- **keepFloor** on tree nodes so at least `min` copies stay available (paused copies do not count)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/verae-fleet
|
||||||
|
npm test
|
||||||
|
node src/cli.js list
|
||||||
|
node src/cli.js serve # http://0.0.0.0:3850/
|
||||||
|
```
|
||||||
|
|
||||||
|
Against a running daemon:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node src/cli.js status
|
||||||
|
node src/cli.js pause tree-node-0 # floor starts another tree-node
|
||||||
|
node src/cli.js resume tree-node-0
|
||||||
|
node src/cli.js restart tree-node-1
|
||||||
|
node src/cli.js stop webhook-deliver # disable that service
|
||||||
|
node src/cli.js start webhook-deliver
|
||||||
|
```
|
||||||
|
|
||||||
|
Add capacity in `machines.json` or the monitor **Add machine** form (`kind=ssh`, user, host, identity file path). New replicas land on the least-loaded eligible host.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node src/cli.js ssh-check ns1 # marchon@70.88.205.138 with ~/.ssh/id_ed25519
|
||||||
|
```
|
||||||
|
|
||||||
|
Private keys stay on disk (`~/.ssh/id_ed25519`); git stores only the path. Optional overrides: `machines.secrets.json` (gitignored).
|
||||||
|
|
||||||
|
Operator console (Fleet · Trace · Docs) at http://127.0.0.1:3850/ — [docs/CONSOLE.md](docs/CONSOLE.md) · [CONSOLE.pdf](docs/CONSOLE.pdf) (also https://zapier.georgelambert.org/packages/verae-fleet/docs/CONSOLE.pdf). Message-processing **min / avg / p50 / p90** RTT is on the Fleet tab.
|
||||||
|
|
||||||
|
Zapier cloud apps are listed but **not spawned**. NATS on NS1 is **monitored only** (loopback `:4222`, never a public bind).
|
||||||
|
|
||||||
|
On a dedicated host, run **[verae-keep](https://git.georgelambert.org/marchon/verae-keep)** next to fleet: keep restarts crashed replicas unless this console paused or stopped them (`intent.json` per instance). Pause/stop/start write that file locally and over SSH.
|
||||||
|
|
||||||
|
NS1 overlay (no rewrite of git JSON): `FLEET_FILE=overlays/ns1/fleet.json` `FLEET_MACHINES=overlays/ns1/machines.json`. User systemd: `bash scripts/install-systemd.sh`. Proxmox worker: machine id `px-worker` after [verae-proxmox](https://git.georgelambert.org/marchon/verae-proxmox).
|
||||||
|
|
||||||
|
Install on Docker / Proxmox / metal: https://zapier.georgelambert.org/packages/verae-ops/README.pdf
|
||||||
|
|
||||||
|
Clone: `ssh://git@git.georgelambert.org:2223/marchon/verae-fleet.git`
|
||||||
24
SERVICES.md
Normal file
24
SERVICES.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Service list
|
||||||
|
|
||||||
|
Central replica spec: [`fleet.json`](fleet.json).
|
||||||
|
Per-service files: [`services/`](services/).
|
||||||
|
|
||||||
|
| Service | Config | min | max | keepFloor | Spawned by fleet |
|
||||||
|
|---------|--------|-----|-----|-----------|------------------|
|
||||||
|
| nats | `services/nats.json` | 1 | 1 | no | no (external monitor) |
|
||||||
|
| zappier-edge | `services/zappier-edge.json` | 1 | 1 | yes | yes |
|
||||||
|
| middleware-http | `services/middleware-http.json` | 1 | 1 | yes | yes |
|
||||||
|
| job-poller | `services/job-poller.json` | 1 | 2 | yes | yes |
|
||||||
|
| webhook-deliver | `services/webhook-deliver.json` | 1 | 2 | yes | yes |
|
||||||
|
| archive-aggregator | `services/archive-aggregator.json` | 1 | 2 | yes | yes |
|
||||||
|
| archive-worm | `services/archive-worm.json` | 3 | 6 | yes | yes |
|
||||||
|
| **tree-node** | `services/tree-node.json` | **3** | 9 | **yes** | yes |
|
||||||
|
| zapier-simulator | `services/zapier-simulator.json` | 0 | 1 | no | optional |
|
||||||
|
| zapier-platform-app | `services/unmanaged.json` | — | — | — | no (Zapier cloud) |
|
||||||
|
| verae-activate | `services/unmanaged.json` | — | — | — | no |
|
||||||
|
| request-splitter | `services/unmanaged.json` | — | — | — | no (library) |
|
||||||
|
| verae-chain-client | `services/unmanaged.json` | — | — | — | no (library) |
|
||||||
|
|
||||||
|
`keepFloor`: monitor starts replacements when **available** (running, healthy, not paused) drops below `min`. That is how tree nodes stay at three live copies if one is paused, crashed, or not responding.
|
||||||
|
|
||||||
|
Hosts: [`machines.json`](machines.json). Add machines in the monitor UI or POST `/api/machines`. Placement is least-loaded eligible host. Remote: `node src/agent.js`.
|
||||||
9
SUMMARY.md
Normal file
9
SUMMARY.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# verae-fleet
|
||||||
|
|
||||||
|
**Job:** Catalog services, hold per-service configs, monitor health, restart bad replicas, pause/resume/on/off, and keep `fleet.json` replica floors — especially tree-node `min`.
|
||||||
|
|
||||||
|
**Expects:** operator CLI/HTTP on `127.0.0.1:3850`. Spawns `src/worker.js` children with `/health`.
|
||||||
|
|
||||||
|
**Sends:** nothing to Zapier or public NATS.
|
||||||
|
|
||||||
|
**Test:** `npm test` — list configs; start 3 tree-nodes; pause one still ≥3 available; unhealthy restart; stop+replace.
|
||||||
428
docs/CONSOLE.html
Normal file
428
docs/CONSOLE.html
Normal file
|
|
@ -0,0 +1,428 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="generator" content="pandoc" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
|
||||||
|
<title>Operator console</title>
|
||||||
|
<style>
|
||||||
|
/* Default styles provided by pandoc.
|
||||||
|
** See https://pandoc.org/MANUAL.html#variables-for-html for config info.
|
||||||
|
*/
|
||||||
|
html {
|
||||||
|
color: #1a1a1a;
|
||||||
|
background-color: #fdfdfd;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 36em;
|
||||||
|
padding-left: 50px;
|
||||||
|
padding-right: 50px;
|
||||||
|
padding-top: 50px;
|
||||||
|
padding-bottom: 50px;
|
||||||
|
hyphens: auto;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
font-kerning: normal;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
body {
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.8em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
html {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: transparent;
|
||||||
|
color: black;
|
||||||
|
font-size: 12pt;
|
||||||
|
}
|
||||||
|
p, h2, h3 {
|
||||||
|
orphans: 3;
|
||||||
|
widows: 3;
|
||||||
|
}
|
||||||
|
h2, h3, h4 {
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
a:visited {
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin-top: 1.4em;
|
||||||
|
}
|
||||||
|
h5, h6 {
|
||||||
|
font-size: 1em;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
h6 {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
ol, ul {
|
||||||
|
padding-left: 1.7em;
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
li > ol, li > ul {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
blockquote {
|
||||||
|
margin: 1em 0 1em 1.7em;
|
||||||
|
padding-left: 1em;
|
||||||
|
border-left: 2px solid #e6e6e6;
|
||||||
|
color: #606060;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: Menlo, Monaco, Consolas, 'Lucida Console', monospace;
|
||||||
|
font-size: 85%;
|
||||||
|
margin: 0;
|
||||||
|
hyphens: manual;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
margin: 1em 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
pre code {
|
||||||
|
padding: 0;
|
||||||
|
overflow: visible;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
}
|
||||||
|
.sourceCode {
|
||||||
|
background-color: transparent;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #1a1a1a;
|
||||||
|
height: 1px;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
margin: 1em 0;
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
display: block;
|
||||||
|
font-variant-numeric: lining-nums tabular-nums;
|
||||||
|
}
|
||||||
|
table caption {
|
||||||
|
margin-bottom: 0.75em;
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
border-top: 1px solid #1a1a1a;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
border-top: 1px solid #1a1a1a;
|
||||||
|
padding: 0.25em 0.5em 0.25em 0.5em;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 0.125em 0.5em 0.25em 0.5em;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
margin-bottom: 4em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#TOC li {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
#TOC ul {
|
||||||
|
padding-left: 1.3em;
|
||||||
|
}
|
||||||
|
#TOC > ul {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
#TOC a:not(:hover) {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
code{white-space: pre-wrap;}
|
||||||
|
span.smallcaps{font-variant: small-caps;}
|
||||||
|
div.columns{display: flex; gap: min(4vw, 1.5em);}
|
||||||
|
div.column{flex: auto; overflow-x: auto;}
|
||||||
|
div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
|
||||||
|
/* The extra [class] is a hack that increases specificity enough to
|
||||||
|
override a similar rule in reveal.js */
|
||||||
|
ul.task-list[class]{list-style: none;}
|
||||||
|
ul.task-list li input[type="checkbox"] {
|
||||||
|
font-size: inherit;
|
||||||
|
width: 0.8em;
|
||||||
|
margin: 0 0.8em 0.2em -1.6em;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.display.math{display: block; text-align: center; margin: 0.5rem auto;}
|
||||||
|
/* CSS for syntax highlighting */
|
||||||
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
|
pre > code.sourceCode { white-space: pre; position: relative; }
|
||||||
|
pre > code.sourceCode > span { display: inline-block; line-height: 1.25; }
|
||||||
|
pre > code.sourceCode > span:empty { height: 1.2em; }
|
||||||
|
.sourceCode { overflow: visible; }
|
||||||
|
code.sourceCode > span { color: inherit; text-decoration: inherit; }
|
||||||
|
div.sourceCode { margin: 1em 0; }
|
||||||
|
pre.sourceCode { margin: 0; }
|
||||||
|
@media screen {
|
||||||
|
div.sourceCode { overflow: auto; }
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
pre > code.sourceCode { white-space: pre-wrap; }
|
||||||
|
pre > code.sourceCode > span { text-indent: -5em; padding-left: 5em; }
|
||||||
|
}
|
||||||
|
pre.numberSource code
|
||||||
|
{ counter-reset: source-line 0; }
|
||||||
|
pre.numberSource code > span
|
||||||
|
{ position: relative; left: -4em; counter-increment: source-line; }
|
||||||
|
pre.numberSource code > span > a:first-child::before
|
||||||
|
{ content: counter(source-line);
|
||||||
|
position: relative; left: -1em; text-align: right; vertical-align: baseline;
|
||||||
|
border: none; display: inline-block;
|
||||||
|
-webkit-touch-callout: none; -webkit-user-select: none;
|
||||||
|
-khtml-user-select: none; -moz-user-select: none;
|
||||||
|
-ms-user-select: none; user-select: none;
|
||||||
|
padding: 0 4px; width: 4em;
|
||||||
|
background-color: #232629;
|
||||||
|
color: #7a7c7d;
|
||||||
|
}
|
||||||
|
pre.numberSource { margin-left: 3em; border-left: 1px solid #7a7c7d; padding-left: 4px; }
|
||||||
|
div.sourceCode
|
||||||
|
{ color: #cfcfc2; background-color: #232629; }
|
||||||
|
@media screen {
|
||||||
|
pre > code.sourceCode > span > a:first-child::before { text-decoration: underline; }
|
||||||
|
}
|
||||||
|
code span { color: #cfcfc2; } /* Normal */
|
||||||
|
code span.al { color: #95da4c; background-color: #4d1f24; font-weight: bold; } /* Alert */
|
||||||
|
code span.an { color: #3f8058; } /* Annotation */
|
||||||
|
code span.at { color: #2980b9; } /* Attribute */
|
||||||
|
code span.bn { color: #f67400; } /* BaseN */
|
||||||
|
code span.bu { color: #7f8c8d; } /* BuiltIn */
|
||||||
|
code span.cf { color: #fdbc4b; font-weight: bold; } /* ControlFlow */
|
||||||
|
code span.ch { color: #3daee9; } /* Char */
|
||||||
|
code span.cn { color: #27aeae; font-weight: bold; } /* Constant */
|
||||||
|
code span.co { color: #7a7c7d; } /* Comment */
|
||||||
|
code span.cv { color: #7f8c8d; } /* CommentVar */
|
||||||
|
code span.do { color: #a43340; } /* Documentation */
|
||||||
|
code span.dt { color: #2980b9; } /* DataType */
|
||||||
|
code span.dv { color: #f67400; } /* DecVal */
|
||||||
|
code span.er { color: #da4453; text-decoration: underline; } /* Error */
|
||||||
|
code span.ex { color: #0099ff; font-weight: bold; } /* Extension */
|
||||||
|
code span.fl { color: #f67400; } /* Float */
|
||||||
|
code span.fu { color: #8e44ad; } /* Function */
|
||||||
|
code span.im { color: #27ae60; } /* Import */
|
||||||
|
code span.in { color: #c45b00; } /* Information */
|
||||||
|
code span.kw { color: #cfcfc2; font-weight: bold; } /* Keyword */
|
||||||
|
code span.op { color: #cfcfc2; } /* Operator */
|
||||||
|
code span.ot { color: #27ae60; } /* Other */
|
||||||
|
code span.pp { color: #27ae60; } /* Preprocessor */
|
||||||
|
code span.re { color: #2980b9; background-color: #153042; } /* RegionMarker */
|
||||||
|
code span.sc { color: #3daee9; } /* SpecialChar */
|
||||||
|
code span.ss { color: #da4453; } /* SpecialString */
|
||||||
|
code span.st { color: #f44f4f; } /* String */
|
||||||
|
code span.va { color: #27aeae; } /* Variable */
|
||||||
|
code span.vs { color: #da4453; } /* VerbatimString */
|
||||||
|
code span.wa { color: #da4453; } /* Warning */
|
||||||
|
</style>
|
||||||
|
<style>/* Colored print + screen stylesheet for zapier.georgelambert.org */
|
||||||
|
:root {
|
||||||
|
--ink: #171a26;
|
||||||
|
--muted: #5b6178;
|
||||||
|
--line: #d9dce8;
|
||||||
|
--bg: #f4f5fb;
|
||||||
|
--paper: #ffffff;
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--accent-deep: #312e81;
|
||||||
|
--accent-soft: #eef0fe;
|
||||||
|
--ok: #047857;
|
||||||
|
--warn: #8a5a00;
|
||||||
|
--code-bg: #1b1f33;
|
||||||
|
--code-fg: #e8ecff;
|
||||||
|
}
|
||||||
|
html { background: var(--bg); }
|
||||||
|
body {
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1.25rem 3rem;
|
||||||
|
max-width: 48rem;
|
||||||
|
font: 15px/1.55 -apple-system, "Segoe UI", Georgia, serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
.doc-banner {
|
||||||
|
background: linear-gradient(160deg, #312e81 0%, #4f46e5 60%, #7c74f0 100%);
|
||||||
|
color: #eef0fe;
|
||||||
|
margin: -1.5rem -1.25rem 1.5rem;
|
||||||
|
padding: 1.1rem 1.25rem 1rem;
|
||||||
|
}
|
||||||
|
.doc-banner a { color: #fff; }
|
||||||
|
.doc-banner .kicker {
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font: 700 10px system-ui, sans-serif;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.doc-banner h1 { margin: 0.25rem 0 0; font-size: 1.45rem; color: #fff; }
|
||||||
|
h1, h2, h3, h4 { color: var(--accent-deep); page-break-after: avoid; }
|
||||||
|
h1 { font-size: 1.7rem; }
|
||||||
|
h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
border-bottom: 2px solid var(--accent);
|
||||||
|
padding-bottom: 0.2rem;
|
||||||
|
margin-top: 1.6rem;
|
||||||
|
}
|
||||||
|
h3 { font-size: 1.05rem; color: var(--accent); }
|
||||||
|
a { color: var(--accent); }
|
||||||
|
p, li { orphans: 3; widows: 3; }
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.86em;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-deep);
|
||||||
|
padding: 0.08em 0.28em;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
pre, div.sourceCode, div.sourceCode pre {
|
||||||
|
background: var(--code-bg) !important;
|
||||||
|
color: var(--code-fg) !important;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
pre code { background: transparent; color: inherit; padding: 0; }
|
||||||
|
#title-block-header, header#title-block-header, h1.title { display: none; }
|
||||||
|
.doc-banner + h1 { display: none; }
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.8rem 0 1.2rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
th, td { border: 1px solid var(--line); padding: 0.38rem 0.55rem; text-align: left; vertical-align: top; }
|
||||||
|
th {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font: 650 12px system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
tr:nth-child(even) td { background: var(--accent-soft); }
|
||||||
|
blockquote {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-deep);
|
||||||
|
}
|
||||||
|
img { max-width: 100%; height: auto; border-radius: 8px; page-break-inside: avoid; }
|
||||||
|
hr { border: 0; border-top: 1px solid var(--line); }
|
||||||
|
ul, ol { padding-left: 1.25rem; }
|
||||||
|
nav.site { font: 13px system-ui, sans-serif; margin-bottom: 0.4rem; }
|
||||||
|
.source-path { font: 11px ui-monospace, Menlo, monospace; color: var(--muted); }
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: letter;
|
||||||
|
margin: 0.65in 0.7in 0.8in 0.7in;
|
||||||
|
@top-left {
|
||||||
|
content: "Verae Time × Zapier";
|
||||||
|
font: 700 8pt system-ui, sans-serif;
|
||||||
|
color: #4f46e5;
|
||||||
|
}
|
||||||
|
@top-right {
|
||||||
|
content: "zapier.georgelambert.org";
|
||||||
|
font: 8pt system-ui, sans-serif;
|
||||||
|
color: #6b7186;
|
||||||
|
}
|
||||||
|
@bottom-center {
|
||||||
|
content: counter(page) " / " counter(pages);
|
||||||
|
font: 8pt system-ui, sans-serif;
|
||||||
|
color: #6b7186;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
html, body { background: #fff; max-width: none; padding: 0; }
|
||||||
|
.doc-banner { margin: 0 0 1rem; border-radius: 8px; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
a { text-decoration: none; }
|
||||||
|
th, tr:nth-child(even) td, pre, blockquote, code { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc-banner"><nav class="site"><a href="/">zapier.georgelambert.org</a> · <a href="/index-md.html">Markdown indexes</a></nav><div class="kicker">Verae Time × Zapier</div><h1>Operator console</h1><div class="source-path">packages/verae-fleet/docs/CONSOLE.md</div></div>
|
||||||
|
<header id="title-block-header">
|
||||||
|
<h1 class="title">Operator console</h1>
|
||||||
|
</header>
|
||||||
|
<h1 id="operator-console">Operator console</h1>
|
||||||
|
<p>Operator UI at <strong>http://0.0.0.0:3850/</strong> (all
|
||||||
|
interfaces). Fleet, Trace (simulator), and Docs in one shell. NATS stays
|
||||||
|
private (<code>127.0.0.1:4222</code>); SSH identity files stay on
|
||||||
|
disk.</p>
|
||||||
|
<div class="sourceCode" id="cb1"><pre
|
||||||
|
class="sourceCode bash"><code class="sourceCode bash"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="bu">cd</span> packages/verae-fleet</span>
|
||||||
|
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a><span class="ex">node</span> src/cli.js serve</span></code></pre></div>
|
||||||
|
<h2 id="tabs">Tabs</h2>
|
||||||
|
<table>
|
||||||
|
<colgroup>
|
||||||
|
<col style="width: 35%" />
|
||||||
|
<col style="width: 64%" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tab</th>
|
||||||
|
<th>Purpose</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Fleet</strong></td>
|
||||||
|
<td>Machines (SSH/local), replica floors, green/yellow/red, RTT spark.
|
||||||
|
Replica and service actions live in one <strong>⋯</strong> overflow
|
||||||
|
menu. Power column shows on/paused/off as status, not toggles.
|
||||||
|
Add-machine roles are chips.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Trace</strong></td>
|
||||||
|
<td>In-process Zapier simulator: run a step, hop log,
|
||||||
|
monitors/suggestions</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Docs</strong></td>
|
||||||
|
<td>Reading order: overview → module summaries → architecture →
|
||||||
|
remaining docs</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h2 id="screenshots">Screenshots</h2>
|
||||||
|
<figure>
|
||||||
|
<img src="screenshots/console-fleet.png" alt="Fleet" />
|
||||||
|
<figcaption aria-hidden="true">Fleet</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure>
|
||||||
|
<img src="screenshots/console-trace.png" alt="Trace" />
|
||||||
|
<figcaption aria-hidden="true">Trace</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure>
|
||||||
|
<img src="screenshots/console-docs.png" alt="Docs" />
|
||||||
|
<figcaption aria-hidden="true">Docs</figcaption>
|
||||||
|
</figure>
|
||||||
|
<p>Design tokens match the zappier portal/admin (indigo shell). Customer
|
||||||
|
portal is unchanged.</p>
|
||||||
|
<p>See <a
|
||||||
|
href="https://git.georgelambert.org/marchon/overview">overview</a>.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
docs/CONSOLE.md
Normal file
28
docs/CONSOLE.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Operator console
|
||||||
|
|
||||||
|
Operator UI at **http://0.0.0.0:3850/** (all interfaces). Fleet, Trace (simulator), and Docs in one shell. NATS stays private (`127.0.0.1:4222`); SSH identity files stay on disk.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/verae-fleet
|
||||||
|
node src/cli.js serve
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tabs
|
||||||
|
|
||||||
|
| Tab | Purpose |
|
||||||
|
|-----|---------|
|
||||||
|
| **Fleet** | Machines (SSH/local), replica floors, green/yellow/red, RTT spark. Replica and service actions live in one **⋯** overflow menu. Power column shows on/paused/off as status, not toggles. Add-machine roles are chips. |
|
||||||
|
| **Trace** | In-process Zapier simulator: run a step, hop log, monitors/suggestions |
|
||||||
|
| **Docs** | Reading order: overview → module summaries → architecture → remaining docs |
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Design tokens match the zappier portal/admin (indigo shell). Customer portal is unchanged.
|
||||||
|
|
||||||
|
See [overview](https://git.georgelambert.org/marchon/overview).
|
||||||
BIN
docs/CONSOLE.pdf
Normal file
BIN
docs/CONSOLE.pdf
Normal file
Binary file not shown.
BIN
docs/screenshots/console-docs.png
Normal file
BIN
docs/screenshots/console-docs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
BIN
docs/screenshots/console-fleet.png
Normal file
BIN
docs/screenshots/console-fleet.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 391 KiB |
BIN
docs/screenshots/console-trace.png
Normal file
BIN
docs/screenshots/console-trace.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 285 KiB |
40
fleet.json
Normal file
40
fleet.json
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"apiVersion": "verae.fleet/v1",
|
||||||
|
"control": {
|
||||||
|
"bind": "0.0.0.0",
|
||||||
|
"port": 3850,
|
||||||
|
"probeIntervalMs": 750,
|
||||||
|
"unhealthyAfterMs": 2500,
|
||||||
|
"restartBackoffMs": [200, 800, 2000],
|
||||||
|
"maxRestartsPerHour": 40
|
||||||
|
},
|
||||||
|
"nats": {
|
||||||
|
"url": "nats://127.0.0.1:4222",
|
||||||
|
"publicBind": false
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"nats": { "min": 1, "max": 1, "keepFloor": false, "enabled": true, "managed": false },
|
||||||
|
"zappier-edge": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"access-authz": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"access-web": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"access-api": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"access-leaf": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"access-zapier": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"access-staff": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"identity": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"jobs-events": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"account-balance": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"customer-service": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"sales-pricing": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"accounting-export": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"middleware-http": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"job-poller": { "min": 1, "max": 2, "keepFloor": true, "enabled": true },
|
||||||
|
"webhook-deliver": { "min": 1, "max": 2, "keepFloor": true, "enabled": true },
|
||||||
|
"archive-aggregator": { "min": 1, "max": 2, "keepFloor": true, "enabled": true },
|
||||||
|
"archive-worm": { "min": 3, "max": 6, "keepFloor": true, "enabled": true },
|
||||||
|
"tree-node": { "min": 3, "max": 9, "keepFloor": true, "enabled": true },
|
||||||
|
"zapier-simulator": { "min": 0, "max": 1, "keepFloor": false, "enabled": true },
|
||||||
|
"staff-iam": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"staff-session": { "min": 1, "max": 1, "keepFloor": true, "enabled": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
61
machines.json
Normal file
61
machines.json
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"machines": [
|
||||||
|
{
|
||||||
|
"id": "local",
|
||||||
|
"title": "Control plane (this host)",
|
||||||
|
"kind": "local",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"enabled": true,
|
||||||
|
"capacity": 32,
|
||||||
|
"roles": ["*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ns1",
|
||||||
|
"title": "NS1.GEORGELAMBERT.ORG",
|
||||||
|
"kind": "ssh",
|
||||||
|
"host": "70.88.205.138",
|
||||||
|
"user": "marchon",
|
||||||
|
"sshPort": 22,
|
||||||
|
"identityFile": "~/.ssh/id_ed25519",
|
||||||
|
"remoteDir": "~/verae-fleet-runtime",
|
||||||
|
"enabled": true,
|
||||||
|
"capacity": 24,
|
||||||
|
"roles": [
|
||||||
|
"tree-node",
|
||||||
|
"archive-worm",
|
||||||
|
"archive-aggregator",
|
||||||
|
"job-poller",
|
||||||
|
"webhook-deliver"
|
||||||
|
],
|
||||||
|
"notes": "SSH as marchon with ~/.ssh/id_ed25519. Workers bind 127.0.0.1 on NS1; control plane probes via ssh+curl. Private key is never stored in git."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lan-134",
|
||||||
|
"title": "70.88.205.134",
|
||||||
|
"kind": "ssh",
|
||||||
|
"host": "70.88.205.134",
|
||||||
|
"user": "marchon",
|
||||||
|
"sshPort": 22,
|
||||||
|
"identityFile": "~/.ssh/id_ed25519",
|
||||||
|
"remoteDir": "~/verae-fleet-runtime",
|
||||||
|
"enabled": false,
|
||||||
|
"capacity": 12,
|
||||||
|
"roles": ["tree-node", "archive-worm"],
|
||||||
|
"notes": "Same user/key as NS1. Enable after confirming node is on PATH."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "px-worker",
|
||||||
|
"title": "Proxmox LXC 510 verae-px-worker",
|
||||||
|
"kind": "ssh",
|
||||||
|
"host": "10.10.10.20",
|
||||||
|
"user": "marchon",
|
||||||
|
"sshPort": 22,
|
||||||
|
"identityFile": "~/.ssh/id_ed25519",
|
||||||
|
"remoteDir": "~/verae-fleet-runtime",
|
||||||
|
"enabled": true,
|
||||||
|
"capacity": 12,
|
||||||
|
"roles": ["tree-node", "archive-worm"],
|
||||||
|
"notes": "Unprivileged Ubuntu CT 510 on NS1 vmbr1. ssh marchon@10.10.10.20."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
13
machines.secrets.json.example
Normal file
13
machines.secrets.json.example
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"ns1": {
|
||||||
|
"user": "marchon",
|
||||||
|
"host": "70.88.205.138",
|
||||||
|
"sshPort": 22,
|
||||||
|
"identityFile": "~/.ssh/id_ed25519"
|
||||||
|
},
|
||||||
|
"lan-134": {
|
||||||
|
"user": "marchon",
|
||||||
|
"host": "70.88.205.134",
|
||||||
|
"identityFile": "~/.ssh/id_ed25519"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
overlays/ns1/README.md
Normal file
13
overlays/ns1/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# NS1 overlay
|
||||||
|
|
||||||
|
Checked-in fleet + machines for **NS1.GEORGELAMBERT.ORG** (Proxmox). Do not rewrite these from `start.sh`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FLEET_FILE=$PWD/overlays/ns1/fleet.json
|
||||||
|
export FLEET_MACHINES=$PWD/overlays/ns1/machines.json
|
||||||
|
node src/cli.js serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Archive worm/tree/poller stay **disabled** here so **verae-keep** owns those processes. HTTP doors stay with fleet.
|
||||||
|
|
||||||
|
`px-worker` (LXC 510, `10.10.10.20`) is listed disabled until `verae-proxmox/scripts/create-worker.sh` and `verae-fleet ssh-check px-worker` succeed; then set `"enabled": true`.
|
||||||
40
overlays/ns1/fleet.json
Normal file
40
overlays/ns1/fleet.json
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"apiVersion": "verae.fleet/v1",
|
||||||
|
"control": {
|
||||||
|
"bind": "0.0.0.0",
|
||||||
|
"port": 3850,
|
||||||
|
"probeIntervalMs": 750,
|
||||||
|
"unhealthyAfterMs": 2500,
|
||||||
|
"restartBackoffMs": [200, 800, 2000],
|
||||||
|
"maxRestartsPerHour": 40
|
||||||
|
},
|
||||||
|
"nats": {
|
||||||
|
"url": "nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222",
|
||||||
|
"publicBind": false
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"nats": { "min": 1, "max": 1, "keepFloor": false, "enabled": true, "managed": false },
|
||||||
|
"zappier-edge": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"access-authz": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"access-web": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"access-api": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"access-leaf": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"access-zapier": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"access-staff": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"identity": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"jobs-events": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"account-balance": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"customer-service": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"sales-pricing": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"accounting-export": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"middleware-http": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"job-poller": { "min": 1, "max": 2, "keepFloor": false, "enabled": false },
|
||||||
|
"webhook-deliver": { "min": 1, "max": 2, "keepFloor": false, "enabled": false },
|
||||||
|
"archive-aggregator": { "min": 1, "max": 2, "keepFloor": false, "enabled": false },
|
||||||
|
"archive-worm": { "min": 3, "max": 6, "keepFloor": false, "enabled": false },
|
||||||
|
"tree-node": { "min": 3, "max": 9, "keepFloor": false, "enabled": false },
|
||||||
|
"zapier-simulator": { "min": 0, "max": 1, "keepFloor": false, "enabled": true },
|
||||||
|
"staff-iam": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||||
|
"staff-session": { "min": 1, "max": 1, "keepFloor": true, "enabled": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
54
overlays/ns1/machines.json
Normal file
54
overlays/ns1/machines.json
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
{
|
||||||
|
"machines": [
|
||||||
|
{
|
||||||
|
"id": "local",
|
||||||
|
"title": "NS1 control plane (Proxmox host)",
|
||||||
|
"kind": "local",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"enabled": true,
|
||||||
|
"capacity": 32,
|
||||||
|
"roles": ["*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ns1",
|
||||||
|
"title": "NS1.GEORGELAMBERT.ORG SSH profile",
|
||||||
|
"kind": "ssh",
|
||||||
|
"host": "70.88.205.138",
|
||||||
|
"user": "marchon",
|
||||||
|
"sshPort": 22,
|
||||||
|
"identityFile": "~/.ssh/id_ed25519",
|
||||||
|
"remoteDir": "~/verae-fleet-runtime",
|
||||||
|
"enabled": false,
|
||||||
|
"capacity": 24,
|
||||||
|
"roles": ["tree-node", "archive-worm", "archive-aggregator", "job-poller", "webhook-deliver"],
|
||||||
|
"notes": "Same box as local; SSH spawn unused on all-in-one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lan-134",
|
||||||
|
"title": "70.88.205.134",
|
||||||
|
"kind": "ssh",
|
||||||
|
"host": "70.88.205.134",
|
||||||
|
"user": "marchon",
|
||||||
|
"sshPort": 22,
|
||||||
|
"identityFile": "~/.ssh/id_ed25519",
|
||||||
|
"remoteDir": "~/verae-fleet-runtime",
|
||||||
|
"enabled": false,
|
||||||
|
"capacity": 12,
|
||||||
|
"roles": ["tree-node", "archive-worm"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "px-worker",
|
||||||
|
"title": "Proxmox LXC 510 verae-px-worker",
|
||||||
|
"kind": "ssh",
|
||||||
|
"host": "10.10.10.20",
|
||||||
|
"user": "marchon",
|
||||||
|
"sshPort": 22,
|
||||||
|
"identityFile": "~/.ssh/id_ed25519",
|
||||||
|
"remoteDir": "~/verae-fleet-runtime",
|
||||||
|
"enabled": true,
|
||||||
|
"capacity": 12,
|
||||||
|
"roles": ["tree-node", "archive-worm"],
|
||||||
|
"notes": "vmbr1 LXC 510. SSH as marchon@10.10.10.20 with ~/.ssh/id_ed25519."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
13
package.json
Normal file
13
package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"name": "verae-fleet",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Service catalog, replica floors, monitor, restart, pause/resume for Verae Zapier workers and tree nodes",
|
||||||
|
"bin": { "verae-fleet": "./src/cli.js" },
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/cli.js serve",
|
||||||
|
"list": "node src/cli.js list",
|
||||||
|
"test": "node --test test/*.test.js"
|
||||||
|
},
|
||||||
|
"engines": { "node": ">=20" }
|
||||||
|
}
|
||||||
456
public/index.html
Normal file
456
public/index.html
Normal file
|
|
@ -0,0 +1,456 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>Verae operator console</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg:#f4f5fb; --panel:#fff; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0;
|
||||||
|
--accent:#4f46e5; --accent-ink:#fff; --soft:#eef0fe;
|
||||||
|
--ok:#047857; --ok-bg:#e3f6e8; --warn:#8a5a00; --warn-bg:#fff3bf;
|
||||||
|
--err:#dc2626; --err-bg:#f9d4d4; --radius:12px;
|
||||||
|
--shadow:0 1px 2px rgba(23,26,38,.05), 0 8px 24px rgba(23,26,38,.06);
|
||||||
|
--fault:#ea580c;
|
||||||
|
}
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
body { margin:0; font:14px/1.45 -apple-system,"SF Pro Text","Segoe UI",sans-serif; background:var(--bg); color:var(--ink); }
|
||||||
|
.skip { position:absolute; left:-999px; }
|
||||||
|
.skip:focus { left:1rem; top:1rem; z-index:30; background:#fff; color:var(--accent); padding:.5rem .9rem; border-radius:8px; }
|
||||||
|
header.app {
|
||||||
|
background:linear-gradient(160deg,#312e81 0%,#4f46e5 60%,#7c74f0 100%);
|
||||||
|
color:#eef0fe; padding:1rem 1.25rem 0; display:flex; flex-wrap:wrap; gap:.75rem 1.5rem; align-items:flex-end;
|
||||||
|
}
|
||||||
|
header.app h1 { margin:0; font-size:1.2rem; }
|
||||||
|
header.app p { margin:.2rem 0 .8rem; font-size:13px; opacity:.85; max-width:44rem; }
|
||||||
|
nav { display:flex; gap:.15rem; }
|
||||||
|
nav button {
|
||||||
|
border:0; background:transparent; color:#c7c9ff; min-height:44px; padding:.7rem 1.15rem;
|
||||||
|
border-radius:10px 10px 0 0; font:650 14px system-ui; cursor:pointer;
|
||||||
|
}
|
||||||
|
nav button:hover { color:#fff; background:rgba(255,255,255,.08); }
|
||||||
|
nav button.on { background:var(--bg); color:var(--accent); }
|
||||||
|
nav button:focus-visible, button.act:focus-visible, a.act:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
|
||||||
|
outline:2px solid #fff; outline-offset:2px;
|
||||||
|
}
|
||||||
|
nav button.on:focus-visible { outline-color:var(--accent); }
|
||||||
|
main { padding:1rem 1.25rem 2.5rem; max-width:1280px; margin:0 auto; }
|
||||||
|
.panel { display:none; }
|
||||||
|
.panel.on { display:block; }
|
||||||
|
h2 { font-size:12px; letter-spacing:.08em; text-transform:uppercase; color:var(--muted); margin:1.2rem 0 .5rem; }
|
||||||
|
.cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:.75rem; }
|
||||||
|
.card {
|
||||||
|
background:var(--panel); border-radius:var(--radius); box-shadow:var(--shadow);
|
||||||
|
padding:.85rem .95rem; border:1px solid var(--line);
|
||||||
|
}
|
||||||
|
.card.operational { background:var(--ok-bg); border-color:#c8efd4; }
|
||||||
|
.card.degraded { background:var(--warn-bg); border-color:#ffe08a; }
|
||||||
|
.card.down { background:var(--err-bg); border-color:#f3c0c0; }
|
||||||
|
.card.disabled { background:#f1f5f9; border-color:#e5e7f0; }
|
||||||
|
.card h3 { margin:0 0 .25rem; font-size:14px; }
|
||||||
|
.muted { color:var(--muted); font-size:12px; }
|
||||||
|
.pill { font:700 10px system-ui; letter-spacing:.06em; text-transform:uppercase; padding:.12rem .4rem; border-radius:999px; }
|
||||||
|
.pill.good { background:#c8efd4; color:var(--ok); }
|
||||||
|
.pill.warn { background:#ffe08a; color:var(--warn); }
|
||||||
|
.pill.bad { background:#f3c0c0; color:var(--err); }
|
||||||
|
button.act, a.act { margin:.15rem .2rem 0 0; border:1px solid var(--line); background:#fff; border-radius:8px; padding:.28rem .5rem; font:650 12px system-ui; cursor:pointer; text-decoration:none; color:var(--ink); display:inline-block; }
|
||||||
|
button.act.primary, a.act.primary { background:var(--accent); color:#fff; border:0; }
|
||||||
|
button.act.danger { color:var(--err); }
|
||||||
|
button.act.fault { background:var(--fault); color:#fff; border:0; }
|
||||||
|
.table-wrap { overflow:auto; border-radius:var(--radius); box-shadow:var(--shadow); }
|
||||||
|
table { width:100%; border-collapse:collapse; background:var(--panel); overflow:visible; }
|
||||||
|
th,td { text-align:left; padding:.45rem .6rem; border-bottom:1px solid var(--line); font-size:13px; vertical-align:top; }
|
||||||
|
th { font-size:11px; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); background:#fafbff; }
|
||||||
|
tr.operational { background:var(--ok-bg); }
|
||||||
|
tr.degraded { background:var(--warn-bg); }
|
||||||
|
tr.down { background:var(--err-bg); }
|
||||||
|
.inst { font:12px ui-monospace,Menlo,monospace; margin:.2rem 0; display:flex; gap:.4rem; align-items:center; flex-wrap:wrap; }
|
||||||
|
form.add { display:flex; flex-wrap:wrap; gap:.4rem; margin:.4rem 0 1rem; align-items:end; }
|
||||||
|
form.add .row1, form.add .row2 { display:flex; flex-wrap:wrap; gap:.4rem; align-items:end; width:100%; }
|
||||||
|
form.add .identity-field { flex: 1 1 22rem; }
|
||||||
|
form.add .identity-field input[name="identityFile"] { min-width:18rem; width:100%; }
|
||||||
|
form.add .roles-field { flex: 1 1 100%; }
|
||||||
|
form.add label { font-size:11px; color:var(--muted); display:flex; flex-direction:column; gap:.15rem; }
|
||||||
|
form.add input, form.add select, aside input, aside select, aside textarea {
|
||||||
|
padding:.4rem .5rem; border:1px solid var(--line); border-radius:8px; font:13px ui-monospace,Menlo,monospace;
|
||||||
|
}
|
||||||
|
.roles { display:flex; flex-wrap:wrap; gap:.35rem; max-width:22rem; }
|
||||||
|
.roles label {
|
||||||
|
display:inline-flex; align-items:center; gap:.25rem; font:650 11px system-ui; color:var(--ink);
|
||||||
|
background:var(--soft); border:1px solid #d7dbf8; border-radius:999px; padding:.2rem .55rem; cursor:pointer;
|
||||||
|
}
|
||||||
|
.roles input { accent-color:var(--accent); }
|
||||||
|
#toast { position:fixed; right:1rem; bottom:1rem; max-width:28rem; background:#171a26; color:#eef0fe; padding:.8rem 1rem; border-radius:10px; display:none; white-space:pre-wrap; font:12px ui-monospace,Menlo,monospace; z-index:9; }
|
||||||
|
.layout { display:grid; grid-template-columns:minmax(260px,340px) 1fr; gap:1rem; min-height:70vh; }
|
||||||
|
@media (max-width:900px) { .layout { grid-template-columns:1fr; } }
|
||||||
|
aside.trace { background:var(--panel); border-radius:var(--radius); box-shadow:var(--shadow); padding:1rem; }
|
||||||
|
.console { background:#0f171d; color:#d7e2ea; border-radius:10px; min-height:220px; overflow:auto; font:12px/1.35 ui-monospace,Menlo,monospace; }
|
||||||
|
.hop { display:grid; grid-template-columns:4.5rem 1.2rem minmax(7rem,11rem) minmax(0,1fr); gap:.4rem; padding:.28rem .7rem; border-left:3px solid transparent; }
|
||||||
|
.hop .mod, .hop .addr, .hop strong { overflow-wrap:anywhere; word-break:break-word; }
|
||||||
|
.hop.ok { border-color:var(--ok); }
|
||||||
|
.hop.error { border-color:var(--err); color:#ffb4b0; }
|
||||||
|
.hop.silent { border-color:#6a7a86; color:#8fa0ab; }
|
||||||
|
.hop.retry,.hop.delay { border-color:#d39b00; }
|
||||||
|
.mod { color:#9ad7c2; }
|
||||||
|
.addr { color:#8cb4ea; }
|
||||||
|
.docs .card p { margin:.35rem 0 .7rem; color:var(--muted); font-size:13px; }
|
||||||
|
.kicker { letter-spacing:.12em; text-transform:uppercase; font:700 10px system-ui; opacity:.75; }
|
||||||
|
code { font:12px ui-monospace,Menlo,monospace; }
|
||||||
|
.spark { display:flex; gap:3px; align-items:flex-end; height:28px; margin:.35rem 0; }
|
||||||
|
.spark i { display:block; width:8px; background:var(--accent); border-radius:2px 2px 0 0; opacity:.85; }
|
||||||
|
.more { position:relative; display:inline-block; }
|
||||||
|
.more-btn { min-width:2rem; font-size:16px; line-height:1; }
|
||||||
|
.more-pop {
|
||||||
|
display:none; position:absolute; right:0; top:calc(100% + 4px); z-index:6;
|
||||||
|
background:#fff; border:1px solid var(--line); border-radius:10px;
|
||||||
|
box-shadow:var(--shadow); min-width:11rem; padding:.25rem; z-index:40;
|
||||||
|
}
|
||||||
|
.more.open .more-pop { display:block; }
|
||||||
|
.more.open.up .more-pop { top:auto; bottom:calc(100% + 4px); }
|
||||||
|
.more-pop button {
|
||||||
|
display:block; width:100%; text-align:left; border:0; background:none;
|
||||||
|
padding:.45rem .6rem; border-radius:8px; font:650 12px system-ui; cursor:pointer; color:var(--ink);
|
||||||
|
}
|
||||||
|
.more-pop button:hover { background:var(--soft); }
|
||||||
|
.more-pop button.danger { color:var(--err); }
|
||||||
|
.faults { border:1px solid #fed7aa; background:#fff7ed; border-radius:10px; padding:.55rem .7rem; }
|
||||||
|
.empty { text-align:center; padding:1.4rem 1rem; color:var(--muted); }
|
||||||
|
.empty svg { display:block; margin:0 auto .5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip" href="#main">Skip to content</a>
|
||||||
|
<header class="app">
|
||||||
|
<div>
|
||||||
|
<div class="kicker">operator LAN · binds 0.0.0.0:3850</div>
|
||||||
|
<h1>Verae operator console</h1>
|
||||||
|
<p>Fleet, message trace, and docs in one shell. HTTP listens on all interfaces; NATS stays private. Pause does not count toward replica floors. SSH keys stay on disk.</p>
|
||||||
|
</div>
|
||||||
|
<nav>
|
||||||
|
<button class="on" data-tab="fleet">Fleet</button>
|
||||||
|
<button data-tab="trace">Trace</button>
|
||||||
|
<button data-tab="docs">Docs</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main id="main">
|
||||||
|
<section id="fleet" class="panel on">
|
||||||
|
<p id="meta" class="muted"></p>
|
||||||
|
<h2>Machines</h2>
|
||||||
|
<form class="add" id="addMachine">
|
||||||
|
<div class="row1">
|
||||||
|
<label>id <input name="id" required placeholder="ns1-b"/></label>
|
||||||
|
<label>host <input name="host" required placeholder="70.88.205.138"/></label>
|
||||||
|
<label>kind <select name="kind"><option value="local">local</option><option value="ssh" selected>ssh</option><option value="agent">agent</option></select></label>
|
||||||
|
<label>user <input name="user" value="marchon"/></label>
|
||||||
|
<label>ssh port <input name="sshPort" type="number" value="22"/></label>
|
||||||
|
<label>capacity <input name="capacity" type="number" min="1" value="8"/></label>
|
||||||
|
</div>
|
||||||
|
<div class="row2">
|
||||||
|
<label class="identity-field">identity file (path on this host)
|
||||||
|
<span style="display:flex;gap:.35rem;align-items:center">
|
||||||
|
<input name="identityFile" value="~/.ssh/id_ed25519"/>
|
||||||
|
<input type="file" id="identityPick" title="Use filename only; SSH keys stay on disk" style="max-width:11rem"/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="roles-field">roles
|
||||||
|
<div class="roles" id="roleChips">
|
||||||
|
<label><input type="checkbox" name="role" value="*"/> *</label>
|
||||||
|
<label><input type="checkbox" name="role" value="tree-node" checked/> tree-node</label>
|
||||||
|
<label><input type="checkbox" name="role" value="archive-worm" checked/> archive-worm</label>
|
||||||
|
<label><input type="checkbox" name="role" value="archive-aggregator"/> archive-aggregator</label>
|
||||||
|
<label><input type="checkbox" name="role" value="job-poller"/> job-poller</label>
|
||||||
|
<label><input type="checkbox" name="role" value="webhook-deliver"/> webhook-deliver</label>
|
||||||
|
<label><input type="checkbox" name="role" value="nats"/> nats</label>
|
||||||
|
<label><input type="checkbox" name="role" value="edge"/> edge</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<button class="act primary" type="submit">Add machine</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="cards" id="machines"></div>
|
||||||
|
<h2>Services</h2>
|
||||||
|
<div id="rows"></div>
|
||||||
|
</section>
|
||||||
|
<section id="trace" class="panel">
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="trace">
|
||||||
|
<h2>Zapier step (simulator)</h2>
|
||||||
|
<label class="muted">Action</label>
|
||||||
|
<select id="action"></select>
|
||||||
|
<label class="muted">Data / batch lines</label>
|
||||||
|
<textarea id="data" style="width:100%;min-height:72px">invoice-1001
|
||||||
|
invoice-1002
|
||||||
|
invoice-1003</textarea>
|
||||||
|
<label class="muted">SHA256</label>
|
||||||
|
<input id="sha256" style="width:100%"/>
|
||||||
|
<label class="muted">Public metadata JSON</label>
|
||||||
|
<input id="publicMeta" style="width:100%" placeholder='{"source":"zap"}'/>
|
||||||
|
<p><label><input type="checkbox" id="includeAttached"/> includeAttached</label>
|
||||||
|
<label><input type="checkbox" id="includeTree"/> includeTree</label></p>
|
||||||
|
<h2>Faults</h2>
|
||||||
|
<div class="faults">
|
||||||
|
<p class="muted" style="margin:.1rem 0 .35rem">Orange is inject-only — not the product brand.</p>
|
||||||
|
<p class="muted"><label><input type="checkbox" id="failEdge402"/> 402</label>
|
||||||
|
<label><input type="checkbox" id="failNatsDropEvents"/> drop events</label>
|
||||||
|
<label><input type="checkbox" id="recover"/> recover</label></p>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<button class="act primary" id="run">Run step</button>
|
||||||
|
<button class="act" id="reset">Reset world</button>
|
||||||
|
<button class="act" data-sc="batch">Batch Merkle</button>
|
||||||
|
<button class="act" data-sc="leaf">Lookup leaf</button>
|
||||||
|
<button class="act" data-sc="tree">Lookup tree</button>
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
<div>
|
||||||
|
<h2>Response</h2>
|
||||||
|
<pre id="response" class="card" style="white-space:pre-wrap">Run a step.</pre>
|
||||||
|
<h2>Hop trace</h2>
|
||||||
|
<div class="console" id="console"></div>
|
||||||
|
<h2>Monitors</h2>
|
||||||
|
<div id="monitors"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="docs" class="panel docs">
|
||||||
|
<p class="muted">Reading order: high-level first, then architecture, then the rest. Catalog defaults to colored PDFs. Operator HTTP binds all interfaces; NATS does not.</p>
|
||||||
|
<p><a class="act primary" href="/docs/CONSOLE.pdf" target="_blank" rel="noopener">Open CONSOLE.pdf</a>
|
||||||
|
· <a href="https://zapier.georgelambert.org/">PDF catalog</a>
|
||||||
|
· <a href="https://zapier.georgelambert.org/index-md.html">Markdown indexes</a></p>
|
||||||
|
<h2>High-level (start here)</h2>
|
||||||
|
<div class="cards">
|
||||||
|
<article class="card"><h3>Overview</h3><p>Whole system in plain language.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/README.pdf">Open PDF</a></article>
|
||||||
|
<article class="card"><h3>Modules and repositories</h3><p>Short names of every independent repo.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/02-modules-and-repos.pdf">Open PDF</a></article>
|
||||||
|
<article class="card"><h3>docs-master</h3><p>One-line SUMMARY + NATS for each module.</p><a class="act primary" href="https://zapier.georgelambert.org/docs-master/README.pdf">Open PDF</a></article>
|
||||||
|
<article class="card"><h3>User guide</h3><p>Signup → register → lookup (non-operator).</p><a class="act primary" href="https://zapier.georgelambert.org/user-docs/README.pdf">Open PDF</a></article>
|
||||||
|
</div>
|
||||||
|
<h2>Architecture</h2>
|
||||||
|
<div class="cards">
|
||||||
|
<article class="card"><h3>NATS.IO 3-server cluster</h3><p>Loopback NATS, not a public bus.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/03-nats-cluster.pdf">Open PDF</a></article>
|
||||||
|
<article class="card"><h3>Uptime / replica floors</h3><p>Pause does not count toward floors.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/04-uptime.pdf">Open PDF</a></article>
|
||||||
|
<article class="card"><h3>Local network failures</h3><p>What the monitors flag.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/05-network-failures.pdf">Open PDF</a></article>
|
||||||
|
<article class="card"><h3>Address routing</h3><p>Access planes to internal subjects.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/06-address-routing.pdf">Open PDF</a></article>
|
||||||
|
<article class="card"><h3>Diagrams</h3><p>System, cluster, routing.</p><a class="act" href="https://zapier.georgelambert.org/overview/08-diagrams.pdf">PDF</a> <a class="act" href="https://zapier.georgelambert.org/overview/diagrams/system.svg">SVG</a></article>
|
||||||
|
<article class="card"><h3>Composition</h3><p>How packages sit together.</p><a class="act primary" href="https://zapier.georgelambert.org/docs/02-architecture/composition.pdf">Open PDF</a></article>
|
||||||
|
<article class="card"><h3>Module NATS map</h3><p>Subjects per repo.</p><a class="act primary" href="https://zapier.georgelambert.org/docs/02-architecture/modules-and-nats.pdf">Open PDF</a></article>
|
||||||
|
<article class="card"><h3>Archive / bloom</h3><p>WORM nodes.</p><a class="act primary" href="https://zapier.georgelambert.org/docs/02-architecture/archive-nats.pdf">Open PDF</a></article>
|
||||||
|
<article class="card"><h3>Tree nodes</h3><p>Merkle proofs for hashes.</p><a class="act primary" href="https://zapier.georgelambert.org/docs/02-architecture/tree-nodes.pdf">Open PDF</a></article>
|
||||||
|
<article class="card"><h3>Fleet</h3><p>Replica floors and spawn.</p><a class="act primary" href="https://zapier.georgelambert.org/docs/02-architecture/fleet.pdf">Open PDF</a></article>
|
||||||
|
</div>
|
||||||
|
<h2>Remaining documentation</h2>
|
||||||
|
<div class="cards">
|
||||||
|
<article class="card"><h3>Full index</h3><p>Every remaining document.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/INDEX.pdf">Open PDF</a></article>
|
||||||
|
<article class="card"><h3>Expansion template</h3><p>New verae.* worker.</p><a class="act" href="https://zapier.georgelambert.org/overview/09-expansion-template.pdf">PDF</a> <a class="act" href="https://git.georgelambert.org/marchon/verae-nats-process">repo</a></article>
|
||||||
|
<article class="card"><h3>Zapier developer setup</h3><p>Platform CLI, not a customer surface.</p><a class="act primary" href="https://zapier.georgelambert.org/docs/04-activate/SETUP-ZAPIER-DEVELOPER.pdf">Open PDF</a></article>
|
||||||
|
<article class="card"><h3>Simulator README</h3><p>Hop-by-hop lab.</p><a class="act primary" href="https://zapier.georgelambert.org/packages/verae-zapier-simulator/README.pdf">Open PDF</a></article>
|
||||||
|
<article class="card"><h3>verae-fleet repo</h3><p>Source for this console.</p><a class="act primary" href="https://git.georgelambert.org/marchon/verae-fleet">Open repo</a></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<div id="toast"></div>
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const EMPTY_SVG = '<svg width="80" height="64" viewBox="0 0 80 64" fill="none" aria-hidden="true"><rect x="16" y="8" width="48" height="48" rx="8" fill="#eef0fe"/><rect x="24" y="20" width="32" height="4" rx="2" fill="#4f46e5" opacity=".35"/><rect x="24" y="30" width="24" height="4" rx="2" fill="#4f46e5" opacity=".2"/><rect x="24" y="40" width="28" height="4" rx="2" fill="#4f46e5" opacity=".2"/></svg>';
|
||||||
|
async function j(url, opts) {
|
||||||
|
const r = await fetch(url, opts);
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
return { ok: r.ok, status: r.status, body };
|
||||||
|
}
|
||||||
|
function toast(msg) {
|
||||||
|
const t = $('toast');
|
||||||
|
t.textContent = typeof msg === 'string' ? msg : JSON.stringify(msg, null, 2);
|
||||||
|
t.style.display = 'block';
|
||||||
|
clearTimeout(t._tm);
|
||||||
|
t._tm = setTimeout(() => { t.style.display = 'none'; }, 8000);
|
||||||
|
}
|
||||||
|
function spark(r) {
|
||||||
|
if (!r || !r.count) return '';
|
||||||
|
const vals = [r.minMs, r.avgMs, r.p50Ms, r.p90Ms].map(Number);
|
||||||
|
const max = Math.max(0.1, ...vals);
|
||||||
|
return `<div class="spark" title="min avg p50 p90">${vals.map(v => `<i style="height:${Math.max(4, (v/max)*28)}px"></i>`).join('')}</div>
|
||||||
|
<div class="muted">RTT ms ${r.minMs} / ${r.avgMs} / ${r.p50Ms} / ${r.p90Ms} n=${r.count}</div>`;
|
||||||
|
}
|
||||||
|
function pill(health) {
|
||||||
|
const label = health === 'operational' ? 'operational' : health === 'degraded' ? 'degraded' : health === 'disabled' ? 'disabled' : 'not operational';
|
||||||
|
const cls = health === 'operational' ? 'good' : health === 'degraded' ? 'warn' : health === 'disabled' ? '' : 'bad';
|
||||||
|
return `<span class="pill ${cls}">${label}</span>`;
|
||||||
|
}
|
||||||
|
function moreMenu(items) {
|
||||||
|
return `<div class="more">
|
||||||
|
<button type="button" class="act more-btn" aria-haspopup="menu" aria-label="Actions">⋯</button>
|
||||||
|
<div class="more-pop" role="menu">${items.map((it) =>
|
||||||
|
`<button type="button" role="menuitem" class="${it.danger ? 'danger' : ''}" data-path="${it.path}" data-confirm="${it.confirm || ''}">${it.label}</button>`
|
||||||
|
).join('')}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
async function act(path, { confirm: c } = {}) {
|
||||||
|
if (c && !confirm(c)) return;
|
||||||
|
const r = await j(path, { method:'POST' });
|
||||||
|
if (r.body.check) toast(r.body.check);
|
||||||
|
else if (!r.ok) toast(r.body.error || r.body);
|
||||||
|
await drawFleet();
|
||||||
|
}
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const item = e.target.closest('.more-pop button[data-path]');
|
||||||
|
if (item) {
|
||||||
|
act(item.dataset.path, { confirm: item.dataset.confirm || undefined });
|
||||||
|
item.closest('.more')?.classList.remove('open');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const btn = e.target.closest('.more-btn');
|
||||||
|
if (btn) {
|
||||||
|
const wrap = btn.closest('.more');
|
||||||
|
document.querySelectorAll('.more.open').forEach((m) => { if (m !== wrap) m.classList.remove('open'); });
|
||||||
|
wrap.classList.toggle('open');
|
||||||
|
if (wrap.classList.contains('open')) {
|
||||||
|
const pop = wrap.querySelector('.more-pop');
|
||||||
|
const space = window.innerHeight - wrap.getBoundingClientRect().bottom;
|
||||||
|
wrap.classList.toggle('up', space < (pop?.offsetHeight || 140) + 16);
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.more.open').forEach((m) => m.classList.remove('open'));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('nav button').forEach((b) => b.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('nav button').forEach((x) => x.classList.toggle('on', x === b));
|
||||||
|
document.querySelectorAll('.panel').forEach((p) => p.classList.toggle('on', p.id === b.dataset.tab));
|
||||||
|
}));
|
||||||
|
async function drawFleet() {
|
||||||
|
const { body: s } = await j('/api/status');
|
||||||
|
$('meta').textContent = 'probe ' + (s.monitor?.t || '—') + ' · NATS ' + (s.nats?.url || '') + ' · HTTP 0.0.0.0:3850';
|
||||||
|
const machines = s.machines || [];
|
||||||
|
$('machines').innerHTML = machines.length ? machines.map((m) => {
|
||||||
|
const health = !m.enabled ? 'disabled' : m.running > 0 ? 'operational' : 'degraded';
|
||||||
|
return `<article class="card ${health}">
|
||||||
|
<h3>${m.id} ${pill(health)}</h3>
|
||||||
|
<div class="muted">${m.title || ''}</div>
|
||||||
|
<div><code>${m.kind === 'ssh' ? (m.user||'marchon')+'@' : ''}${m.host}</code></div>
|
||||||
|
<div class="muted">${m.kind} · ${m.running}/${m.capacity} running · ${m.available} avail</div>
|
||||||
|
<div class="muted">${(m.roles||[]).map((r) => `<span class="pill">${r}</span>`).join(' ')}</div>
|
||||||
|
${spark(m.rtt)}
|
||||||
|
<div>
|
||||||
|
<button class="act" onclick="act('/api/machines/${m.id}/${m.enabled ? 'disable' : 'enable'}')">${m.enabled ? 'disable' : 'enable'}</button>
|
||||||
|
${m.kind === 'ssh' ? `<button class="act" onclick="act('/api/machines/${m.id}/check')">ssh-check</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}).join('') : `<div class="empty">${EMPTY_SVG}<div>No machines yet</div><p>Add a machine above. Roles are chips, not a truncated text field.</p></div>`;
|
||||||
|
const services = Object.values(s.services || {});
|
||||||
|
if (!services.length) {
|
||||||
|
$('rows').innerHTML = `<div class="empty">${EMPTY_SVG}<div>No services</div><p>Fleet catalog is empty on this host.</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('rows').innerHTML = '<div class="table-wrap"><table><thead><tr><th>Service</th><th>Health</th><th>Floor</th><th>RTT</th><th>Instances</th><th>Power</th></tr></thead><tbody>' +
|
||||||
|
services.map((sv) => {
|
||||||
|
const h = sv.health || 'down';
|
||||||
|
const inst = (sv.instances || []).map((i) => {
|
||||||
|
const st = !i.pid ? 'dead' : i.paused ? 'paused' : i.healthy === false ? 'unhealthy' : 'up';
|
||||||
|
const cls = st === 'up' ? 'good' : st === 'paused' ? 'warn' : 'bad';
|
||||||
|
return `<div class="inst"><span class="pill ${cls}">${st}</span> ${i.id} @${i.machine||'local'} :${i.healthPort}
|
||||||
|
${moreMenu([
|
||||||
|
{ label: 'Pause replica', path: `/api/instances/${i.id}/pause` },
|
||||||
|
{ label: 'Resume replica', path: `/api/instances/${i.id}/resume` },
|
||||||
|
{ label: 'Restart replica', path: `/api/instances/${i.id}/restart`, confirm: `Restart ${i.id}?`, danger: true },
|
||||||
|
])}
|
||||||
|
</div>`;
|
||||||
|
}).join('') || '<span class="muted">none</span>';
|
||||||
|
const pausedAll = (sv.instances || []).length && (sv.instances || []).every((i) => i.paused);
|
||||||
|
const power = h === 'down' || !sv.available ? 'off' : pausedAll ? 'paused' : 'on';
|
||||||
|
const powerCls = power === 'on' ? 'good' : power === 'paused' ? 'warn' : 'bad';
|
||||||
|
return `<tr class="${h}">
|
||||||
|
<td><strong>${sv.id}</strong><br><span class="muted">${sv.title||''}</span></td>
|
||||||
|
<td>${pill(h)}</td>
|
||||||
|
<td>${sv.available} / min ${sv.min}${sv.belowFloor ? ' <span class="pill bad">below</span>' : ''}</td>
|
||||||
|
<td>${spark(sv.rtt)}</td>
|
||||||
|
<td>${inst}</td>
|
||||||
|
<td>
|
||||||
|
<span class="pill ${powerCls}" title="Status, not a toggle">${power}</span>
|
||||||
|
${moreMenu([
|
||||||
|
{ label: 'Start (keep floor)', path: `/api/services/${sv.id}/start` },
|
||||||
|
{ label: 'Pause all replicas', path: `/api/services/${sv.id}/pause` },
|
||||||
|
{ label: 'Stop (disable keepFloor)', path: `/api/services/${sv.id}/stop`, confirm: `Turn off ${sv.id}? This disables keepFloor.`, danger: true },
|
||||||
|
])}
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('') + '</tbody></table></div>';
|
||||||
|
}
|
||||||
|
$('identityPick')?.addEventListener('change', (e) => {
|
||||||
|
const f = e.target.files && e.target.files[0];
|
||||||
|
if (!f) return;
|
||||||
|
document.querySelector('input[name="identityFile"]').value = '~/.ssh/' + f.name;
|
||||||
|
});
|
||||||
|
$('addMachine').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = e.target;
|
||||||
|
const roles = [...f.querySelectorAll('input[name=role]:checked')].map((el) => el.value);
|
||||||
|
await j('/api/machines', {
|
||||||
|
method:'POST', headers:{'content-type':'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: f.id.value, host: f.host.value, kind: f.kind.value, user: f.user.value,
|
||||||
|
sshPort: Number(f.sshPort.value||22), identityFile: f.identityFile.value,
|
||||||
|
capacity: Number(f.capacity.value||8),
|
||||||
|
roles: roles.length ? roles : ['tree-node'], enabled: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
f.reset();
|
||||||
|
f.querySelector('input[name=role][value="tree-node"]').checked = true;
|
||||||
|
f.querySelector('input[name=role][value="archive-worm"]').checked = true;
|
||||||
|
await drawFleet();
|
||||||
|
});
|
||||||
|
async function bootTrace() {
|
||||||
|
const { body } = await j('/api/sim/actions');
|
||||||
|
$('action').innerHTML = (body.actions||[]).map(a => `<option value="${a.key}">${a.label}</option>`).join('');
|
||||||
|
$('action').value = 'timestamp_and_wait';
|
||||||
|
}
|
||||||
|
function faults() {
|
||||||
|
const f = {};
|
||||||
|
for (const id of ['failEdge402','failNatsDropEvents','recover']) if ($(id).checked) f[id] = true;
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
async function runSim() {
|
||||||
|
const action = $('action').value;
|
||||||
|
let publicMetadata;
|
||||||
|
try { publicMetadata = $('publicMeta').value ? JSON.parse($('publicMeta').value) : undefined; } catch { publicMetadata = { raw: $('publicMeta').value }; }
|
||||||
|
const { body } = await j('/api/sim/run', {
|
||||||
|
method:'POST', headers:{'content-type':'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
action, includeAttached: $('includeAttached').checked, includeTree: $('includeTree').checked,
|
||||||
|
faults: faults(),
|
||||||
|
input: { data: $('data').value, sha256: $('sha256').value.trim()||undefined, publicMetadata, items: $('data').value, email: 'ada@example.com' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
$('response').textContent = JSON.stringify(body.response, null, 2);
|
||||||
|
if (body.response?.sha256) $('sha256').value = body.response.sha256;
|
||||||
|
if (body.response?.leaves) $('sha256').value = body.response.leaves[0];
|
||||||
|
$('console').innerHTML = (body.trace||[]).map(e => {
|
||||||
|
const arrow = e.dir === 'out' ? '←' : e.dir === 'in' ? '→' : '·';
|
||||||
|
return `<div class="hop ${e.status}"><span>${e.tMs}ms</span><span>${arrow}</span><span class="mod">${e.module}</span><span><strong>${e.hop}</strong> <span class="addr">${e.address||''}</span> ${e.note? '— '+e.note:''}</span></div>`;
|
||||||
|
}).join('');
|
||||||
|
const m = body.monitor || {};
|
||||||
|
$('monitors').innerHTML = `<p>${pill(m.ok ? 'operational' : 'degraded')} ${m.stats ? m.stats.hops+' hops · '+m.stats.errors+' errors' : ''}</p>` +
|
||||||
|
(m.suggestions||[]).map(s => `<div class="card ${s.severity==='high'?'down':'degraded'}"><span class="pill ${s.severity==='high'?'bad':'warn'}">${s.severity}</span> ${s.text}</div>`).join('');
|
||||||
|
}
|
||||||
|
$('run').onclick = runSim;
|
||||||
|
$('reset').onclick = async () => { await j('/api/sim/reset', { method:'POST' }); $('console').innerHTML=''; $('response').textContent='World reset.'; };
|
||||||
|
document.querySelectorAll('[data-sc]').forEach((b) => b.addEventListener('click', async () => {
|
||||||
|
const sc = b.dataset.sc;
|
||||||
|
if (sc === 'batch') { $('action').value = 'batch_timestamp'; $('data').value = 'invoice-1001\ninvoice-1002\ninvoice-1003'; }
|
||||||
|
if (sc === 'leaf') { $('action').value = 'hash_lookup'; $('includeTree').checked = false; }
|
||||||
|
if (sc === 'tree') { $('action').value = 'tree_lookup'; $('includeTree').checked = true; }
|
||||||
|
await runSim();
|
||||||
|
}));
|
||||||
|
drawFleet();
|
||||||
|
bootTrace().then(async () => {
|
||||||
|
const tab = new URLSearchParams(location.search).get('tab');
|
||||||
|
if (tab) document.querySelector(`nav button[data-tab="${tab}"]`)?.click();
|
||||||
|
const demo = new URLSearchParams(location.search).get('demo');
|
||||||
|
if (demo) {
|
||||||
|
const b = document.querySelector(`[data-sc="${demo}"]`);
|
||||||
|
if (b) b.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setInterval(drawFleet, 2000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11
scripts/install-systemd.sh
Executable file
11
scripts/install-systemd.sh
Executable file
|
|
@ -0,0 +1,11 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||||
|
mkdir -p "$UNIT_DIR"
|
||||||
|
install -m 644 "$ROOT/systemd/verae-fleet.service" "$UNIT_DIR/verae-fleet.service"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable verae-fleet.service
|
||||||
|
loginctl enable-linger "$USER" || true
|
||||||
|
echo "enabled verae-fleet (linger). start: systemctl --user start verae-fleet"
|
||||||
|
echo "do not also nohup node src/cli.js serve"
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue