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"`
}
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))

View File

@ -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

View File

@ -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
}

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) {
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(`<!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){
const resp = await fetch(path, {
method:'POST',
@ -856,7 +870,8 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
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(`<!doctype html>
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(`<!doctype html>
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();
</script>
</body>
</html>`))