diff --git a/pkg/service/app.go b/pkg/service/app.go index f7518d2..2acc9d2 100644 --- a/pkg/service/app.go +++ b/pkg/service/app.go @@ -107,6 +107,12 @@ type ArtifactSummary struct { 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. type App struct { settings Settings @@ -118,6 +124,7 @@ type App struct { snapshots map[string]SnapshotRecord targets map[string]facts.Targets artifactStore map[string]ArtifactSummary + deviceStore map[string]deviceSnapshot } // NewApp creates a Metis service app instance. @@ -143,6 +150,7 @@ func NewApp(settings Settings) (*App, error) { snapshots: map[string]SnapshotRecord{}, targets: map[string]facts.Targets{}, artifactStore: map[string]ArtifactSummary{}, + deviceStore: map[string]deviceSnapshot{}, } _ = app.loadSnapshots() _ = app.loadTargets() @@ -180,7 +188,7 @@ func (a *App) State(deviceHost string) PageState { }) flashHosts := a.flashHosts() - devices, deviceErr := a.ListDevices(deviceHost) + devices, deviceErr := a.cachedDevices(deviceHost) preferredDevice := preferredDevice(devices) return PageState{ LocalHost: a.settings.LocalHost, @@ -614,6 +622,52 @@ func errorString(err error) string { 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 { score := 0 model := strings.ToLower(strings.TrimSpace(device.Model)) diff --git a/pkg/service/cluster.go b/pkg/service/cluster.go index a99c03e..a3156fa 100644 --- a/pkg/service/cluster.go +++ b/pkg/service/cluster.go @@ -270,9 +270,9 @@ func (a *App) remotePodState(kube *kubeClient, podName string) (podState, error) } if strings.TrimSpace(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 diff --git a/pkg/service/remote.go b/pkg/service/remote.go index d6c08a3..c634573 100644 --- a/pkg/service/remote.go +++ b/pkg/service/remote.go @@ -15,6 +15,10 @@ import ( const hostTmpDevicePath = "hosttmp:///tmp" func (a *App) ListDevices(host string) ([]Device, error) { + return a.cachedDevices(host) +} + +func (a *App) RefreshDevices(host string) ([]Device, error) { if host == "" { host = a.settings.DefaultFlashHost } @@ -24,22 +28,29 @@ func (a *App) ListDevices(host string) ([]Device, error) { } target, ok := nodeMap[host] 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) 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()) logs, err := a.runRemotePod("", podName, a.remoteDevicePodSpec(podName, host, image)) if err != nil { + a.recordDevices(host, nil, err) return nil, err } var payload struct { Devices []Device `json:"devices"` } 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 { 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 }) + a.recordDevices(host, payload.Devices, nil) return payload.Devices, nil } @@ -319,7 +331,7 @@ func (a *App) ensureDevice(host, path string) (*Device, error) { if strings.TrimSpace(path) == "" { 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 { return nil, err } diff --git a/pkg/service/server.go b/pkg/service/server.go index fb13337..d2ff4d3 100644 --- a/pkg/service/server.go +++ b/pkg/service/server.go @@ -82,7 +82,7 @@ func (a *App) handleState(w http.ResponseWriter, r *http.Request) { func (a *App) handleDevices(w http.ResponseWriter, r *http.Request) { host := r.URL.Query().Get("host") - devices, err := a.ListDevices(host) + devices, err := a.RefreshDevices(host) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -815,6 +815,20 @@ var metisPage = template.Must(template.New("metis").Parse(` } } + 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){ const resp = await fetch(path, { method:'POST', @@ -856,7 +870,8 @@ var metisPage = template.Must(template.New("metis").Parse(` document.getElementById('refresh-devices').addEventListener('click', 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){ banner('warn', 'Flash host needs attention', state.device_error); return; @@ -904,7 +919,9 @@ var metisPage = template.Must(template.New("metis").Parse(` hostSelect.addEventListener('change', 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){ 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(` render(); clearBanner(); - setInterval(async ()=>{ + (async ()=>{ + try { + await refreshDevices(); + } catch (_error) { + // Initial media scan can fail if the selected host is unavailable. + } + })(); + + async function pollLoop(){ try { await refreshState({silent:true}); } catch (_error) { // Keep the live dashboard calm during background polling. } - }, 5000); + const running = (state.jobs || []).some((job)=>job.status === 'running'); + setTimeout(pollLoop, running ? 2000 : 5000); + } + pollLoop(); `))