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"`
|
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))
|
||||||
|
|||||||
@ -270,11 +270,11 @@ 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) != "" {
|
if strings.TrimSpace(terminated.Message) != "" {
|
||||||
out.Message = terminated.Message
|
out.Message = terminated.Message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>`))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user