diff --git a/forge/comms/aclManager.js b/forge/comms/aclManager.js index d760fb7db9..51e8349e45 100644 --- a/forge/comms/aclManager.js +++ b/forge/comms/aclManager.js @@ -140,6 +140,29 @@ module.exports = function (app) { return false } }, + checkTeamStateSub: async function (requestParts, usernameParts) { + // requestParts = [ fullTopic , , ] + // usernameParts = [ 'fe-team', , , ] + // team-wide status wildcard: gate on team membership only. requestParts[2] + // is the instance/device id (or '+'), never the user — don't validate it. + const topicTeamHash = requestParts[1] + const usernameUserHash = usernameParts[1] + const usernameTeamHash = usernameParts[2] + if (topicTeamHash !== usernameTeamHash) { + return false + } + try { + const team = await app.db.models.Team.byId(usernameTeamHash) + if (!team) return false + const user = await app.db.models.User.byId(usernameUserHash) + if (!user) return false + const membership = await app.db.models.TeamMember.getTeamMembership(user.id, team.id, false) + return !!membership + } catch (error) { + app.log.error('Unexpected error during team-channel status ACL check', { requestParts, usernameParts, error }) + return false + } + }, checkExpertTopic: async function (topicParts, usernameParts, acl) { // topicParts = [ fullTopic , , , , [, ] ] // usernameParts = [ 'expert-client' | 'expert-agent', [, ] ] @@ -333,6 +356,10 @@ module.exports = function (app) { { topic: /^ff\/v1\/[^/]+\/t\/updated$/ }, // - ff/v1//u//membership { topic: /^ff\/v1\/[^/]+\/u\/[^/]+\/membership$/ }, + // - ff/v1//p//state + { topic: /^ff\/v1\/[^/]+\/p\/[^/]+\/state$/ }, + // - ff/v1//d//state + { topic: /^ff\/v1\/[^/]+\/d\/[^/]+\/state$/ }, // ff/v1/platform/sync { topic: /^ff\/v1\/platform\/sync$/ }, // ff/v1/platform/leader @@ -396,7 +423,11 @@ module.exports = function (app) { // - ff/v1//t/updated { topic: /^ff\/v1\/([^/]+)\/t\/updated$/, verify: 'checkUserIsTeamMember' }, // - ff/v1//u//membership - { topic: /^ff\/v1\/([^/]+)\/u\/([^/]+)\/membership$/, verify: 'checkUserIsTeamMember' } + { topic: /^ff\/v1\/([^/]+)\/u\/([^/]+)\/membership$/, verify: 'checkUserIsTeamMember' }, + // - ff/v1//p/+/state + { topic: /^ff\/v1\/([^/]+)\/p\/([^/]+)\/state$/, verify: 'checkTeamStateSub' }, + // - ff/v1//d/+/state + { topic: /^ff\/v1\/([^/]+)\/d\/([^/]+)\/state$/, verify: 'checkTeamStateSub' } ], pub: [] }, diff --git a/forge/comms/devices.js b/forge/comms/devices.js index ab85b6d9cc..4c890e5576 100644 --- a/forge/comms/devices.js +++ b/forge/comms/devices.js @@ -121,7 +121,11 @@ class DeviceCommsHandler { const teamId = this.app.db.models.Team.encodeHashid(device.TeamId) const startTime = Date.now() try { + const previousState = device.state const payload = JSON.parse(status.status) + if (previousState === 'restarting' && payload?.state === 'stopped') { + payload.state = 'restarting' + } await this.app.db.controllers.Device.updateState(device, payload) if (payload === null) { @@ -130,6 +134,10 @@ class DeviceCommsHandler { return } + if (payload.state !== previousState) { + this.app.comms.team.notifyDeviceState(teamId, status.id, payload.state) + } + // If the status state===unknown, the device is waiting for confirmation // it has the right details. Always response with an 'update' command in // this scenario diff --git a/forge/comms/index.js b/forge/comms/index.js index 04a7c463e0..ab7cb9ed80 100644 --- a/forge/comms/index.js +++ b/forge/comms/index.js @@ -71,6 +71,14 @@ module.exports = fp(async function (app, _opts) { if (!teamHash || !userHash) return const msg = { reason: reason || null, srcId: srcId || null } client.publish(`ff/v1/${teamHash}/u/${userHash}/membership`, JSON.stringify(msg)) + }, + notifyDeviceState: function (teamHash, id, state) { + if (!teamHash || !id) return + client.publish(`ff/v1/${teamHash}/d/${id}/state`, JSON.stringify({ id, meta: { state } })) + }, + notifyInstanceState: function (teamHash, id, state) { + if (!teamHash || !id) return + client.publish(`ff/v1/${teamHash}/p/${id}/state`, JSON.stringify({ id, meta: { state } })) } } }) diff --git a/forge/containers/stub/index.js b/forge/containers/stub/index.js index 03401a8b2d..34faca7fea 100644 --- a/forge/containers/stub/index.js +++ b/forge/containers/stub/index.js @@ -119,8 +119,12 @@ module.exports = { } else { const startTime = project.name === 'stub-slow-start' ? 6000 : module.exports.START_DELAY return new Promise((resolve, reject) => { - setTimeout(() => { + setTimeout(async () => { list[project.id].state = 'running' + // mirror a real launcher reporting its settled state so inflight clears via the confirm path + try { + await this._app.db.controllers.Project.updateLatestProjectState(project.id, 'running') + } catch (err) {} resolve() }, startTime) }) @@ -218,6 +222,15 @@ module.exports = { */ restartFlows: async (project, options) => { this._app.log.info(`[stub driver] Restarting flows ${project.id}`) + // mirror a real launcher reporting running shortly after the bounce, so the restarting mask clears + setTimeout(async () => { + if (list[project.id]) { + list[project.id].state = 'running' + } + try { + await this._app.db.controllers.Project.updateLatestProjectState(project.id, 'running') + } catch (err) {} + }, module.exports.START_DELAY) }, /** diff --git a/forge/db/controllers/Project.js b/forge/db/controllers/Project.js index 19ee3b8b23..be533e369c 100644 --- a/forge/db/controllers/Project.js +++ b/forge/db/controllers/Project.js @@ -14,12 +14,47 @@ const latestProjectState = 'project-latestProjectState' const inflightDeploys = 'project-inflightDeploys' +const lastPublishedProjectState = 'project-lastPublishedProjectState' + +const publishLocks = new Map() + module.exports = { init (app) { app.caches.createCache(inflightProjectState) app.caches.createCache(latestProjectState) app.caches.createCache(inflightDeploys) + app.caches.createCache(lastPublishedProjectState) + }, + + // serialized per project so concurrent callers can't intermix and strand a stale state + publishLiveState: async function (app, project) { + if (!app.comms || !project?.TeamId) return + const key = project.id + const previous = publishLocks.get(key) ?? Promise.resolve() + const current = previous.catch(() => {}).then(() => this._publishLiveStateUnlocked(app, project)) + const tracked = current.finally(() => { + if (publishLocks.get(key) === tracked) { + publishLocks.delete(key) + } + }) + publishLocks.set(key, tracked) + return tracked + }, + + _publishLiveStateUnlocked: async function (app, project) { + try { + const inflight = await this.getInflightState(app, project) + const latest = await this.getLatestProjectState(app, project.id) + const effective = inflight ?? (project.state === 'suspended' ? 'suspended' : (latest ?? project.state)) + if (!effective) return + const cache = app.caches.getCache(lastPublishedProjectState) + if (await cache.get(project.id) === effective) return + await cache.set(project.id, effective) + app.comms.team.notifyInstanceState(app.db.models.Team.encodeHashid(project.TeamId), project.id, effective) + } catch (err) { + app.log.warn(`Failed to broadcast live state for ${project.id}: ${err.toString()}`) + } }, getProjectPaginationOptions: function (app, request) { @@ -58,6 +93,7 @@ module.exports = { */ setInflightState: async function (app, project, state) { await app.caches.getCache(inflightProjectState).set(project.id, state) + await this.publishLiveState(app, project) }, /** @@ -87,6 +123,7 @@ module.exports = { clearInflightState: async function (app, project) { await app.caches.getCache(inflightProjectState).del(project.id) await app.caches.getCache(inflightDeploys).del(project.id) + await this.publishLiveState(app, project) }, /** @@ -810,6 +847,16 @@ module.exports = { } else { await this.setLatestProjectState(app, projectId, state) } + const project = await app.db.models.Project.byId(projectId, { barebone: true }) + if (project) { + const inflight = await this.getInflightState(app, project) + const isTransition = inflight === 'starting' || inflight === 'restarting' + if (isTransition && ['running', 'safe', 'crashed'].includes(state)) { + await this.clearInflightState(app, project) + } else if (app.comms) { + await this.publishLiveState(app, project) + } + } } } diff --git a/forge/db/models/Project.js b/forge/db/models/Project.js index fc4817f0e0..a6dab47dfd 100644 --- a/forge/db/models/Project.js +++ b/forge/db/models/Project.js @@ -166,6 +166,11 @@ module.exports = { } } }, + afterSave: async (project, opts) => { + if (project.changed('state')) { + await Controllers.Project.publishLiveState(project) + } + }, afterDestroy: async (project, opts) => { await M.AccessToken.destroy({ where: { diff --git a/forge/routes/api/projectActions.js b/forge/routes/api/projectActions.js index ebcf8d053d..0483395967 100644 --- a/forge/routes/api/projectActions.js +++ b/forge/routes/api/projectActions.js @@ -8,6 +8,7 @@ * @namespace project * @memberof forge.routes.api */ + module.exports = async function (app) { app.post('/start', { preHandler: app.needsPermission('project:change-status'), @@ -50,8 +51,11 @@ module.exports = async function (app) { await app.db.controllers.Project.setInflightState(request.project, 'starting') const startResult = await app.containers.start(request.project) startResult.started.then(async () => { - await app.auditLog.Project.project.started(request.session.User, null, request.project) - await app.db.controllers.Project.clearInflightState(request.project) + if (request.project.state === 'suspended') { + await app.db.controllers.Project.clearInflightState(request.project) + } else { + await app.auditLog.Project.project.started(request.session.User, null, request.project) + } return true }).catch(err => { app.log.info(`failed to start project ${request.project.id}`) @@ -154,7 +158,6 @@ module.exports = async function (app) { await request.project.save() await app.containers.restartFlows(request.project) await app.auditLog.Project.project.restarted(request.session.User, null, request.project) - await app.db.controllers.Project.clearInflightState(request.project) reply.send({ status: 'okay' }) } catch (err) { await app.db.controllers.Project.clearInflightState(request.project) @@ -255,10 +258,12 @@ module.exports = async function (app) { } await app.db.controllers.Project.setInflightState(request.project, 'rollback') await app.db.controllers.Project.importProjectSnapshot(request.project, snapshot) - await app.db.controllers.Project.clearInflightState(request.project) await app.auditLog.Project.project.snapshot.rolledBack(request.session.User, null, request.project, snapshot) if (restartProject) { + await app.db.controllers.Project.setInflightState(request.project, 'restarting') await app.containers.restartFlows(request.project) + } else { + await app.db.controllers.Project.clearInflightState(request.project) } reply.send({ status: 'okay' }) } catch (err) { diff --git a/frontend/src/components/DevicesBrowser.vue b/frontend/src/components/DevicesBrowser.vue index 24f8883cf0..e05e442327 100644 --- a/frontend/src/components/DevicesBrowser.vue +++ b/frontend/src/components/DevicesBrowser.vue @@ -377,6 +377,7 @@ import FfPopover from '../ui-components/components/Popover.vue' import PopoverItem from '../ui-components/components/PopoverItem.vue' import FfCheckbox from '../ui-components/components/form/Checkbox.vue' +import { applyLiveState } from '../utils/applyLiveState.js' import { debounce } from '../utils/eventHandling.js' import { createPollTimer } from '../utils/timers.js' @@ -388,6 +389,7 @@ import RemoveDeviceFromGroupDialog from './dialogs/device-group-management/Remov import { useAccountSettingsStore } from '@/stores/account-settings.js' import { useContextStore } from '@/stores/context.js' +import { useLiveStatusStore } from '@/stores/live-status' import { useUxDialogStore } from '@/stores/ux-dialog.js' import { useUxToursStore } from '@/stores/ux-tours.js' @@ -468,6 +470,7 @@ export default { computed: { ...mapState(useContextStore, ['team']), ...mapState(useAccountSettingsStore, ['featuresCheck']), + ...mapState(useLiveStatusStore, { liveDeviceStatuses: 'deviceStatuses', statusChannelLive: 'live' }), ...mapState(useUxDialogStore, ['dialog']), ...mapState(useUxToursStore, ['tours']), columns () { @@ -604,11 +607,19 @@ export default { if (this.dialog?.is?.payload?.devices) { this.setDialogDevices(devices) } + }, + liveDeviceStatuses: { handler: 'applyLiveStatus', deep: true }, + statusChannelLive (live) { + if (live) { + this.pollTimer?.stop() + } else { + this.pollTimer?.start() + } } }, mounted () { this.fullReloadOfData() - this.pollTimer = createPollTimer(this.pollTimerElapsed, POLL_TIME) // auto starts + this.pollTimer = createPollTimer(this.pollTimerElapsed, POLL_TIME, !this.statusChannelLive) }, async unmounted () { this.pollTimer.stop() @@ -620,6 +631,20 @@ export default { }, methods: { ...mapActions(useUxDialogStore, ['setDialogDevices']), + applyLiveStatus () { + for (const id of this.allDeviceStatuses.keys()) { + const state = this.liveDeviceStatuses[id] + if (!state) continue + const statusObj = this.allDeviceStatuses.get(id) + if (statusObj.status !== state) { + this.allDeviceStatuses.set(id, applyLiveState(statusObj, state, { device: true })) + } + const device = this.devices.get(id) + if (device && device.status !== state) { + this.devices.set(id, applyLiveState(device, state, { device: true })) + } + } + }, pollTimerElapsed: async function () { this.pollTimer.pause() try { diff --git a/frontend/src/composables/DeviceHelper.js b/frontend/src/composables/DeviceHelper.js index 00ea1808e5..87584bb553 100644 --- a/frontend/src/composables/DeviceHelper.js +++ b/frontend/src/composables/DeviceHelper.js @@ -7,21 +7,13 @@ import deviceApi from '../api/devices.js' import Alerts from '../services/alerts.js' import Dialog from '../services/dialog.js' import { DeviceStateMutator } from '../utils/DeviceStateMutator.js' +import { isTransitionState } from '../utils/stateTransitions.js' import { createPollTimer } from '../utils/timers.js' import { useContextStore } from '@/stores/context.js' // constants const POLL_TIME = 5000 -const deviceTransitionStates = [ - 'loading', - 'installing', - 'starting', - 'stopping', - 'restarting', - 'suspending', - 'importing' -] export function useDeviceHelper () { const $router = useRouter() @@ -33,7 +25,7 @@ export function useDeviceHelper () { // duplicated functionality because the pollTimer is not reactive const isPolling = ref(false) - const isInTransitionState = computed(() => deviceTransitionStates.includes(device.value.status)) + const isInTransitionState = computed(() => isTransitionState(device.value.status)) const agentSupportsDeviceAccess = computed(() => device.value?.agentVersion && semver.gte(device.value?.agentVersion, '0.8.0') diff --git a/frontend/src/mixins/Instance.js b/frontend/src/mixins/Instance.js index 7a2d00b903..0449765016 100644 --- a/frontend/src/mixins/Instance.js +++ b/frontend/src/mixins/Instance.js @@ -6,13 +6,16 @@ import usePermissions from '../composables/Permissions.js' import alerts from '../services/alerts.js' import Dialog from '../services/dialog.js' import { InstanceStateMutator } from '../utils/InstanceStateMutator.js' +import { applyLiveState } from '../utils/applyLiveState.js' import { useAccountStore } from '@/stores/account.js' import { useContextStore } from '@/stores/context.js' +import { useLiveStatusStore } from '@/stores/live-status' export default { computed: { ...mapState(useContextStore, ['team']), + ...mapState(useLiveStatusStore, { liveInstanceStatuses: 'instanceStatuses', statusChannelLive: 'live' }), instanceRunning () { return this.instance?.meta?.state === 'running' }, @@ -37,10 +40,16 @@ export default { instance (instance) { this.instanceChanged() this.setContextualInstance(instance) - } + }, + liveInstanceStatuses: { handler: 'applyLiveStatus', deep: true } }, methods: { ...mapActions(useContextStore, { setContextualInstance: 'setInstance' }), + applyLiveStatus () { + const state = this.liveInstanceStatuses[this.instance?.id] + if (!state || this.instance?.meta?.state === state) return + this.instance = applyLiveState(this.instance, state, { clearFlags: true }) + }, showConfirmDeleteDialog () { this.$refs.confirmInstanceDeleteDialog.show(this.instance) }, diff --git a/frontend/src/pages/application/index.vue b/frontend/src/pages/application/index.vue index 5a9cafe6ee..720bdf3e65 100644 --- a/frontend/src/pages/application/index.vue +++ b/frontend/src/pages/application/index.vue @@ -25,7 +25,9 @@ @instance-delete="instanceShowConfirmDelete" /> - + @@ -38,6 +40,7 @@ import usePermissions from '../../composables/Permissions.js' import applicationMixin from '../../mixins/Application.js' import instanceActionsMixin from '../../mixins/InstanceActions.js' +import { applyLiveState } from '../../utils/applyLiveState.js' import ConfirmInstanceDeleteDialog from '../instance/Settings/dialogs/ConfirmInstanceDeleteDialog.vue' @@ -45,6 +48,7 @@ import ConfirmApplicationDeleteDialog from './Settings/dialogs/ConfirmApplicatio import { useAccountSettingsStore } from '@/stores/account-settings.js' import { useContextStore } from '@/stores/context.js' +import { useLiveStatusStore } from '@/stores/live-status' export default { name: 'ApplicationPage', @@ -62,6 +66,7 @@ export default { computed: { ...mapState(useContextStore, ['team']), ...mapState(useAccountSettingsStore, ['features']), + ...mapState(useLiveStatusStore, { liveInstanceStatuses: 'instanceStatuses', statusChannelLive: 'live' }), navigation () { const routes = [ { @@ -133,6 +138,18 @@ export default { '$route.params': { handler: 'updateApplication', immediate: true + }, + liveInstanceStatuses: { handler: 'applyLiveStatus', deep: true } + }, + methods: { + applyLiveStatus () { + for (const id of this.applicationInstances.keys()) { + const state = this.liveInstanceStatuses[id] + if (!state) continue + const row = this.applicationInstances.get(id) + if (row?.status === state && row?.meta?.state === state) continue + this.applicationInstances.set(id, applyLiveState(row, state, { clearFlags: true })) + } } } } diff --git a/frontend/src/pages/device/index.vue b/frontend/src/pages/device/index.vue index bfa510d9db..d03feafd34 100644 --- a/frontend/src/pages/device/index.vue +++ b/frontend/src/pages/device/index.vue @@ -154,6 +154,8 @@ import Alerts from '../../services/alerts.js' import Dialog from '../../services/dialog.js' import { DeviceStateMutator } from '../../utils/DeviceStateMutator.js' +import { applyLiveState } from '../../utils/applyLiveState.js' +import { isTransitionState } from '../../utils/stateTransitions.js' import { createPollTimer } from '../../utils/timers.js' import DeviceAssignApplicationDialog from '../team/Devices/dialogs/DeviceAssignApplicationDialog.vue' @@ -169,22 +171,13 @@ import DeviceModeBadge from './components/DeviceModeBadge.vue' import { useAccountSettingsStore } from '@/stores/account-settings.js' import { useAccountStore } from '@/stores/account.js' import { useContextStore } from '@/stores/context.js' +import { useLiveStatusStore } from '@/stores/live-status' import { useUxStore } from '@/stores/ux.js' // constants const POLL_TIME = 5000 -const deviceTransitionStates = [ - 'loading', - 'installing', - 'starting', - 'stopping', - 'restarting', - 'suspending', - 'importing' -] - export default { name: 'DevicePage', components: { @@ -227,6 +220,7 @@ export default { computed: { ...mapState(useContextStore, ['team']), ...mapState(useAccountSettingsStore, ['features']), + ...mapState(useLiveStatusStore, { liveDeviceStatuses: 'deviceStatuses', statusChannelLive: 'live' }), actionsButtonKind () { switch (true) { case this.neverConnected: @@ -363,7 +357,17 @@ export default { } }, watch: { - device: 'deviceChanged' + device: 'deviceChanged', + liveDeviceStatuses: { handler: 'applyLiveStatus', deep: true }, + statusChannelLive (live) { + if (live) { + this.pollTimer?.stop() + } else if (this.pollTimer) { + this.pollTimer.start() + } else { + this.pollTimer = createPollTimer(this.pollTimerElapsed, POLL_TIME) + } + } }, async mounted () { this.mounted = true @@ -380,6 +384,11 @@ export default { methods: { ...mapActions(useUxStore, ['validateUserAction']), ...mapActions(useContextStore, { setContextualDevice: 'setDevice' }), + applyLiveStatus () { + const state = this.liveDeviceStatuses[this.device?.id] + if (!state || this.device?.status === state) return + this.device = applyLiveState(this.device, state, { device: true, clearFlags: true }) + }, pollTimerElapsed: async function () { // Only refresh device via the timer if we are on the overview page, developer mode page // the device status is empty or the device is in a transition state @@ -391,7 +400,7 @@ export default { await this.loadDevice() } else if (typeof this.device?.status === 'undefined') { await this.loadDevice() - } else if (deviceTransitionStates.includes(this.device?.status)) { + } else if (isTransitionState(this.device?.status)) { await this.loadDevice() } } catch (err) { @@ -410,7 +419,7 @@ export default { return this.$router.push({ name: 'Home' }) } } - if (!this.pollTimer) { + if (!this.pollTimer && !this.statusChannelLive) { this.pollTimer = createPollTimer(this.pollTimerElapsed, POLL_TIME) } @@ -422,7 +431,7 @@ export default { useAccountStore().setTeam(this.device.team.slug) }, deviceRefresh: async function () { - if (this.pollTimer.running) { + if (this.pollTimer?.running) { // If the poll timer is running, we don't need to manually refresh the device return } diff --git a/frontend/src/pages/instance/Editor/index.vue b/frontend/src/pages/instance/Editor/index.vue index 82e9ae15dd..c771fa66e3 100644 --- a/frontend/src/pages/instance/Editor/index.vue +++ b/frontend/src/pages/instance/Editor/index.vue @@ -43,6 +43,7 @@ diff --git a/frontend/src/pages/instance/index.vue b/frontend/src/pages/instance/index.vue index 7f4cd6b85d..0920682f50 100644 --- a/frontend/src/pages/instance/index.vue +++ b/frontend/src/pages/instance/index.vue @@ -70,7 +70,7 @@ /> - + diff --git a/frontend/src/pages/team/Applications/components/compact/DeviceTile.vue b/frontend/src/pages/team/Applications/components/compact/DeviceTile.vue index 475708d74e..42ad55fdac 100644 --- a/frontend/src/pages/team/Applications/components/compact/DeviceTile.vue +++ b/frontend/src/pages/team/Applications/components/compact/DeviceTile.vue @@ -1,8 +1,8 @@