metis/pkg/service/server.go

874 lines
29 KiB
Go

package service
import (
"encoding/json"
"html/template"
"net/http"
"strings"
)
type userContext struct {
Name string
Groups []string
}
type pageData struct {
State PageState
AllowedGroups []string
DefaultMessage string
BootJSON template.JS
}
// Handler returns the Metis HTTP handler.
func (a *App) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", a.handleHealth)
mux.HandleFunc("/metrics", a.handleMetrics)
mux.HandleFunc("/internal/sentinel/snapshot", a.handleInternalSnapshot)
mux.HandleFunc("/internal/sentinel/watch", a.handleInternalWatch)
mux.HandleFunc("/api/state", a.withUIAuth(a.handleState))
mux.HandleFunc("/api/devices", a.withUIAuth(a.handleDevices))
mux.HandleFunc("/api/jobs/build", a.withUIAuth(a.handleBuild))
mux.HandleFunc("/api/jobs/replace", a.withUIAuth(a.handleReplace))
mux.HandleFunc("/api/sentinel/watch", a.withUIAuth(a.handleWatch))
mux.HandleFunc("/", a.withUIAuth(a.handleIndex))
return mux
}
func (a *App) handleHealth(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "service": "metis"})
}
func (a *App) handleMetrics(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
a.metrics.Render(w)
}
func (a *App) handleInternalSnapshot(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var record SnapshotRecord
if err := json.NewDecoder(r.Body).Decode(&record); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := a.StoreSnapshot(record); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
}
func (a *App) handleInternalWatch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
event, err := a.WatchSentinel()
if err != nil {
a.metrics.RecordWatch("error")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, event)
}
func (a *App) handleState(w http.ResponseWriter, r *http.Request) {
host := r.URL.Query().Get("host")
writeJSON(w, http.StatusOK, a.State(host))
}
func (a *App) handleDevices(w http.ResponseWriter, r *http.Request) {
host := r.URL.Query().Get("host")
devices, err := a.ListDevices(host)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, map[string]any{"devices": devices})
}
func (a *App) handleBuild(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
node := requestValue(r, "node")
job, err := a.Build(node)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, http.StatusAccepted, job)
}
func (a *App) handleReplace(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
node := requestValue(r, "node")
host := requestValue(r, "host")
device := requestValue(r, "device")
job, err := a.Replace(node, host, device)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, http.StatusAccepted, job)
}
func (a *App) handleWatch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
event, err := a.WatchSentinel()
if err != nil {
a.metrics.RecordWatch("error")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, event)
}
func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) {
state := a.State(a.settings.DefaultFlashHost)
payload, _ := json.Marshal(state)
data := pageData{
State: state,
AllowedGroups: append([]string{}, a.settings.AllowedGroups...),
BootJSON: template.JS(payload),
}
_ = metisPage.Execute(w, data)
}
func (a *App) withUIAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := a.authorize(r)
if !ok {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if user.Name != "" {
w.Header().Set("X-Metis-User", user.Name)
}
next(w, r)
}
}
func (a *App) authorize(r *http.Request) (userContext, bool) {
user := firstNonEmptyHeader(r, "X-Auth-Request-User", "X-Forwarded-User", "X-Auth-Request-Email", "X-Forwarded-Email")
groups := splitHeaderList(firstNonEmptyHeader(r, "X-Auth-Request-Groups", "X-Forwarded-Groups"))
if len(groups) == 0 {
return userContext{Name: user, Groups: groups}, false
}
for _, group := range groups {
for _, allowed := range a.settings.AllowedGroups {
if normalizeGroupValue(group) == normalizeGroupValue(allowed) {
return userContext{Name: user, Groups: groups}, true
}
}
}
return userContext{Name: user, Groups: groups}, false
}
func firstNonEmptyHeader(r *http.Request, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(r.Header.Get(key)); value != "" {
return value
}
}
return ""
}
func splitHeaderList(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func normalizeGroupValue(raw string) string {
value := strings.ToLower(strings.TrimSpace(raw))
return strings.TrimPrefix(value, "/")
}
func requestValue(r *http.Request, key string) string {
if err := r.ParseForm(); err == nil {
if value := strings.TrimSpace(r.Form.Get(key)); value != "" {
return value
}
}
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err == nil {
if value, ok := payload[key].(string); ok {
return strings.TrimSpace(value)
}
}
return ""
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Metis Control</title>
<style>
:root{
--bg:#081018;
--bg-soft:#0e1722;
--panel:#101c29;
--panel-strong:#172535;
--line:rgba(149,177,205,.18);
--line-strong:rgba(149,177,205,.28);
--ink:#f3f7fb;
--muted:#9bb0c4;
--brand:#3da7ff;
--brand-deep:#1c6ca8;
--accent:#ff9a4a;
--success:#3dd08c;
--danger:#ff6f6f;
--warn:#f2c14c;
--shadow:0 24px 60px rgba(0,0,0,.35);
}
*{box-sizing:border-box}
body{
margin:0;
min-height:100vh;
font-family:"Avenir Next","Trebuchet MS","Segoe UI",sans-serif;
color:var(--ink);
background:
radial-gradient(circle at top left, rgba(61,167,255,.20), transparent 28rem),
radial-gradient(circle at top right, rgba(255,154,74,.16), transparent 26rem),
linear-gradient(180deg, #071018 0%, #0a131d 50%, #0b1622 100%);
}
.frame{
max-width:1320px;
margin:0 auto;
padding:2rem 1.25rem 3rem;
}
.mast{
display:flex;
justify-content:space-between;
align-items:flex-end;
gap:1.5rem;
margin-bottom:1rem;
}
.eyebrow{
letter-spacing:.14em;
text-transform:uppercase;
font-size:.72rem;
color:#81c6ff;
margin-bottom:.35rem;
font-weight:800;
}
h1{
margin:0;
font-size:clamp(2rem,4vw,3.4rem);
line-height:1;
}
.sub{
max-width:56rem;
color:var(--muted);
margin-top:.7rem;
font-size:1rem;
}
.badge{
display:inline-flex;
align-items:center;
gap:.45rem;
padding:.78rem 1rem;
background:rgba(16,28,41,.82);
border:1px solid var(--line);
border-radius:999px;
box-shadow:var(--shadow);
font-size:.92rem;
}
.banner{
display:flex;
align-items:flex-start;
gap:.75rem;
padding:1rem 1.1rem;
border-radius:1rem;
border:1px solid var(--line);
background:rgba(16,28,41,.84);
margin-bottom:1rem;
box-shadow:var(--shadow);
}
.banner.hidden{display:none}
.banner.info{border-color:rgba(61,167,255,.32);background:rgba(17,36,54,.88)}
.banner.success{border-color:rgba(61,208,140,.32);background:rgba(10,41,31,.9)}
.banner.error{border-color:rgba(255,111,111,.32);background:rgba(56,18,18,.9)}
.banner.warn{border-color:rgba(242,193,76,.32);background:rgba(53,40,13,.9)}
.banner strong{display:block;margin-bottom:.15rem}
.grid{
display:grid;
grid-template-columns:1.16fr .9fr;
gap:1rem;
}
.stack{display:grid;gap:1rem}
.card{
background:linear-gradient(180deg, rgba(16,28,41,.95), rgba(12,21,31,.94));
border:1px solid var(--line);
border-radius:1.35rem;
padding:1.1rem;
box-shadow:var(--shadow);
}
.card h2{
margin:0 0 .35rem;
font-size:1rem;
text-transform:uppercase;
letter-spacing:.1em;
color:#8bccff;
}
.hint{
color:var(--muted);
font-size:.92rem;
margin-bottom:1rem;
}
.microcopy{
color:var(--muted);
font-size:.84rem;
margin-top:.5rem;
min-height:1.2rem;
}
.form-grid{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:.85rem;
}
label{
display:grid;
gap:.35rem;
font-weight:700;
font-size:.92rem;
}
select, button{
width:100%;
border-radius:.95rem;
border:1px solid var(--line-strong);
padding:.9rem .95rem;
font:inherit;
}
select{
background:rgba(11,21,33,.96);
color:var(--ink);
min-height:3.1rem;
}
select:focus, button:focus{
outline:2px solid rgba(61,167,255,.28);
outline-offset:2px;
}
button{
cursor:pointer;
background:linear-gradient(135deg,var(--brand) 0%,var(--brand-deep) 100%);
color:#fff;
border:none;
font-weight:800;
letter-spacing:.03em;
box-shadow:0 14px 30px rgba(17,66,102,.30);
transition:transform .12s ease, opacity .12s ease;
}
button:hover{transform:translateY(-1px)}
button.secondary{
background:rgba(18,30,43,.96);
color:var(--ink);
border:1px solid var(--line-strong);
box-shadow:none;
}
button:disabled{
opacity:.55;
cursor:not-allowed;
transform:none;
}
.actions{
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:.7rem;
margin-top:.9rem;
}
.list{
display:grid;
gap:.7rem;
max-height:30rem;
overflow:auto;
}
.item{
border:1px solid rgba(149,177,205,.14);
border-radius:1rem;
padding:.85rem .95rem;
background:rgba(8,17,27,.78);
}
.item-head{
display:flex;
justify-content:space-between;
gap:1rem;
margin-bottom:.35rem;
font-weight:700;
}
.meta{
color:var(--muted);
font-size:.85rem;
}
.bar{
height:.55rem;
background:rgba(149,177,205,.12);
border-radius:999px;
overflow:hidden;
margin-top:.7rem;
}
.bar > span{
display:block;
height:100%;
background:linear-gradient(90deg,var(--accent),var(--brand));
}
.pill{
display:inline-block;
padding:.2rem .55rem;
border-radius:999px;
font-size:.75rem;
text-transform:uppercase;
letter-spacing:.08em;
background:rgba(61,167,255,.12);
color:#9bd1ff;
}
.pill.done{background:rgba(61,208,140,.12);color:var(--success)}
.pill.error{background:rgba(255,111,111,.12);color:var(--danger)}
.pill.running{background:rgba(255,154,74,.14);color:var(--accent)}
.mini{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:.7rem;
}
.stat{
padding:.8rem .9rem;
border-radius:1rem;
background:rgba(8,17,27,.72);
border:1px solid rgba(149,177,205,.12);
}
.stat strong{display:block;font-size:1.35rem}
.row{
display:flex;
justify-content:space-between;
gap:1rem;
align-items:center;
}
code{
font-family:"IBM Plex Mono","SFMono-Regular","Menlo",monospace;
font-size:.88em;
}
@media (max-width: 980px){
.grid,.form-grid,.actions,.mini{grid-template-columns:1fr}
.mast{align-items:flex-start;flex-direction:column}
.row{align-items:flex-start;flex-direction:column}
}
</style>
</head>
<body>
<main class="frame">
<section class="mast">
<div>
<div class="eyebrow">Atlas Recovery Plane</div>
<h1>Metis Control</h1>
<p class="sub">Build replacement node images, verify removable media on the Texas flash host, and keep image templates fresh with sentinel-driven drift tracking.</p>
</div>
<div class="badge"><strong>Default flash host:</strong> <span id="default-host">{{.State.DefaultFlashHost}}</span></div>
</section>
<section id="status-banner" class="banner hidden" aria-live="polite">
<div>
<strong id="status-title">Ready</strong>
<div id="status-text">Metis is ready.</div>
</div>
</section>
<section class="grid">
<div class="stack">
<article class="card">
<h2>Replacement Run</h2>
<p class="hint">This UI is meant for the one-shot recovery path: build the node image, verify the card on the flash host, then write it and hand off only the physical swap.</p>
<div class="form-grid">
<label>Target node
<select id="node-select"></select>
</label>
<label>Flash host
<select id="host-select"></select>
</label>
<label style="grid-column:1 / -1">Detected removable media
<select id="device-select"></select>
</label>
</div>
<div class="microcopy" id="host-note"></div>
<div class="microcopy" id="device-note"></div>
<div class="microcopy" id="artifact-note"></div>
<div class="actions">
<button class="secondary" id="refresh-devices">Refresh media</button>
<button class="secondary" id="build-only">Build image only</button>
<button id="replace-run">Build and flash</button>
</div>
</article>
<article class="card">
<h2>Live Jobs</h2>
<p class="hint">Progress updates stream from the running Metis operation. The replacement flow automatically tries to clear the stale Kubernetes node object before the card write.</p>
<div id="jobs" class="list"></div>
</article>
</div>
<div class="stack">
<article class="card">
<h2>Sentinel Watch</h2>
<p class="hint">Ariadne should hit the internal sentinel watch route on a schedule. You can also run it manually here when you want the latest template recommendations immediately.</p>
<div class="mini">
<div class="stat">
<span class="meta">Tracked nodes</span>
<strong id="snapshot-count">0</strong>
</div>
<div class="stat">
<span class="meta">Class targets</span>
<strong id="target-count">0</strong>
</div>
</div>
<div class="actions" style="grid-template-columns:1fr">
<button id="sentinel-watch">Run sentinel watch now</button>
</div>
</article>
<article class="card">
<div class="row">
<div>
<h2>Recent Changes</h2>
<p class="hint">This stream keeps the image/template story digestible: builds, flashes, snapshot intake, and sentinel-driven target changes all land here.</p>
</div>
</div>
<div id="events" class="list"></div>
</article>
</div>
</section>
</main>
<script id="boot" type="application/json">{{.BootJSON}}</script>
<script>
const boot = JSON.parse(document.getElementById('boot').textContent);
let state = boot;
let busy = false;
const nodeSelect = document.getElementById('node-select');
const hostSelect = document.getElementById('host-select');
const deviceSelect = document.getElementById('device-select');
const jobsEl = document.getElementById('jobs');
const eventsEl = document.getElementById('events');
const snapshotCountEl = document.getElementById('snapshot-count');
const targetCountEl = document.getElementById('target-count');
const hostNoteEl = document.getElementById('host-note');
const deviceNoteEl = document.getElementById('device-note');
const artifactNoteEl = document.getElementById('artifact-note');
const bannerEl = document.getElementById('status-banner');
const bannerTitleEl = document.getElementById('status-title');
const bannerTextEl = document.getElementById('status-text');
const actionButtons = Array.from(document.querySelectorAll('button'));
function fmtTime(value){
if(!value){ return 'pending'; }
const date = new Date(value);
return isNaN(date.getTime()) ? value : date.toLocaleString();
}
function fmtBytes(value){
if(!value){ return '0 B'; }
const units = ['B','KiB','MiB','GiB','TiB'];
let size = Number(value);
let idx = 0;
while(size >= 1024 && idx < units.length - 1){
size /= 1024;
idx += 1;
}
return size.toFixed(size >= 10 || idx === 0 ? 0 : 1) + ' ' + units[idx];
}
function banner(kind, title, text){
bannerEl.className = 'banner ' + kind;
bannerTitleEl.textContent = title;
bannerTextEl.textContent = text;
}
function clearBanner(){
bannerEl.className = 'banner hidden';
}
function setBusy(nextBusy){
busy = nextBusy;
actionButtons.forEach((button)=>{
button.disabled = nextBusy;
});
}
function bestDevicePath(){
return state.preferred_device || (state.devices[0] ? state.devices[0].path : '');
}
function setOptions(select, values, labeler, emptyLabel){
const current = select.value;
select.innerHTML = '';
if(!values.length){
const option = document.createElement('option');
option.value = '';
option.textContent = emptyLabel;
select.appendChild(option);
select.value = '';
return;
}
values.forEach((value)=>{
const option = document.createElement('option');
option.value = value;
option.textContent = labeler ? labeler(value) : value;
select.appendChild(option);
});
if(current && values.includes(current)){
select.value = current;
}
}
function renderJobs(){
jobsEl.innerHTML = '';
const jobs = state.jobs.length ? state.jobs : [{kind:'idle',status:'done',message:'No active or recent Metis jobs yet.',progress_pct:100,started_at:new Date().toISOString(),finished_at:new Date().toISOString()}];
jobs.forEach((job)=>{
const wrap = document.createElement('div');
wrap.className = 'item';
const statusClass = job.status === 'error' ? 'error' : (job.status === 'done' ? 'done' : (job.status === 'running' ? 'running' : ''));
const title = job.kind.toUpperCase() + (job.node ? ' · ' + job.node : '');
const started = fmtTime(job.started_at) + (job.device ? ' · ' + job.device : '') + (job.host ? ' · ' + job.host : '');
const detailBits = [];
if(job.written_bytes){ detailBits.push(fmtBytes(job.written_bytes) + ' / ' + fmtBytes(job.total_bytes)); }
if(job.artifact){ detailBits.push(job.artifact); }
if(job.error){ detailBits.push(job.error); }
wrap.innerHTML =
'<div class="item-head">' +
'<span>' + title + '</span>' +
'<span class="pill ' + statusClass + '">' + job.status + '</span>' +
'</div>' +
'<div>' + (job.message || job.stage || 'queued') + '</div>' +
'<div class="meta">' + started + '</div>' +
'<div class="meta">' + detailBits.join(' · ') + '</div>' +
'<div class="bar"><span style="width:' + Math.max(0, Math.min(100, job.progress_pct || 0)) + '%"></span></div>';
jobsEl.appendChild(wrap);
});
}
function renderEvents(){
eventsEl.innerHTML = '';
state.events.forEach((event)=>{
const wrap = document.createElement('div');
wrap.className = 'item';
wrap.innerHTML =
'<div class="item-head">' +
'<span>' + event.summary + '</span>' +
'<span class="meta">' + fmtTime(event.time) + '</span>' +
'</div>' +
'<div class="meta"><code>' + event.kind + '</code></div>';
eventsEl.appendChild(wrap);
});
}
function render(){
const nodeNames = (state.nodes || []).map((node)=>node.name).filter(Boolean);
setOptions(nodeSelect, nodeNames, null, 'No replacement nodes available');
if(!nodeSelect.value && nodeNames.length){
nodeSelect.value = nodeNames[0];
}
setOptions(hostSelect, state.flash_hosts || [], null, 'No flash hosts available');
if(state.selected_host && (state.flash_hosts || []).includes(state.selected_host)){
hostSelect.value = state.selected_host;
}
if(!hostSelect.value && state.default_flash_host){
hostSelect.value = state.default_flash_host;
}
const devicePaths = (state.devices || []).map((device)=>device.path);
setOptions(deviceSelect, devicePaths, (path)=>{
const dev = state.devices.find((item)=>item.path === path);
if(!dev){ return path; }
const parts = [dev.path, fmtBytes(dev.size_bytes)];
if(dev.model){ parts.push(dev.model); }
else if(dev.transport){ parts.push(dev.transport); }
return parts.join(' · ');
}, state.device_error || 'No removable media detected');
const preferredDevice = bestDevicePath();
const selectedDeviceStillExists = devicePaths.includes(deviceSelect.value);
if(preferredDevice && (!selectedDeviceStillExists || !deviceSelect.value)){
deviceSelect.value = preferredDevice;
}
const selectedHost = hostSelect.value || state.default_flash_host;
const hostIsLocal = selectedHost === state.local_host || selectedHost === state.default_flash_host;
hostNoteEl.textContent = hostIsLocal
? 'Metis is running on ' + state.local_host + ', so media detection and flashing are live for this host.'
: 'The selected host is listed from cluster inventory, but this Metis instance only has direct media access on ' + state.local_host + '.';
if(state.device_error){
deviceNoteEl.textContent = state.device_error;
} else if(state.devices.length){
deviceNoteEl.textContent = 'Best candidate preselected: ' + (bestDevicePath() || 'none');
} else {
deviceNoteEl.textContent = 'Insert an SD card or removable drive on the selected flash host, then refresh media.';
}
const artifact = (state.artifacts || {})[nodeSelect.value];
artifactNoteEl.textContent = artifact && artifact.path
? 'Latest built image: ' + artifact.path
: 'Successful build-only runs are stored on ' + state.local_host + ' under /var/lib/metis/artifacts/<node>.img.';
document.getElementById('build-only').disabled = busy || !nodeSelect.value;
document.getElementById('refresh-devices').disabled = busy;
document.getElementById('replace-run').disabled = busy || !nodeSelect.value || !deviceSelect.value || !!state.device_error;
document.getElementById('sentinel-watch').disabled = busy;
renderJobs();
renderEvents();
snapshotCountEl.textContent = state.snapshots.length;
targetCountEl.textContent = Object.keys(state.targets || {}).length;
}
async function refreshState(opts = {}){
const host = hostSelect.value || state.default_flash_host;
const resp = await fetch('/api/state?host=' + encodeURIComponent(host));
if(!resp.ok){
const text = await resp.text();
throw new Error(text || 'Could not refresh Metis state');
}
state = await resp.json();
render();
if(!opts.silent && state.device_error){
banner('warn', 'Flash host needs attention', state.device_error);
}
}
async function post(path, body){
const resp = await fetch(path, {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(body)
});
if(!resp.ok){
const text = await resp.text();
throw new Error(text || ('Request failed for ' + path));
}
const contentType = resp.headers.get('content-type') || '';
return contentType.includes('application/json') ? resp.json() : {};
}
function requireValue(value, message){
if(stringsafe(value)){
return true;
}
banner('error', 'Missing input', message);
return false;
}
function stringsafe(value){
return !!String(value || '').trim();
}
async function runAction(title, pending, fn){
try {
setBusy(true);
banner('info', title, pending);
await fn();
} catch (error) {
banner('error', title + ' failed', error.message || String(error));
} finally {
setBusy(false);
render();
}
}
document.getElementById('refresh-devices').addEventListener('click', async ()=>{
await runAction('Refreshing media', 'Checking removable devices on the selected flash host.', async ()=>{
await refreshState();
if(state.device_error){
banner('warn', 'Flash host needs attention', state.device_error);
return;
}
banner('success', 'Media refreshed', state.devices.length ? 'Detected ' + state.devices.length + ' flash candidate(s).' : 'No removable media candidates are visible yet.');
});
});
document.getElementById('build-only').addEventListener('click', async ()=>{
if(!requireValue(nodeSelect.value, 'Choose the target node image you want Metis to build first.')){
return;
}
await runAction('Starting image build', 'Queueing the node image build now.', async ()=>{
await post('/api/jobs/build', {node: nodeSelect.value});
await refreshState({silent:true});
banner('success', 'Image build queued', 'Metis started building the replacement image for ' + nodeSelect.value + '.');
});
});
document.getElementById('replace-run').addEventListener('click', async ()=>{
if(!requireValue(nodeSelect.value, 'Choose the target node whose SD card image should be built and flashed.')){
return;
}
if(!requireValue(deviceSelect.value, 'Choose removable media before starting a build-and-flash run.')){
return;
}
if(state.device_error){
banner('error', 'Flash host unavailable', state.device_error);
return;
}
await runAction('Starting build and flash', 'Queueing the full replacement workflow now.', async ()=>{
await post('/api/jobs/replace', {node: nodeSelect.value, host: hostSelect.value, device: deviceSelect.value});
await refreshState({silent:true});
banner('success', 'Replacement workflow queued', 'Metis is building the image for ' + nodeSelect.value + ' and will flash ' + deviceSelect.value + '.');
});
});
document.getElementById('sentinel-watch').addEventListener('click', async ()=>{
await runAction('Running sentinel watch', 'Refreshing template recommendations from the latest snapshots.', async ()=>{
await post('/api/sentinel/watch', {});
await refreshState({silent:true});
banner('success', 'Sentinel watch complete', 'Metis refreshed its template recommendations.');
});
});
hostSelect.addEventListener('change', async ()=>{
await runAction('Changing flash host', 'Loading removable media candidates for the selected flash host.', async ()=>{
await refreshState();
if(!state.device_error){
banner('success', 'Flash host ready', 'Loaded removable media candidates for ' + (hostSelect.value || state.default_flash_host) + '.');
}
});
});
nodeSelect.addEventListener('change', render);
render();
clearBanner();
setInterval(async ()=>{
try {
await refreshState({silent:true});
} catch (_error) {
// Keep the live dashboard calm during background polling.
}
}, 5000);
</script>
</body>
</html>`))