init rig
This commit is contained in:
3
rig/sample-rig/rig-ui/.gitignore
vendored
Normal file
3
rig/sample-rig/rig-ui/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
public/
|
||||
dist/
|
||||
12
rig/sample-rig/rig-ui/index.html
Normal file
12
rig/sample-rig/rig-ui/index.html
Normal 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>
|
||||
108
rig/sample-rig/rig-ui/k8s.yaml
Normal file
108
rig/sample-rig/rig-ui/k8s.yaml
Normal 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
1164
rig/sample-rig/rig-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
rig/sample-rig/rig-ui/package.json
Normal file
14
rig/sample-rig/rig-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
136
rig/sample-rig/rig-ui/src/main.js
Normal file
136
rig/sample-rig/rig-ui/src/main.js
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
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>`;
|
||||
});
|
||||
139
rig/sample-rig/rig-ui/src/style.css
Normal file
139
rig/sample-rig/rig-ui/src/style.css
Normal 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; }
|
||||
9
rig/sample-rig/rig-ui/vite.config.js
Normal file
9
rig/sample-rig/rig-ui/vite.config.js
Normal 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 },
|
||||
});
|
||||
Reference in New Issue
Block a user