service: decouple state polling from media scans

This commit is contained in:
Brad Stein 2026-04-01 13:13:09 -03:00
parent 36069790ad
commit cd41710247
4 changed files with 107 additions and 13 deletions

View File

@ -107,6 +107,12 @@ type ArtifactSummary struct {
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
} }
type deviceSnapshot struct {
Devices []Device
Err string
CheckedAt time.Time
}
// App coordinates builds, flashes, sentinel snapshots, and the web UI state. // App coordinates builds, flashes, sentinel snapshots, and the web UI state.
type App struct { type App struct {
settings Settings settings Settings
@ -118,6 +124,7 @@ type App struct {
snapshots map[string]SnapshotRecord snapshots map[string]SnapshotRecord
targets map[string]facts.Targets targets map[string]facts.Targets
artifactStore map[string]ArtifactSummary artifactStore map[string]ArtifactSummary
deviceStore map[string]deviceSnapshot
} }
// NewApp creates a Metis service app instance. // NewApp creates a Metis service app instance.
@ -143,6 +150,7 @@ func NewApp(settings Settings) (*App, error) {
snapshots: map[string]SnapshotRecord{}, snapshots: map[string]SnapshotRecord{},
targets: map[string]facts.Targets{}, targets: map[string]facts.Targets{},
artifactStore: map[string]ArtifactSummary{}, artifactStore: map[string]ArtifactSummary{},
deviceStore: map[string]deviceSnapshot{},
} }
_ = app.loadSnapshots() _ = app.loadSnapshots()
_ = app.loadTargets() _ = app.loadTargets()
@ -180,7 +188,7 @@ func (a *App) State(deviceHost string) PageState {
}) })
flashHosts := a.flashHosts() flashHosts := a.flashHosts()
devices, deviceErr := a.ListDevices(deviceHost) devices, deviceErr := a.cachedDevices(deviceHost)
preferredDevice := preferredDevice(devices) preferredDevice := preferredDevice(devices)
return PageState{ return PageState{
LocalHost: a.settings.LocalHost, LocalHost: a.settings.LocalHost,
@ -614,6 +622,52 @@ func errorString(err error) string {
return err.Error() return err.Error()
} }
func cloneDevices(devices []Device) []Device {
if len(devices) == 0 {
return nil
}
out := make([]Device, len(devices))
copy(out, devices)
return out
}
func (a *App) cachedDevices(host string) ([]Device, error) {
host = strings.TrimSpace(host)
if host == "" {
host = a.settings.DefaultFlashHost
}
a.mu.RLock()
snapshot, ok := a.deviceStore[host]
a.mu.RUnlock()
if !ok {
return nil, nil
}
if strings.TrimSpace(snapshot.Err) != "" {
return cloneDevices(snapshot.Devices), errors.New(snapshot.Err)
}
return cloneDevices(snapshot.Devices), nil
}
func (a *App) recordDevices(host string, devices []Device, err error) {
host = strings.TrimSpace(host)
if host == "" {
host = a.settings.DefaultFlashHost
}
snapshot := deviceSnapshot{
Devices: cloneDevices(devices),
CheckedAt: time.Now().UTC(),
}
if err != nil {
snapshot.Err = err.Error()
}
a.mu.Lock()
if existing, ok := a.deviceStore[host]; ok && len(snapshot.Devices) == 0 {
snapshot.Devices = cloneDevices(existing.Devices)
}
a.deviceStore[host] = snapshot
a.mu.Unlock()
}
func deviceScore(device Device) int { func deviceScore(device Device) int {
score := 0 score := 0
model := strings.ToLower(strings.TrimSpace(device.Model)) model := strings.ToLower(strings.TrimSpace(device.Model))

View File

@ -270,9 +270,9 @@ func (a *App) remotePodState(kube *kubeClient, podName string) (podState, error)
} }
if strings.TrimSpace(terminated.Reason) != "" { if strings.TrimSpace(terminated.Reason) != "" {
out.Reason = terminated.Reason out.Reason = terminated.Reason
if strings.TrimSpace(terminated.Message) != "" { }
out.Message = terminated.Message if strings.TrimSpace(terminated.Message) != "" {
} out.Message = terminated.Message
} }
} }
return out, nil return out, nil

View File

@ -15,6 +15,10 @@ import (
const hostTmpDevicePath = "hosttmp:///tmp" const hostTmpDevicePath = "hosttmp:///tmp"
func (a *App) ListDevices(host string) ([]Device, error) { func (a *App) ListDevices(host string) ([]Device, error) {
return a.cachedDevices(host)
}
func (a *App) RefreshDevices(host string) ([]Device, error) {
if host == "" { if host == "" {
host = a.settings.DefaultFlashHost host = a.settings.DefaultFlashHost
} }
@ -24,22 +28,29 @@ func (a *App) ListDevices(host string) ([]Device, error) {
} }
target, ok := nodeMap[host] target, ok := nodeMap[host]
if !ok { if !ok {
return nil, fmt.Errorf("flash host %s is not a current cluster node", host) err := fmt.Errorf("flash host %s is not a current cluster node", host)
a.recordDevices(host, nil, err)
return nil, err
} }
image := a.podImageForArch(target.Arch) image := a.podImageForArch(target.Arch)
if image == "" { if image == "" {
return nil, fmt.Errorf("no runner image configured for arch %s", target.Arch) err := fmt.Errorf("no runner image configured for arch %s", target.Arch)
a.recordDevices(host, nil, err)
return nil, err
} }
podName := fmt.Sprintf("metis-devices-%d", time.Now().UTC().UnixNano()) podName := fmt.Sprintf("metis-devices-%d", time.Now().UTC().UnixNano())
logs, err := a.runRemotePod("", podName, a.remoteDevicePodSpec(podName, host, image)) logs, err := a.runRemotePod("", podName, a.remoteDevicePodSpec(podName, host, image))
if err != nil { if err != nil {
a.recordDevices(host, nil, err)
return nil, err return nil, err
} }
var payload struct { var payload struct {
Devices []Device `json:"devices"` Devices []Device `json:"devices"`
} }
if err := json.Unmarshal([]byte(strings.TrimSpace(logs)), &payload); err != nil { if err := json.Unmarshal([]byte(strings.TrimSpace(logs)), &payload); err != nil {
return nil, fmt.Errorf("decode remote devices: %w: %s", err, strings.TrimSpace(logs)) decodeErr := fmt.Errorf("decode remote devices: %w: %s", err, strings.TrimSpace(logs))
a.recordDevices(host, nil, decodeErr)
return nil, decodeErr
} }
sort.Slice(payload.Devices, func(i, j int) bool { sort.Slice(payload.Devices, func(i, j int) bool {
left := deviceScore(payload.Devices[i]) left := deviceScore(payload.Devices[i])
@ -52,6 +63,7 @@ func (a *App) ListDevices(host string) ([]Device, error) {
} }
return payload.Devices[i].Path < payload.Devices[j].Path return payload.Devices[i].Path < payload.Devices[j].Path
}) })
a.recordDevices(host, payload.Devices, nil)
return payload.Devices, nil return payload.Devices, nil
} }
@ -319,7 +331,7 @@ func (a *App) ensureDevice(host, path string) (*Device, error) {
if strings.TrimSpace(path) == "" { if strings.TrimSpace(path) == "" {
return nil, fmt.Errorf("select removable media before starting a flash run") return nil, fmt.Errorf("select removable media before starting a flash run")
} }
devices, err := a.ListDevices(host) devices, err := a.RefreshDevices(host)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -82,7 +82,7 @@ func (a *App) handleState(w http.ResponseWriter, r *http.Request) {
func (a *App) handleDevices(w http.ResponseWriter, r *http.Request) { func (a *App) handleDevices(w http.ResponseWriter, r *http.Request) {
host := r.URL.Query().Get("host") host := r.URL.Query().Get("host")
devices, err := a.ListDevices(host) devices, err := a.RefreshDevices(host)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@ -815,6 +815,20 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
} }
} }
async function refreshDevices(){
const host = hostSelect.value || state.default_flash_host;
const resp = await fetch('/api/devices?host=' + encodeURIComponent(host));
if(!resp.ok){
const text = await resp.text();
throw new Error(text || 'Could not refresh removable media');
}
const payload = await resp.json();
state.devices = payload.devices || [];
state.selected_host = host;
state.device_error = '';
render();
}
async function post(path, body){ async function post(path, body){
const resp = await fetch(path, { const resp = await fetch(path, {
method:'POST', method:'POST',
@ -856,7 +870,8 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
document.getElementById('refresh-devices').addEventListener('click', async ()=>{ document.getElementById('refresh-devices').addEventListener('click', async ()=>{
await runAction('Refreshing media', 'Checking removable devices on the selected flash host.', async ()=>{ await runAction('Refreshing media', 'Checking removable devices on the selected flash host.', async ()=>{
await refreshState(); await refreshDevices();
await refreshState({silent:true});
if(state.device_error){ if(state.device_error){
banner('warn', 'Flash host needs attention', state.device_error); banner('warn', 'Flash host needs attention', state.device_error);
return; return;
@ -904,7 +919,9 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
hostSelect.addEventListener('change', async ()=>{ hostSelect.addEventListener('change', async ()=>{
await runAction('Changing flash host', 'Loading removable media candidates for the selected flash host.', async ()=>{ await runAction('Changing flash host', 'Loading removable media candidates for the selected flash host.', async ()=>{
await refreshState(); await refreshState({silent:true});
await refreshDevices();
await refreshState({silent:true});
if(!state.device_error){ if(!state.device_error){
banner('success', 'Flash host ready', 'Loaded removable media candidates for ' + (hostSelect.value || state.default_flash_host) + '.'); banner('success', 'Flash host ready', 'Loaded removable media candidates for ' + (hostSelect.value || state.default_flash_host) + '.');
} }
@ -914,13 +931,24 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
render(); render();
clearBanner(); clearBanner();
setInterval(async ()=>{ (async ()=>{
try {
await refreshDevices();
} catch (_error) {
// Initial media scan can fail if the selected host is unavailable.
}
})();
async function pollLoop(){
try { try {
await refreshState({silent:true}); await refreshState({silent:true});
} catch (_error) { } catch (_error) {
// Keep the live dashboard calm during background polling. // Keep the live dashboard calm during background polling.
} }
}, 5000); const running = (state.jobs || []).some((job)=>job.status === 'running');
setTimeout(pollLoop, running ? 2000 : 5000);
}
pollLoop();
</script> </script>
</body> </body>
</html>`)) </html>`))