Manager: implement worker status change requests

Implement the OpenAPI `RequestWorkerStatusChange` operation, and handle
these changes in the web interface.
This commit is contained in:
Sybren A. Stüvel 2022-05-31 17:22:03 +02:00
parent fdb0b82664
commit f97f0a34c3
9 changed files with 158 additions and 34 deletions

View File

@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"git.blender.org/flamenco/internal/manager/persistence" "git.blender.org/flamenco/internal/manager/persistence"
"git.blender.org/flamenco/internal/manager/webupdates"
"git.blender.org/flamenco/internal/uuid" "git.blender.org/flamenco/internal/uuid"
"git.blender.org/flamenco/pkg/api" "git.blender.org/flamenco/pkg/api"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
@ -54,6 +55,54 @@ func (f *Flamenco) FetchWorker(e echo.Context, workerUUID string) error {
return e.JSON(http.StatusOK, apiWorker) return e.JSON(http.StatusOK, apiWorker)
} }
func (f *Flamenco) RequestWorkerStatusChange(e echo.Context, workerUUID string) error {
logger := requestLogger(e)
logger = logger.With().Str("worker", workerUUID).Logger()
if !uuid.IsValid(workerUUID) {
return sendAPIError(e, http.StatusBadRequest, "not a valid UUID")
}
// Decode the request body.
var change api.WorkerStatusChangeRequest
if err := e.Bind(&change); err != nil {
logger.Warn().Err(err).Msg("bad request received")
return sendAPIError(e, http.StatusBadRequest, "invalid format")
}
// Fetch the worker.
dbWorker, err := f.persist.FetchWorker(e.Request().Context(), workerUUID)
if errors.Is(err, persistence.ErrWorkerNotFound) {
logger.Debug().Msg("non-existent worker requested")
return sendAPIError(e, http.StatusNotFound, "worker %q not found", workerUUID)
}
if err != nil {
logger.Error().Err(err).Msg("error fetching worker")
return sendAPIError(e, http.StatusInternalServerError, "error fetching worker: %v", err)
}
// Store the status change.
logger = logger.With().
Str("status", string(dbWorker.Status)).
Str("requested", string(change.StatusRequested)).
Bool("lazy", change.IsLazy).
Logger()
logger.Info().Msg("worker status change requested")
dbWorker.StatusRequested = change.StatusRequested
dbWorker.LazyStatusRequest = change.IsLazy
if err := f.persist.SaveWorker(e.Request().Context(), dbWorker); err != nil {
logger.Error().Err(err).Msg("error saving worker after status change request")
return sendAPIError(e, http.StatusInternalServerError, "error saving worker: %v", err)
}
// Broadcast the change.
update := webupdates.NewWorkerUpdate(dbWorker)
f.broadcaster.BroadcastWorkerUpdate(update)
return e.NoContent(http.StatusNoContent)
}
func workerSummary(w persistence.Worker) api.WorkerSummary { func workerSummary(w persistence.Worker) api.WorkerSummary {
summary := api.WorkerSummary{ summary := api.WorkerSummary{
Id: w.UUID, Id: w.UUID,
@ -63,6 +112,7 @@ func workerSummary(w persistence.Worker) api.WorkerSummary {
} }
if w.StatusRequested != "" { if w.StatusRequested != "" {
summary.StatusRequested = &w.StatusRequested summary.StatusRequested = &w.StatusRequested
summary.LazyStatusRequest = &w.LazyStatusRequest
} }
return summary return summary
} }
@ -82,6 +132,7 @@ func workerDBtoAPI(dbWorker *persistence.Worker) api.Worker {
if dbWorker.StatusRequested != "" { if dbWorker.StatusRequested != "" {
apiWorker.StatusRequested = &dbWorker.StatusRequested apiWorker.StatusRequested = &dbWorker.StatusRequested
apiWorker.LazyStatusRequest = &dbWorker.LazyStatusRequest
} }
return apiWorker return apiWorker

View File

@ -121,3 +121,41 @@ func TestFetchWorker(t *testing.T) {
SupportedTaskTypes: []string{"blender", "ffmpeg", "file-management", "misc"}, SupportedTaskTypes: []string{"blender", "ffmpeg", "file-management", "misc"},
}) })
} }
func TestRequestWorkerStatusChange(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
mf := newMockedFlamenco(mockCtrl)
worker := testWorker()
workerUUID := worker.UUID
prevStatus := worker.Status
mf.persistence.EXPECT().FetchWorker(gomock.Any(), workerUUID).Return(&worker, nil)
requestStatus := api.WorkerStatusAsleep
savedWorker := worker
savedWorker.StatusRequested = requestStatus
savedWorker.LazyStatusRequest = true
mf.persistence.EXPECT().SaveWorker(gomock.Any(), &savedWorker).Return(nil)
// Expect a broadcast of the change
mf.broadcaster.EXPECT().BroadcastWorkerUpdate(api.SocketIOWorkerUpdate{
Id: worker.UUID,
Nickname: worker.Name,
PreviousStatus: &prevStatus,
Status: prevStatus,
StatusRequested: &requestStatus,
LazyStatusRequest: ptr(true),
Updated: worker.UpdatedAt,
Version: worker.Software,
})
echo := mf.prepareMockedJSONRequest(api.WorkerStatusChangeRequest{
StatusRequested: requestStatus,
IsLazy: true,
})
err := mf.flamenco.RequestWorkerStatusChange(echo, workerUUID)
assert.NoError(t, err)
assertResponseEmpty(t, echo)
}

View File

@ -22,7 +22,9 @@ type Worker struct {
Platform string `gorm:"type:varchar(16);default:''"` Platform string `gorm:"type:varchar(16);default:''"`
Software string `gorm:"type:varchar(32);default:''"` Software string `gorm:"type:varchar(32);default:''"`
Status api.WorkerStatus `gorm:"type:varchar(16);default:''"` Status api.WorkerStatus `gorm:"type:varchar(16);default:''"`
StatusRequested api.WorkerStatus `gorm:"type:varchar(16);default:''"` StatusRequested api.WorkerStatus `gorm:"type:varchar(16);default:''"`
LazyStatusRequest bool `gorm:"type:smallint;default:0"`
SupportedTaskTypes string `gorm:"type:varchar(255);default:''"` // comma-separated list of task types. SupportedTaskTypes string `gorm:"type:varchar(255);default:''"` // comma-separated list of task types.
} }

View File

@ -20,6 +20,12 @@ func NewWorkerUpdate(worker *persistence.Worker) api.SocketIOWorkerUpdate {
Version: worker.Software, Version: worker.Software,
Updated: worker.UpdatedAt, Updated: worker.UpdatedAt,
} }
if worker.StatusRequested != "" {
workerUpdate.StatusRequested = &worker.StatusRequested
workerUpdate.LazyStatusRequest = &worker.LazyStatusRequest
}
return workerUpdate return workerUpdate
} }

View File

@ -465,4 +465,8 @@ section.footer-popup .tabulator .tabulator-tableholder .tabulator-placeholder .t
span.state-transition-arrow { span.state-transition-arrow {
font-weight: bold; font-weight: bold;
font-size: 110%; font-size: 110%;
color: var(--color-accent);
}
span.state-transition-arrow.lazy {
color: var(--color-text-muted);
} }

View File

@ -10,11 +10,7 @@
<dd>{{ workerData.nickname }}</dd> <dd>{{ workerData.nickname }}</dd>
<dt class="field-status">Status</dt> <dt class="field-status">Status</dt>
<dd>{{ workerData.status }} <dd v-html="workerStatusHTML"></dd>
<template v-if="workerData.status_requested">
<span class='state-transition-arrow'></span> {{ workerData.status_requested }}
</template>
</dd>
<dt class="field-version">Version</dt> <dt class="field-version">Version</dt>
<dd title="Version of Flamenco">{{ workerData.version }}</dd> <dd title="Version of Flamenco">{{ workerData.version }}</dd>
@ -39,6 +35,7 @@
import * as datetime from "@/datetime"; import * as datetime from "@/datetime";
import { WorkerMgtApi } from '@/manager-api'; import { WorkerMgtApi } from '@/manager-api';
import { apiClient } from '@/stores/api-query-count'; import { apiClient } from '@/stores/api-query-count';
import { workerStatus } from "../../statusindicator";
export default { export default {
props: [ props: [
@ -48,12 +45,19 @@ export default {
return { return {
datetime: datetime, // So that the template can access it. datetime: datetime, // So that the template can access it.
api: new WorkerMgtApi(apiClient), api: new WorkerMgtApi(apiClient),
workerStatusHTML: "",
}; };
}, },
mounted() { mounted() {
// Allow testing from the JS console: // Allow testing from the JS console:
window.workerDetailsVue = this; window.workerDetailsVue = this;
}, },
watch: {
workerData(newData) {
this.workerStatusHTML = workerStatus(newData);
console.log("new worker data; status=", this.workerStatusHTML);
},
},
computed: { computed: {
hasWorkerData() { hasWorkerData() {
return !!this.workerData && !!this.workerData.id; return !!this.workerData && !!this.workerData.id;

View File

@ -14,7 +14,7 @@
<script lang="js"> <script lang="js">
import { TabulatorFull as Tabulator } from 'tabulator-tables'; import { TabulatorFull as Tabulator } from 'tabulator-tables';
import { WorkerMgtApi } from '@/manager-api' import { WorkerMgtApi } from '@/manager-api'
import { indicator } from '@/statusindicator'; import { indicator, workerStatus } from '@/statusindicator';
import { apiClient } from '@/stores/api-query-count'; import { apiClient } from '@/stores/api-query-count';
import StatusFilterBar from '@/components/StatusFilterBar.vue' import StatusFilterBar from '@/components/StatusFilterBar.vue'
@ -46,10 +46,8 @@ export default {
formatter: (cell) => { formatter: (cell) => {
const data = cell.getData(); const data = cell.getData();
const dot = indicator(data.status, 'worker-'); const dot = indicator(data.status, 'worker-');
if (data.status_requested) { const asString = workerStatus(data);
return `${dot} ${data.status} <span class='state-transition-arrow'>➜</span> ${data.status_requested}`; return `${dot} ${asString}`;
}
return `${dot} ${data.status}`;
}, },
}, },
{ title: 'Name', field: 'nickname', sorter: 'string' }, { title: 'Name', field: 'nickname', sorter: 'string' },
@ -108,22 +106,24 @@ export default {
this._refreshAvailableStatuses(); this._refreshAvailableStatuses();
}, },
processWorkerUpdate(workerUpdate) { processWorkerUpdate(workerUpdate) {
// updateData() will only overwrite properties that are actually set on if (!this.tabulator.initialized) return;
// workerUpdate, and leave the rest as-is.
if (this.tabulator.initialized) { // Contrary to tabulator.getRow(), rowManager.findRow() doesn't log a
this.tabulator.updateData([workerUpdate]) // warning when the row cannot be found,
.then(this.sortData); const existingRow = this.tabulator.rowManager.findRow(workerUpdate.id);
let promise;
if (existingRow) {
promise = this.tabulator.updateData([workerUpdate]);
// Tabulator doesn't know we're using 'status_requested' in the 'status'
// column, so it also won't know to redraw when that field changes.
promise.then(() => existingRow.reinitialize(true));
} else {
promise = this.tabulator.addData([workerUpdate]);
} }
this._refreshAvailableStatuses(); promise
}, .then(this.sortData)
processNewWorker(workerUpdate) { .then(this.refreshAvailableStatuses);
if (this.tabulator.initialized) {
this.tabulator.updateData([workerUpdate])
.then(this.sortData);
}
this.tabulator.addData([workerUpdate])
.then(this.sortData);
this._refreshAvailableStatuses();
}, },
onRowClick(event, row) { onRowClick(event, row) {

View File

@ -16,3 +16,25 @@ export function indicator(status, classNamePrefix) {
if (!classNamePrefix) classNamePrefix = ""; // force an empty string for any false value. if (!classNamePrefix) classNamePrefix = ""; // force an empty string for any false value.
return `<span title="${label}" class="indicator ${classNamePrefix}status-${status}"></span>`; return `<span title="${label}" class="indicator ${classNamePrefix}status-${status}"></span>`;
} }
/**
* Construct HTML for showing a worker's status, including any status change
* request.
*
* @param {API.WorkerSummary} workerInfo
* @returns the HTML for the worker status.
*/
export function workerStatus(worker) {
if (!worker.status_requested) {
return `${worker.status}`;
}
let arrow;
if (worker.lazy_status_request) {
arrow = `<span class='state-transition-arrow lazy' title='lazy status transition'>➜</span>`
} else {
arrow = `<span class='state-transition-arrow forced' title='forced status transition'>➠</span>`
}
return `${worker.status} ${arrow} ${worker.status_requested}`;
}

View File

@ -68,10 +68,7 @@ export default {
this.notifs.addWorkerUpdate(workerUpdate); this.notifs.addWorkerUpdate(workerUpdate);
if (this.$refs.workersTable) { if (this.$refs.workersTable) {
if (workerUpdate.previous_status)
this.$refs.workersTable.processWorkerUpdate(workerUpdate); this.$refs.workersTable.processWorkerUpdate(workerUpdate);
else
this.$refs.workersTable.processNewWorker(workerUpdate);
} }
if (this.workerID != workerUpdate.id) if (this.workerID != workerUpdate.id)
return; return;