629 lines
20 KiB
Go
629 lines
20 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 := strings.TrimSpace(r.Header.Get("X-Auth-Request-User"))
|
|
if user == "" {
|
|
user = strings.TrimSpace(r.Header.Get("X-Forwarded-User"))
|
|
}
|
|
if user == "" {
|
|
return userContext{}, false
|
|
}
|
|
groups := splitHeaderList(r.Header.Get("X-Auth-Request-Groups"))
|
|
for _, allowedUser := range a.settings.AllowedUsers {
|
|
if allowedUser == user {
|
|
return userContext{Name: user, Groups: groups}, true
|
|
}
|
|
}
|
|
for _, group := range groups {
|
|
for _, allowed := range a.settings.AllowedGroups {
|
|
if group == allowed {
|
|
return userContext{Name: user, Groups: groups}, true
|
|
}
|
|
}
|
|
}
|
|
return userContext{Name: user, Groups: groups}, false
|
|
}
|
|
|
|
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 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{
|
|
--ink:#111318;
|
|
--muted:#616778;
|
|
--line:rgba(17,19,24,.12);
|
|
--paper:rgba(255,255,255,.84);
|
|
--paper-strong:#ffffff;
|
|
--brand:#1d5f8c;
|
|
--brand-deep:#153b59;
|
|
--accent:#d47b37;
|
|
--success:#1b8f5a;
|
|
--danger:#a63c35;
|
|
--shadow:0 20px 60px rgba(17,19,24,.12);
|
|
}
|
|
*{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(212,123,55,.18), transparent 30rem),
|
|
radial-gradient(circle at top right, rgba(29,95,140,.18), transparent 32rem),
|
|
linear-gradient(180deg, #f8f4ee 0%, #eef2f5 48%, #e4edf2 100%);
|
|
}
|
|
.frame{
|
|
max-width:1280px;
|
|
margin:0 auto;
|
|
padding:2rem 1.25rem 3rem;
|
|
}
|
|
.mast{
|
|
display:flex;
|
|
justify-content:space-between;
|
|
align-items:flex-end;
|
|
gap:1.5rem;
|
|
margin-bottom:1.5rem;
|
|
}
|
|
.eyebrow{
|
|
letter-spacing:.14em;
|
|
text-transform:uppercase;
|
|
font-size:.72rem;
|
|
color:var(--brand-deep);
|
|
margin-bottom:.35rem;
|
|
font-weight:700;
|
|
}
|
|
h1{
|
|
margin:0;
|
|
font-size:clamp(2rem,4vw,3.4rem);
|
|
line-height:1;
|
|
}
|
|
.sub{
|
|
max-width:54rem;
|
|
color:var(--muted);
|
|
margin-top:.7rem;
|
|
font-size:1rem;
|
|
}
|
|
.badge{
|
|
display:inline-flex;
|
|
align-items:center;
|
|
gap:.45rem;
|
|
padding:.7rem .95rem;
|
|
background:rgba(255,255,255,.72);
|
|
border:1px solid rgba(21,59,89,.12);
|
|
border-radius:999px;
|
|
box-shadow:var(--shadow);
|
|
font-size:.9rem;
|
|
}
|
|
.grid{
|
|
display:grid;
|
|
grid-template-columns:1.2fr .9fr;
|
|
gap:1rem;
|
|
}
|
|
.stack{
|
|
display:grid;
|
|
gap:1rem;
|
|
}
|
|
.card{
|
|
background:var(--paper);
|
|
backdrop-filter:blur(14px);
|
|
border:1px solid var(--line);
|
|
border-radius:1.25rem;
|
|
padding:1.1rem;
|
|
box-shadow:var(--shadow);
|
|
}
|
|
.card h2{
|
|
margin:0 0 .35rem;
|
|
font-size:1rem;
|
|
text-transform:uppercase;
|
|
letter-spacing:.1em;
|
|
color:var(--brand-deep);
|
|
}
|
|
.hint{
|
|
color:var(--muted);
|
|
font-size:.92rem;
|
|
margin-bottom:1rem;
|
|
}
|
|
.form-grid{
|
|
display:grid;
|
|
grid-template-columns:repeat(2,minmax(0,1fr));
|
|
gap:.85rem;
|
|
}
|
|
label{
|
|
display:grid;
|
|
gap:.35rem;
|
|
font-weight:600;
|
|
font-size:.92rem;
|
|
}
|
|
select, button{
|
|
width:100%;
|
|
border-radius:.85rem;
|
|
border:1px solid rgba(17,19,24,.14);
|
|
padding:.85rem .95rem;
|
|
font:inherit;
|
|
}
|
|
button{
|
|
cursor:pointer;
|
|
background:linear-gradient(135deg,var(--brand) 0%,var(--brand-deep) 100%);
|
|
color:#fff;
|
|
border:none;
|
|
font-weight:700;
|
|
letter-spacing:.03em;
|
|
box-shadow:0 14px 30px rgba(21,59,89,.18);
|
|
}
|
|
button.secondary{
|
|
background:#fff;
|
|
color:var(--ink);
|
|
border:1px solid rgba(17,19,24,.14);
|
|
box-shadow: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(17,19,24,.1);
|
|
border-radius:1rem;
|
|
padding:.85rem .95rem;
|
|
background:rgba(255,255,255,.8);
|
|
}
|
|
.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(17,19,24,.08);
|
|
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(21,59,89,.08);
|
|
color:var(--brand-deep);
|
|
}
|
|
.pill.done{background:rgba(27,143,90,.12);color:var(--success)}
|
|
.pill.error{background:rgba(166,60,53,.12);color:var(--danger)}
|
|
.pill.running{background:rgba(212,123,55,.12);color:#9a5a20}
|
|
.mini{
|
|
display:grid;
|
|
grid-template-columns:repeat(2,minmax(0,1fr));
|
|
gap:.7rem;
|
|
}
|
|
.stat{
|
|
padding:.8rem .9rem;
|
|
border-radius:1rem;
|
|
background:rgba(255,255,255,.72);
|
|
border:1px solid rgba(17,19,24,.08);
|
|
}
|
|
.stat strong{display:block;font-size:1.35rem}
|
|
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}
|
|
}
|
|
</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 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="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">
|
|
<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 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;
|
|
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');
|
|
|
|
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 setOptions(select, values, labeler){
|
|
const current = select.value;
|
|
select.innerHTML = '';
|
|
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 render(){
|
|
setOptions(nodeSelect, state.nodes.map((n)=>n.name));
|
|
setOptions(hostSelect, state.flash_hosts);
|
|
if(!hostSelect.value){ hostSelect.value = state.default_flash_host; }
|
|
setOptions(deviceSelect, state.devices.map((d)=>d.path), (path)=>{
|
|
const dev = state.devices.find((item)=>item.path === path);
|
|
if(!dev){ return path; }
|
|
return dev.path + ' · ' + fmtBytes(dev.size_bytes) + ' · ' + (dev.model || dev.transport || 'removable media');
|
|
});
|
|
|
|
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 progress = job.written_bytes ? (fmtBytes(job.written_bytes) + ' / ' + fmtBytes(job.total_bytes)) : '';
|
|
const detail = progress + (job.artifact ? ' · ' + job.artifact : '') + (job.error ? ' · ' + 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">' + detail + '</div>' +
|
|
'<div class="bar"><span style="width:' + Math.max(0, Math.min(100, job.progress_pct || 0)) + '%"></span></div>';
|
|
jobsEl.appendChild(wrap);
|
|
});
|
|
|
|
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);
|
|
});
|
|
snapshotCountEl.textContent = state.snapshots.length;
|
|
targetCountEl.textContent = Object.keys(state.targets || {}).length;
|
|
}
|
|
async function refreshState(){
|
|
const host = hostSelect.value || state.default_flash_host;
|
|
const resp = await fetch('/api/state?host=' + encodeURIComponent(host));
|
|
if(resp.ok){
|
|
state = await resp.json();
|
|
render();
|
|
}
|
|
}
|
|
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));
|
|
}
|
|
return resp.json();
|
|
}
|
|
|
|
document.getElementById('refresh-devices').addEventListener('click', async ()=>{
|
|
await refreshState();
|
|
});
|
|
document.getElementById('build-only').addEventListener('click', async ()=>{
|
|
await post('/api/jobs/build', {node: nodeSelect.value});
|
|
await refreshState();
|
|
});
|
|
document.getElementById('replace-run').addEventListener('click', async ()=>{
|
|
await post('/api/jobs/replace', {node: nodeSelect.value, host: hostSelect.value, device: deviceSelect.value});
|
|
await refreshState();
|
|
});
|
|
document.getElementById('sentinel-watch').addEventListener('click', async ()=>{
|
|
await post('/api/sentinel/watch', {});
|
|
await refreshState();
|
|
});
|
|
hostSelect.addEventListener('change', refreshState);
|
|
render();
|
|
setInterval(refreshState, 5000);
|
|
</script>
|
|
</body>
|
|
</html>`))
|