This commit is contained in:
2026-08-20 11:24:42 -03:00
parent a65c92257d
commit 83b6cbebe3
64 changed files with 7688 additions and 0 deletions

3
rig/sample-rig/rig-ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
public/
dist/

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>IT WORKS</title>
</head>
<body>
<main id="app"></main>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,108 @@
# How to plug the UI into whatever k8s you generated. THIS IS THE WHOLE THING:
# one Pod running the vite app, one Service to reach it.
#
# Optional by design. The UI complements a rig; it is not part of the end
# product, and a rig is complete and useful without it. Apply this only when you
# want the listing:
#
# kubectl apply -n <your-namespace> -f rig-ui/k8s.yaml
#
# A bare Pod, not a Deployment — this is a dev-loop convenience, not a workload
# to keep alive. If it dies you re-apply it; nothing depends on it staying up.
#
# The app and bundle.json arrive as a ConfigMap named `rig-ui`, which
# ctrl/manifest.py generates from the folder. Nothing is baked into an image, so
# editing bundle.json and re-applying is the whole update cycle.
apiVersion: v1
kind: Pod
metadata:
name: rig-ui
labels:
app: rig-ui
spec:
containers:
- name: vite
image: node:22-alpine
workingDir: /app
# npm install at start: no image to build and no registry to publish to,
# which is the point of a minimal plug-in. It needs egress to a registry —
# on a locked-down cluster point npm at the internal one, or bake an image
# instead. Nothing else here changes if you do.
command: ["sh", "-c"]
# A ConfigMap mounts flat (keys cannot contain '/'), so the files are
# placed into vite's expected layout here. bundle.json goes to public/
# because that is what vite serves at /bundle.json, which is where the
# app fetches it.
args:
- |
mkdir -p /app/src /app/public &&
cp /src/package.json /src/vite.config.js /src/index.html /app/ &&
cp /src/main.js /src/style.css /app/src/ &&
cp /src/bundle.json /app/public/ &&
npm install --no-audit --no-fund &&
npm run dev
env:
# Rendered in the heading so two rigs sharing a cluster stay
# distinguishable. Set from the namespace by ctrl/manifest.py.
- name: VITE_RIG_NAME
value: __RIG_NAME__
ports:
- name: http
containerPort: 5173
volumeMounts:
# /src is read-only from the ConfigMap; the app is copied to a writable
# /app because npm install has to create node_modules.
- name: rig-ui
mountPath: /src
- name: app
mountPath: /app
readinessProbe:
httpGet: { path: /, port: 5173 }
# npm install decides how long this takes, and it is the slow part.
initialDelaySeconds: 15
periodSeconds: 5
failureThreshold: 30
resources:
requests: { memory: 128Mi, cpu: 50m }
limits: { memory: 512Mi }
volumes:
- name: rig-ui
configMap:
name: rig-ui
- name: app
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: rig-ui
labels:
app: rig-ui
# No annotations, deliberately — see k8s/app.yaml. The target is EKS but this
# stays VPC-agnostic: no subnets, no security groups, no -scheme, no -type.
# A bare LoadBalancer is what lets one manifest work on kind and on EKS.
spec:
type: LoadBalancer
selector:
app: rig-ui
ports:
- name: http
port: 80
targetPort: 5173
protocol: TCP
# Pinned, because a LoadBalancer Service also allocates a NodePort and
# this is the only address that works everywhere.
#
# On WSL the MetalLB address is on a docker bridge INSIDE the Linux VM,
# and Windows has no route to it — the page looks broken while the
# cluster is perfectly healthy. 30080 is what rig's `hostport` ingress
# mode publishes to the host, so this is reachable at
# localhost:$HTTP_PORT from a Windows browser with nothing configured.
#
# Costs nothing elsewhere: MetalLB still assigns an external IP on Linux,
# and on EKS the load balancer targets this NodePort anyway. One Service,
# no per-environment branch.
#
# A pinned NodePort is cluster-unique, so two rigs must live in separate
# clusters — which is how they are run anyway.
nodePort: 30080

1164
rig/sample-rig/rig-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"name": "rig-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 5173"
},
"devDependencies": {
"vite": "^6"
}
}

View File

@@ -0,0 +1,136 @@
import "./style.css";
/* The IT WORKS page: renders bundle.json as the list of what shipped.
*
* Plain vite, no framework — this is a complement to the rig, not part of it,
* and it should stay small enough that nobody has to adopt a stack to read it.
*
* Laid out like soleprint's templated vein pages, because it does the same job:
* name each component, list what it exposes, show what comes back. Tool chrome
* and output are styled apart on purpose (see style.css) — that separation is
* what tells you whether you are reading the tool or its result.
*
* bundle.json is fetched at runtime rather than imported, so the same built app
* serves whatever rig it was copied into. Editing the ConfigMap changes the page
* without rebuilding.
*/
const esc = (s) =>
String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const tag = (text, on = false) =>
`<span class="tag${on ? " on" : ""}">${esc(text)}</span>`;
/* Tool chrome: one bordered card per component. */
function components(list, activeKey) {
if (!list?.length)
return `<div class="component"><p>nothing listed</p></div>`;
return list
.map((it) => {
const tags = [
it.standalone ? tag("standalone") : "",
it.state ? tag(it.state) : "",
activeKey && it[activeKey] ? tag("active", true) : "",
].join("");
return `<div class="component">
<h4>${esc(it.name ?? "?")} ${tags}</h4>
<p>${esc(it.summary ?? "")}</p>
</div>`;
})
.join("");
}
/* Endpoint rows: path on the left, what it returns on the right. */
function endpoints(list) {
return list
.map(
(e) => `<li><code>${esc(e.path)}</code>
<span class="desc">${esc(e.desc)}</span></li>`
)
.join("");
}
/* Output: what the endpoint above actually returns, so the page demonstrates
itself rather than describing what a demonstration would look like. */
function example(bundle) {
const sample = {
bundle: bundle.bundle?.name,
tools: (bundle.tools ?? []).map((t) => t.name),
rigs: (bundle.rigs ?? []).map((r) => r.name),
};
return `<pre class="output">${esc(JSON.stringify(sample, null, 2))}</pre>`;
}
/* Cluster state, when there is any to show.
*
* Fetched separately and allowed to fail: the bundle listing is the point, and a
* rig with no cluster reachable is a normal state, not an error. Renders nothing
* at all when absent. When the payload says `mocked`, say so loudly. */
function clusterSection(c) {
if (!c) return "";
const m = c.cluster ?? {};
const meta = [m.context, m.k8s, m.profile && `profile ${m.profile}`,
m.nodes && `${m.nodes} node${m.nodes > 1 ? "s" : ""}`]
.filter(Boolean).join(" · ");
return `
<h2>Cluster${c.mocked ? " (mocked)" : ""}</h2>
${c.mocked ? `<p class="mock">mocked — no cluster was queried; these are canned values</p>` : ""}
${meta ? `<p class="tagline">${esc(meta)}</p>` : ""}
<div class="components">${components(c.workloads)}</div>
<h2>Services</h2>
<div class="components">${components(c.services)}</div>`;
}
function render(b, name, cluster) {
const meta = b.bundle ?? {};
const next = (b.next ?? []).map((n) => `<li>${esc(n)}</li>`).join("");
return `
<h1><span class="ok">IT WORKS</span> — ${esc(name || meta.name || "rig")}</h1>
<p class="tagline">${esc(meta.description ?? "")}</p>
<h2>Tools (${b.tools?.length ?? 0})</h2>
<div class="components">${components(b.tools)}</div>
<h2>Rigs (${b.rigs?.length ?? 0})</h2>
<div class="components">${components(b.rigs, "active")}</div>
<h2>Endpoints</h2>
<ul class="endpoints">${endpoints([
{ path: "/", desc: "this page" },
{ path: "/bundle.json", desc: "the manifest it renders" },
])}</ul>
<h2>Example — GET /bundle.json</h2>
${example(b)}
${clusterSection(cluster)}
${next ? `<div class="next"><ul>${next}</ul></div>` : ""}`;
}
const app = document.getElementById("app");
const json = (path, required) =>
fetch(path)
.then((r) => {
if (r.ok) return r.json();
if (required) throw new Error(`${path} -> HTTP ${r.status}`);
return null; // optional: absent is a normal state, not an error
})
.catch((err) => {
if (required) throw err;
return null;
});
Promise.all([json("/bundle.json", true), json("/cluster.mock.json", false)])
// RIG_NAME is injected by vite from the pod env, so two rigs sharing a
// cluster are distinguishable even if a copied bundle.json kept its old name.
.then(([b, cluster]) => {
app.innerHTML = render(b, import.meta.env.VITE_RIG_NAME, cluster);
})
.catch((err) => {
app.innerHTML = `<h1 class="err">bundle unavailable</h1>
<p class="tagline">${esc(err.message)}</p>`;
});

View File

@@ -0,0 +1,139 @@
/* Minimal and self-contained — no framework dependency.
*
* The visual language follows soleprint's templated vein pages, because this
* page does the same job: say what a component is, list what it exposes, and
* show what comes back. Two treatments, deliberately distinct:
*
* TOOL CHROME bordered cards on the darker background, accent-coloured
* titles, endpoint rows separated by rules.
* OUTPUT a lighter raised block, monospace, pre-wrap and selectable —
* it is data, not furniture, and should read as a payload.
*
* Keeping them apart matters more than either looks: it is what tells you at a
* glance whether you are reading the tool or the thing it produced. */
:root {
--bg: #0d0d0f;
--surface: #16161a;
--surface-raised: #1e1e24;
--border: #2e2e38;
--border-strong: #3d3d4a;
--text: #e8e8f0;
--muted: #8888a0;
--accent: #3ecf8e;
--accent-dim: #f5a623;
--mono: "JetBrains Mono", "Cascadia Mono", Consolas, ui-monospace, monospace;
}
body {
margin: 0;
padding: 2.5rem 1.5rem;
background: var(--bg);
color: var(--text);
font: 14px/1.6 var(--mono);
}
main { max-width: 56rem; margin: 0 auto; }
h1 { margin: 0; font-size: 1.6rem; letter-spacing: 0.02em; }
h1 .ok { color: var(--accent); }
h1.err { color: #f06565; }
.tagline { color: var(--muted); margin: 0.35rem 0 2.25rem; }
h2 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
margin: 2.25rem 0 0.75rem;
font-weight: 600;
}
/* ── tool chrome ────────────────────────────────────────────────────────── */
.components {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
}
.component {
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: 8px;
padding: 0.75rem;
}
.component h4 {
margin: 0 0 0.25rem;
font-size: 0.95rem;
color: var(--accent);
}
.component p {
margin: 0;
font-size: 0.85rem;
color: var(--muted);
}
.endpoints { list-style: none; margin: 0; padding: 0; }
.endpoints li {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.6rem 0;
border-bottom: 1px solid var(--border-strong);
}
.endpoints li:last-child { border-bottom: none; }
.endpoints code {
background: var(--surface);
color: var(--accent);
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.endpoints .desc { color: var(--muted); font-size: 0.9rem; }
.tag {
font-size: 0.7rem;
padding: 0.1rem 0.45rem;
border-radius: 3px;
background: var(--border);
color: var(--muted);
white-space: nowrap;
}
.tag.on { background: var(--accent); color: var(--bg); }
/* ── output ─────────────────────────────────────────────────────────────── */
.output {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
font-size: 0.85rem;
white-space: pre-wrap;
word-break: break-word;
user-select: text;
color: var(--text);
margin: 0;
}
.output .k { color: var(--accent); }
/* Mocked-data banner. Loud on purpose: it only appears when the payload is
canned, and a demo that looks live but is not is worse than one that says so. */
.mock {
margin: 0 0 0.75rem;
padding: 0.4rem 0.75rem;
border: 1px dashed var(--accent-dim);
border-radius: 6px;
color: var(--accent-dim);
font-size: 0.8rem;
}
.next {
color: #555568;
font-size: 0.8rem;
margin-top: 2.5rem;
border-top: 1px solid var(--border);
padding-top: 1rem;
}
.next ul { margin: 0; padding: 0 0 0 1.1rem; }
.next li { list-style: disc; padding: 0.15rem 0; }

View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vite";
/* Serves on 0.0.0.0 so the pod is reachable through the Service, and allows any
* Host header because the address is assigned at runtime (MetalLB locally, a
* cloud load balancer on EKS) and is never known at build time. */
export default defineConfig({
server: { host: "0.0.0.0", port: 5173, strictPort: true, allowedHosts: true },
preview: { host: "0.0.0.0", port: 5173, strictPort: true, allowedHosts: true },
});