service: decouple state polling from media scans
This commit is contained in:
parent
36069790ad
commit
cd41710247
@ -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))
|
||||
|
||||
@ -270,11 +270,11 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>`))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user