2026-03-31 14:52:50 -03:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-31 20:53:59 -03:00
|
|
|
values := requestValues(r)
|
|
|
|
|
node := values["node"]
|
2026-03-31 14:52:50 -03:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-31 20:53:59 -03:00
|
|
|
values := requestValues(r)
|
|
|
|
|
node := values["node"]
|
|
|
|
|
host := values["host"]
|
|
|
|
|
device := values["device"]
|
2026-03-31 14:52:50 -03:00
|
|
|
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) {
|
2026-03-31 18:03:14 -03:00
|
|
|
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"))
|
2026-03-31 18:46:13 -03:00
|
|
|
if len(groups) == 0 {
|
|
|
|
|
return userContext{Name: user, Groups: groups}, false
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
for _, group := range groups {
|
|
|
|
|
for _, allowed := range a.settings.AllowedGroups {
|
2026-03-31 18:03:14 -03:00
|
|
|
if normalizeGroupValue(group) == normalizeGroupValue(allowed) {
|
2026-03-31 14:52:50 -03:00
|
|
|
return userContext{Name: user, Groups: groups}, true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return userContext{Name: user, Groups: groups}, false
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:03:14 -03:00
|
|
|
func firstNonEmptyHeader(r *http.Request, keys ...string) string {
|
|
|
|
|
for _, key := range keys {
|
|
|
|
|
if value := strings.TrimSpace(r.Header.Get(key)); value != "" {
|
|
|
|
|
return value
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 14:52:50 -03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:03:14 -03:00
|
|
|
func normalizeGroupValue(raw string) string {
|
|
|
|
|
value := strings.ToLower(strings.TrimSpace(raw))
|
|
|
|
|
return strings.TrimPrefix(value, "/")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 20:53:59 -03:00
|
|
|
func requestValues(r *http.Request) map[string]string {
|
|
|
|
|
values := map[string]string{}
|
2026-03-31 14:52:50 -03:00
|
|
|
if err := r.ParseForm(); err == nil {
|
2026-03-31 20:53:59 -03:00
|
|
|
for key, rawValues := range r.Form {
|
|
|
|
|
for _, raw := range rawValues {
|
|
|
|
|
if value := strings.TrimSpace(raw); value != "" {
|
|
|
|
|
values[key] = value
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
var payload map[string]any
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err == nil {
|
2026-03-31 20:53:59 -03:00
|
|
|
for key, raw := range payload {
|
|
|
|
|
if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
|
|
|
|
|
values[key] = strings.TrimSpace(value)
|
|
|
|
|
}
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-31 20:53:59 -03:00
|
|
|
return values
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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{
|
2026-03-31 18:46:13 -03:00
|
|
|
--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);
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
*{box-sizing:border-box}
|
|
|
|
|
body{
|
|
|
|
|
margin:0;
|
|
|
|
|
min-height:100vh;
|
|
|
|
|
font-family:"Avenir Next","Trebuchet MS","Segoe UI",sans-serif;
|
|
|
|
|
color:var(--ink);
|
|
|
|
|
background:
|
2026-03-31 18:46:13 -03:00
|
|
|
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%);
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
.frame{
|
2026-03-31 18:46:13 -03:00
|
|
|
max-width:1320px;
|
2026-03-31 14:52:50 -03:00
|
|
|
margin:0 auto;
|
|
|
|
|
padding:2rem 1.25rem 3rem;
|
|
|
|
|
}
|
|
|
|
|
.mast{
|
|
|
|
|
display:flex;
|
|
|
|
|
justify-content:space-between;
|
|
|
|
|
align-items:flex-end;
|
|
|
|
|
gap:1.5rem;
|
2026-03-31 18:46:13 -03:00
|
|
|
margin-bottom:1rem;
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
.eyebrow{
|
|
|
|
|
letter-spacing:.14em;
|
|
|
|
|
text-transform:uppercase;
|
|
|
|
|
font-size:.72rem;
|
2026-03-31 18:46:13 -03:00
|
|
|
color:#81c6ff;
|
2026-03-31 14:52:50 -03:00
|
|
|
margin-bottom:.35rem;
|
2026-03-31 18:46:13 -03:00
|
|
|
font-weight:800;
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
h1{
|
|
|
|
|
margin:0;
|
|
|
|
|
font-size:clamp(2rem,4vw,3.4rem);
|
|
|
|
|
line-height:1;
|
|
|
|
|
}
|
|
|
|
|
.sub{
|
2026-03-31 18:46:13 -03:00
|
|
|
max-width:56rem;
|
2026-03-31 14:52:50 -03:00
|
|
|
color:var(--muted);
|
|
|
|
|
margin-top:.7rem;
|
|
|
|
|
font-size:1rem;
|
|
|
|
|
}
|
|
|
|
|
.badge{
|
|
|
|
|
display:inline-flex;
|
|
|
|
|
align-items:center;
|
|
|
|
|
gap:.45rem;
|
2026-03-31 18:46:13 -03:00
|
|
|
padding:.78rem 1rem;
|
|
|
|
|
background:rgba(16,28,41,.82);
|
|
|
|
|
border:1px solid var(--line);
|
2026-03-31 14:52:50 -03:00
|
|
|
border-radius:999px;
|
|
|
|
|
box-shadow:var(--shadow);
|
2026-03-31 18:46:13 -03:00
|
|
|
font-size:.92rem;
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
2026-03-31 18:46:13 -03:00
|
|
|
.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);
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
2026-03-31 18:46:13 -03:00
|
|
|
.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{
|
2026-03-31 14:52:50 -03:00
|
|
|
display:grid;
|
2026-03-31 18:46:13 -03:00
|
|
|
grid-template-columns:1.16fr .9fr;
|
2026-03-31 14:52:50 -03:00
|
|
|
gap:1rem;
|
|
|
|
|
}
|
2026-03-31 18:46:13 -03:00
|
|
|
.stack{display:grid;gap:1rem}
|
2026-03-31 14:52:50 -03:00
|
|
|
.card{
|
2026-03-31 18:46:13 -03:00
|
|
|
background:linear-gradient(180deg, rgba(16,28,41,.95), rgba(12,21,31,.94));
|
2026-03-31 14:52:50 -03:00
|
|
|
border:1px solid var(--line);
|
2026-03-31 18:46:13 -03:00
|
|
|
border-radius:1.35rem;
|
2026-03-31 14:52:50 -03:00
|
|
|
padding:1.1rem;
|
|
|
|
|
box-shadow:var(--shadow);
|
|
|
|
|
}
|
|
|
|
|
.card h2{
|
|
|
|
|
margin:0 0 .35rem;
|
|
|
|
|
font-size:1rem;
|
|
|
|
|
text-transform:uppercase;
|
|
|
|
|
letter-spacing:.1em;
|
2026-03-31 18:46:13 -03:00
|
|
|
color:#8bccff;
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
.hint{
|
|
|
|
|
color:var(--muted);
|
|
|
|
|
font-size:.92rem;
|
|
|
|
|
margin-bottom:1rem;
|
|
|
|
|
}
|
2026-03-31 18:46:13 -03:00
|
|
|
.microcopy{
|
|
|
|
|
color:var(--muted);
|
|
|
|
|
font-size:.84rem;
|
|
|
|
|
margin-top:.5rem;
|
|
|
|
|
min-height:1.2rem;
|
|
|
|
|
}
|
2026-03-31 14:52:50 -03:00
|
|
|
.form-grid{
|
|
|
|
|
display:grid;
|
|
|
|
|
grid-template-columns:repeat(2,minmax(0,1fr));
|
|
|
|
|
gap:.85rem;
|
|
|
|
|
}
|
|
|
|
|
label{
|
|
|
|
|
display:grid;
|
|
|
|
|
gap:.35rem;
|
2026-03-31 18:46:13 -03:00
|
|
|
font-weight:700;
|
2026-03-31 14:52:50 -03:00
|
|
|
font-size:.92rem;
|
|
|
|
|
}
|
|
|
|
|
select, button{
|
|
|
|
|
width:100%;
|
2026-03-31 18:46:13 -03:00
|
|
|
border-radius:.95rem;
|
|
|
|
|
border:1px solid var(--line-strong);
|
|
|
|
|
padding:.9rem .95rem;
|
2026-03-31 14:52:50 -03:00
|
|
|
font:inherit;
|
|
|
|
|
}
|
2026-03-31 18:46:13 -03:00
|
|
|
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;
|
|
|
|
|
}
|
2026-03-31 14:52:50 -03:00
|
|
|
button{
|
|
|
|
|
cursor:pointer;
|
|
|
|
|
background:linear-gradient(135deg,var(--brand) 0%,var(--brand-deep) 100%);
|
|
|
|
|
color:#fff;
|
|
|
|
|
border:none;
|
2026-03-31 18:46:13 -03:00
|
|
|
font-weight:800;
|
2026-03-31 14:52:50 -03:00
|
|
|
letter-spacing:.03em;
|
2026-03-31 18:46:13 -03:00
|
|
|
box-shadow:0 14px 30px rgba(17,66,102,.30);
|
|
|
|
|
transition:transform .12s ease, opacity .12s ease;
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
2026-03-31 18:46:13 -03:00
|
|
|
button:hover{transform:translateY(-1px)}
|
2026-03-31 14:52:50 -03:00
|
|
|
button.secondary{
|
2026-03-31 18:46:13 -03:00
|
|
|
background:rgba(18,30,43,.96);
|
2026-03-31 14:52:50 -03:00
|
|
|
color:var(--ink);
|
2026-03-31 18:46:13 -03:00
|
|
|
border:1px solid var(--line-strong);
|
2026-03-31 14:52:50 -03:00
|
|
|
box-shadow:none;
|
|
|
|
|
}
|
2026-03-31 18:46:13 -03:00
|
|
|
button:disabled{
|
|
|
|
|
opacity:.55;
|
|
|
|
|
cursor:not-allowed;
|
|
|
|
|
transform:none;
|
|
|
|
|
}
|
2026-03-31 14:52:50 -03:00
|
|
|
.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{
|
2026-03-31 18:46:13 -03:00
|
|
|
border:1px solid rgba(149,177,205,.14);
|
2026-03-31 14:52:50 -03:00
|
|
|
border-radius:1rem;
|
|
|
|
|
padding:.85rem .95rem;
|
2026-03-31 18:46:13 -03:00
|
|
|
background:rgba(8,17,27,.78);
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
.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;
|
2026-03-31 18:46:13 -03:00
|
|
|
background:rgba(149,177,205,.12);
|
2026-03-31 14:52:50 -03:00
|
|
|
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;
|
2026-03-31 18:46:13 -03:00
|
|
|
background:rgba(61,167,255,.12);
|
|
|
|
|
color:#9bd1ff;
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
2026-03-31 18:46:13 -03:00
|
|
|
.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)}
|
2026-03-31 14:52:50 -03:00
|
|
|
.mini{
|
|
|
|
|
display:grid;
|
|
|
|
|
grid-template-columns:repeat(2,minmax(0,1fr));
|
|
|
|
|
gap:.7rem;
|
|
|
|
|
}
|
|
|
|
|
.stat{
|
|
|
|
|
padding:.8rem .9rem;
|
|
|
|
|
border-radius:1rem;
|
2026-03-31 18:46:13 -03:00
|
|
|
background:rgba(8,17,27,.72);
|
|
|
|
|
border:1px solid rgba(149,177,205,.12);
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
.stat strong{display:block;font-size:1.35rem}
|
2026-03-31 18:46:13 -03:00
|
|
|
.row{
|
|
|
|
|
display:flex;
|
|
|
|
|
justify-content:space-between;
|
|
|
|
|
gap:1rem;
|
|
|
|
|
align-items:center;
|
|
|
|
|
}
|
2026-03-31 14:52:50 -03:00
|
|
|
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}
|
2026-03-31 18:46:13 -03:00
|
|
|
.row{align-items:flex-start;flex-direction:column}
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
</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>
|
|
|
|
|
|
2026-03-31 18:46:13 -03:00
|
|
|
<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>
|
|
|
|
|
|
2026-03-31 14:52:50 -03:00
|
|
|
<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>
|
2026-03-31 19:21:26 -03:00
|
|
|
<div class="microcopy" id="target-note"></div>
|
2026-03-31 18:46:13 -03:00
|
|
|
<div class="microcopy" id="host-note"></div>
|
|
|
|
|
<div class="microcopy" id="device-note"></div>
|
2026-03-31 19:00:48 -03:00
|
|
|
<div class="microcopy" id="artifact-note"></div>
|
2026-03-31 14:52:50 -03:00
|
|
|
<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">
|
2026-03-31 18:46:13 -03:00
|
|
|
<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>
|
2026-03-31 14:52:50 -03:00
|
|
|
<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;
|
2026-03-31 18:46:13 -03:00
|
|
|
let busy = false;
|
2026-04-01 01:45:44 -03:00
|
|
|
let lastJobAlert = '';
|
2026-03-31 18:46:13 -03:00
|
|
|
|
2026-03-31 14:52:50 -03:00
|
|
|
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');
|
2026-03-31 19:21:26 -03:00
|
|
|
const targetNoteEl = document.getElementById('target-note');
|
2026-03-31 18:46:13 -03:00
|
|
|
const hostNoteEl = document.getElementById('host-note');
|
|
|
|
|
const deviceNoteEl = document.getElementById('device-note');
|
2026-03-31 19:00:48 -03:00
|
|
|
const artifactNoteEl = document.getElementById('artifact-note');
|
2026-03-31 18:46:13 -03:00
|
|
|
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'));
|
2026-03-31 14:52:50 -03:00
|
|
|
|
|
|
|
|
function fmtTime(value){
|
|
|
|
|
if(!value){ return 'pending'; }
|
|
|
|
|
const date = new Date(value);
|
|
|
|
|
return isNaN(date.getTime()) ? value : date.toLocaleString();
|
|
|
|
|
}
|
2026-03-31 18:46:13 -03:00
|
|
|
|
|
|
|
|
function fmtBytes(value){
|
|
|
|
|
if(!value){ return '0 B'; }
|
|
|
|
|
const units = ['B','KiB','MiB','GiB','TiB'];
|
|
|
|
|
let size = Number(value);
|
2026-03-31 14:52:50 -03:00
|
|
|
let idx = 0;
|
2026-03-31 18:46:13 -03:00
|
|
|
while(size >= 1024 && idx < units.length - 1){
|
|
|
|
|
size /= 1024;
|
|
|
|
|
idx += 1;
|
|
|
|
|
}
|
|
|
|
|
return size.toFixed(size >= 10 || idx === 0 ? 0 : 1) + ' ' + units[idx];
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 01:45:44 -03:00
|
|
|
function fmtDuration(startValue, endValue){
|
|
|
|
|
if(!startValue){ return ''; }
|
|
|
|
|
const start = new Date(startValue);
|
|
|
|
|
if(isNaN(start.getTime())){ return ''; }
|
|
|
|
|
const end = endValue ? new Date(endValue) : new Date();
|
|
|
|
|
if(isNaN(end.getTime())){ return ''; }
|
|
|
|
|
let seconds = Math.max(0, Math.round((end.getTime() - start.getTime()) / 1000));
|
|
|
|
|
const hours = Math.floor(seconds / 3600);
|
|
|
|
|
seconds -= hours * 3600;
|
|
|
|
|
const minutes = Math.floor(seconds / 60);
|
|
|
|
|
seconds -= minutes * 60;
|
|
|
|
|
if(hours){ return hours + 'h ' + minutes + 'm'; }
|
|
|
|
|
if(minutes){ return minutes + 'm ' + seconds + 's'; }
|
|
|
|
|
return seconds + 's';
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:46:13 -03:00
|
|
|
function banner(kind, title, text){
|
|
|
|
|
bannerEl.className = 'banner ' + kind;
|
|
|
|
|
bannerTitleEl.textContent = title;
|
|
|
|
|
bannerTextEl.textContent = text;
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
2026-03-31 18:46:13 -03:00
|
|
|
|
|
|
|
|
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){
|
2026-03-31 14:52:50 -03:00
|
|
|
const current = select.value;
|
|
|
|
|
select.innerHTML = '';
|
2026-03-31 18:46:13 -03:00
|
|
|
if(!values.length){
|
|
|
|
|
const option = document.createElement('option');
|
|
|
|
|
option.value = '';
|
|
|
|
|
option.textContent = emptyLabel;
|
|
|
|
|
select.appendChild(option);
|
|
|
|
|
select.value = '';
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-31 14:52:50 -03:00
|
|
|
values.forEach((value)=>{
|
|
|
|
|
const option = document.createElement('option');
|
|
|
|
|
option.value = value;
|
|
|
|
|
option.textContent = labeler ? labeler(value) : value;
|
|
|
|
|
select.appendChild(option);
|
|
|
|
|
});
|
2026-03-31 18:46:13 -03:00
|
|
|
if(current && values.includes(current)){
|
|
|
|
|
select.value = current;
|
|
|
|
|
}
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:46:13 -03:00
|
|
|
function renderJobs(){
|
2026-03-31 14:52:50 -03:00
|
|
|
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()}];
|
2026-03-31 18:46:13 -03:00
|
|
|
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 : '');
|
2026-04-01 01:45:44 -03:00
|
|
|
const timingBits = [];
|
|
|
|
|
if(job.stage){ timingBits.push('stage: ' + job.stage); }
|
2026-04-01 02:07:09 -03:00
|
|
|
const stageDuration = fmtDuration(job.stage_started_at || job.started_at, job.finished_at);
|
|
|
|
|
if(stageDuration){
|
|
|
|
|
timingBits.push((job.status === 'running' ? 'stage elapsed ' : 'stage duration ') + stageDuration);
|
|
|
|
|
}
|
|
|
|
|
const totalDuration = fmtDuration(job.started_at, job.finished_at);
|
|
|
|
|
if(totalDuration && job.stage_started_at){
|
|
|
|
|
timingBits.push((job.status === 'running' ? 'total elapsed ' : 'total duration ') + totalDuration);
|
2026-04-01 01:45:44 -03:00
|
|
|
}
|
|
|
|
|
if(job.updated_at && job.status === 'running'){
|
|
|
|
|
timingBits.push('last update ' + fmtDuration(job.updated_at, new Date().toISOString()) + ' ago');
|
|
|
|
|
}
|
2026-03-31 18:46:13 -03:00
|
|
|
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>' +
|
2026-04-01 01:45:44 -03:00
|
|
|
'<div class="meta">' + timingBits.join(' · ') + '</div>' +
|
2026-03-31 18:46:13 -03:00
|
|
|
'<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);
|
|
|
|
|
});
|
2026-04-01 01:45:44 -03:00
|
|
|
|
|
|
|
|
const newestError = jobs.find((job)=>job.status === 'error');
|
|
|
|
|
if(newestError){
|
|
|
|
|
const signature = [newestError.id, newestError.error || newestError.message || newestError.stage || 'error'].join(':');
|
|
|
|
|
if(signature !== lastJobAlert){
|
|
|
|
|
lastJobAlert = signature;
|
|
|
|
|
banner('error', 'Metis job failed', newestError.error || newestError.message || 'Check the live jobs panel for details.');
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-31 18:46:13 -03:00
|
|
|
}
|
2026-03-31 14:52:50 -03:00
|
|
|
|
2026-03-31 18:46:13 -03:00
|
|
|
function renderEvents(){
|
2026-03-31 14:52:50 -03:00
|
|
|
eventsEl.innerHTML = '';
|
2026-03-31 18:46:13 -03:00
|
|
|
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];
|
|
|
|
|
}
|
2026-03-31 19:21:26 -03:00
|
|
|
const trackedNodes = Math.max((state.snapshots || []).length, (state.flash_hosts || []).length);
|
|
|
|
|
targetNoteEl.textContent = nodeNames.length
|
|
|
|
|
? 'Only nodes with full replacement definitions appear here. Current replacement coverage: ' + nodeNames.length + ' node(s)' + (trackedNodes ? ' across ' + trackedNodes + ' tracked cluster node(s).' : '.')
|
|
|
|
|
: 'No inventory-backed replacement nodes are loaded yet.';
|
2026-03-31 18:46:13 -03:00
|
|
|
|
|
|
|
|
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;
|
2026-03-31 20:42:35 -03:00
|
|
|
hostNoteEl.textContent = 'Metis will inspect media and run the flash writer on ' + selectedHost + ' through a short-lived in-cluster worker. ' + state.default_flash_host + ' remains the default flash host.';
|
2026-03-31 18:46:13 -03:00
|
|
|
|
|
|
|
|
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.';
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 19:00:48 -03:00
|
|
|
const artifact = (state.artifacts || {})[nodeSelect.value];
|
2026-03-31 20:42:35 -03:00
|
|
|
artifactNoteEl.textContent = artifact && artifact.ref
|
|
|
|
|
? 'Latest published image: ' + artifact.ref + ' (Metis keeps the newest 3 builds in Harbor).'
|
|
|
|
|
: 'Successful build-only runs publish <node>:latest into Harbor and keep the newest 3 builds per node.';
|
2026-03-31 19:00:48 -03:00
|
|
|
|
2026-03-31 18:46:13 -03:00
|
|
|
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();
|
2026-03-31 14:52:50 -03:00
|
|
|
snapshotCountEl.textContent = state.snapshots.length;
|
|
|
|
|
targetCountEl.textContent = Object.keys(state.targets || {}).length;
|
|
|
|
|
}
|
2026-03-31 18:46:13 -03:00
|
|
|
|
|
|
|
|
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);
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-31 18:46:13 -03:00
|
|
|
|
2026-03-31 14:52:50 -03:00
|
|
|
async function post(path, body){
|
|
|
|
|
const resp = await fetch(path, {
|
|
|
|
|
method:'POST',
|
|
|
|
|
headers:{'Content-Type':'application/json'},
|
|
|
|
|
body: JSON.stringify(body)
|
|
|
|
|
});
|
2026-03-31 18:46:13 -03:00
|
|
|
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();
|
|
|
|
|
}
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.getElementById('refresh-devices').addEventListener('click', async ()=>{
|
2026-03-31 18:46:13 -03:00
|
|
|
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.');
|
|
|
|
|
});
|
2026-03-31 14:52:50 -03:00
|
|
|
});
|
2026-03-31 18:46:13 -03:00
|
|
|
|
2026-03-31 14:52:50 -03:00
|
|
|
document.getElementById('build-only').addEventListener('click', async ()=>{
|
2026-03-31 18:46:13 -03:00
|
|
|
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});
|
2026-03-31 20:42:35 -03:00
|
|
|
banner('success', 'Image build queued', 'Metis started building the replacement image for ' + nodeSelect.value + '. Successful build-only runs publish ' + nodeSelect.value + ':latest to Harbor and keep the newest 3 builds.');
|
2026-03-31 18:46:13 -03:00
|
|
|
});
|
2026-03-31 14:52:50 -03:00
|
|
|
});
|
2026-03-31 18:46:13 -03:00
|
|
|
|
2026-03-31 14:52:50 -03:00
|
|
|
document.getElementById('replace-run').addEventListener('click', async ()=>{
|
2026-03-31 18:46:13 -03:00
|
|
|
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 + '.');
|
|
|
|
|
});
|
2026-03-31 14:52:50 -03:00
|
|
|
});
|
2026-03-31 18:46:13 -03:00
|
|
|
|
2026-03-31 14:52:50 -03:00
|
|
|
document.getElementById('sentinel-watch').addEventListener('click', async ()=>{
|
2026-03-31 18:46:13 -03:00
|
|
|
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) + '.');
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-31 14:52:50 -03:00
|
|
|
});
|
2026-03-31 19:00:48 -03:00
|
|
|
nodeSelect.addEventListener('change', render);
|
2026-03-31 18:46:13 -03:00
|
|
|
|
2026-03-31 14:52:50 -03:00
|
|
|
render();
|
2026-03-31 18:46:13 -03:00
|
|
|
clearBanner();
|
|
|
|
|
setInterval(async ()=>{
|
|
|
|
|
try {
|
|
|
|
|
await refreshState({silent:true});
|
|
|
|
|
} catch (_error) {
|
|
|
|
|
// Keep the live dashboard calm during background polling.
|
|
|
|
|
}
|
|
|
|
|
}, 5000);
|
2026-03-31 14:52:50 -03:00
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>`))
|