diff --git a/src/App.vue b/src/App.vue index b02a46d81b..0dd8ecd36b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -103,6 +103,7 @@ + +
+
+ Mission location +
+ + Vehicle + + + Clear + +
+
+
+
+
+
+ + +
+
+ + + diff --git a/src/components/blueos-cloud/BlueOsCloudMissionDecisionOptions.vue b/src/components/blueos-cloud/BlueOsCloudMissionDecisionOptions.vue new file mode 100644 index 0000000000..afa1766e8e --- /dev/null +++ b/src/components/blueos-cloud/BlueOsCloudMissionDecisionOptions.vue @@ -0,0 +1,85 @@ + + + diff --git a/src/components/blueos-cloud/BlueOsCloudMissionForm.vue b/src/components/blueos-cloud/BlueOsCloudMissionForm.vue new file mode 100644 index 0000000000..c095c47de0 --- /dev/null +++ b/src/components/blueos-cloud/BlueOsCloudMissionForm.vue @@ -0,0 +1,137 @@ + + + diff --git a/src/components/blueos-cloud/BlueOsCloudMissionPicker.vue b/src/components/blueos-cloud/BlueOsCloudMissionPicker.vue new file mode 100644 index 0000000000..cbb4877516 --- /dev/null +++ b/src/components/blueos-cloud/BlueOsCloudMissionPicker.vue @@ -0,0 +1,243 @@ + + + diff --git a/src/components/blueos-cloud/BlueOsCloudMissionStartupDialog.vue b/src/components/blueos-cloud/BlueOsCloudMissionStartupDialog.vue new file mode 100644 index 0000000000..fcbec8268c --- /dev/null +++ b/src/components/blueos-cloud/BlueOsCloudMissionStartupDialog.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/components/blueos-cloud/BlueOsCloudMissionStartupHost.vue b/src/components/blueos-cloud/BlueOsCloudMissionStartupHost.vue new file mode 100644 index 0000000000..b490123573 --- /dev/null +++ b/src/components/blueos-cloud/BlueOsCloudMissionStartupHost.vue @@ -0,0 +1,39 @@ + + + diff --git a/src/components/mini-widgets/MissionIdentifier.vue b/src/components/mini-widgets/MissionIdentifier.vue index ac3e59ae5c..e184ba590e 100644 --- a/src/components/mini-widgets/MissionIdentifier.vue +++ b/src/components/mini-widgets/MissionIdentifier.vue @@ -1,19 +1,19 @@ diff --git a/src/composables/blueos-cloud/useBlueOsCloudMission.ts b/src/composables/blueos-cloud/useBlueOsCloudMission.ts new file mode 100644 index 0000000000..b2e0adb691 --- /dev/null +++ b/src/composables/blueos-cloud/useBlueOsCloudMission.ts @@ -0,0 +1,167 @@ +import { type ComputedRef, computed } from 'vue' + +import { openSnackbar } from '@/composables/snackbar' +import type { BlueOsCloudMission } from '@/libs/blueos-cloud/types' +import { generateAutomaticMissionName } from '@/libs/mission/automatic-name' +import { useAppInterfaceStore } from '@/stores/appInterface' +import { useBlueOsCloudStore } from '@/stores/blueOsCloud' +import { useMissionStore } from '@/stores/mission' +import type { WaypointCoordinates } from '@/types/mission' + +/** + * Payload emitted by the BlueOS Cloud mission form. + */ +export type MissionFormSubmitPayload = { + /** + * Mission title. + */ + name: string + /** + * Mission description. + */ + description: string + /** + * Mission start location, or `null` when the user left it unset. + */ + location: WaypointCoordinates | null +} + +/** + * Public API of {@link useBlueOsCloudMission}. + */ +type BlueOsCloudMissionApi = { + /** + * Whether cloud missions are available, which requires pirate mode and an authenticated user. + */ + isCloudActive: ComputedRef + /** + * Whether a cloud mission is already linked to the current mission cycle. + */ + hasMissionThisCycle: ComputedRef + /** + * Mission offered as "continue previous", or `null` when one is already linked to this cycle. + */ + previousMission: ComputedRef + /** + * Fetches the mission list when it holds no record of the linked mission, so its details are revalidated. + */ + ensureLinkedMissionLoaded: () => Promise + /** + * Relinks the previously linked mission to the current cycle. + */ + continuePreviousMission: () => void + /** + * Links a mission chosen from the picker to the current cycle. + * @param {BlueOsCloudMission} mission - Selected cloud mission. + */ + selectExistingMission: (mission: BlueOsCloudMission) => void + /** + * Creates a mission from the form payload and links it to a brand-new mission cycle. + * @param {MissionFormSubmitPayload} payload - New mission fields. + */ + createMission: (payload: MissionFormSubmitPayload) => void + /** + * Applies the form payload to the mission already linked to this cycle. + * @param {MissionFormSubmitPayload} payload - Updated mission fields. + */ + editLinkedMission: (payload: MissionFormSubmitPayload) => void +} + +/** + * Mission-cycle awareness and the linking actions (continue, select, create, edit) shared by the startup decision + * dialog and the mission configuration dialog, so both entry points behave identically. + * @returns {BlueOsCloudMissionApi} Cycle state and mission linking actions. + */ +export const useBlueOsCloudMission = (): BlueOsCloudMissionApi => { + const interfaceStore = useAppInterfaceStore() + const cloudStore = useBlueOsCloudStore() + const missionStore = useMissionStore() + + // BlueOS Cloud missions are an advanced feature, gated behind pirate mode like the Cloud settings menu. + const isCloudActive = computed(() => interfaceStore.pirateMode && cloudStore.isAuthenticated) + + // Stamp that ties a cloud mission link to the current mission cycle (renewed after 6h idle / a new day). + const currentCycleId = computed(() => new Date(missionStore.missionStartTime).getTime()) + + const hasMissionThisCycle = computed( + () => !!cloudStore.linkedMissionId && cloudStore.linkedMissionCycleId === currentCycleId.value + ) + + // Last linked mission id is kept even after skip/cycle mismatch, so "continue previous" stays available. + const previousMission = computed(() => (hasMissionThisCycle.value ? null : cloudStore.linkedMission)) + + const notify = (message: string): void => { + openSnackbar({ message, variant: 'success', duration: 3000, closeButton: true }) + } + + const ensureLinkedMissionLoaded = async (): Promise => { + const missionRef = cloudStore.linkedMissionId + // Also fetches while the mission is linked to this cycle, where only the cache would answer: the remembered + // details would otherwise never notice an edit made on the cloud, and an edit from Cockpit would revert it. + if (!missionRef || !cloudStore.isLinkedMissionSynced) return + if (cloudStore.missions.some((mission) => mission.id === missionRef)) return + try { + await cloudStore.refreshMissions() + } catch { + // Offline — continue-previous stays hidden until the missions can be loaded. + } + } + + const continuePreviousMission = (): void => { + const mission = previousMission.value + if (!mission) return + const title = mission.title?.trim() + missionStore.applyMissionName(title || generateAutomaticMissionName(), { + isAutomatic: !title, + startNewMission: false, + }) + cloudStore.linkExistingMission(mission.id, currentCycleId.value) + notify(`Continuing BlueOS Cloud mission "${title || 'Untitled mission'}".`) + } + + const selectExistingMission = (mission: BlueOsCloudMission): void => { + logUserAction(`Opened BlueOS Cloud mission '${mission.title}'`) + const title = mission.title?.trim() + missionStore.applyMissionName(title || generateAutomaticMissionName(), { + isAutomatic: !title, + startNewMission: false, + }) + cloudStore.linkExistingMission(mission.id, currentCycleId.value) + notify(`Now logging to BlueOS Cloud mission "${title || 'Untitled mission'}".`) + } + + const createMission = (payload: MissionFormSubmitPayload): void => { + const { name, description, location } = payload + logUserAction(`Created BlueOS Cloud mission '${name}'`) + missionStore.applyMissionName(name, { isAutomatic: false, startNewMission: true }) + cloudStore.startCloudMission( + { name, description, latitude: location?.[0] ?? null, longitude: location?.[1] ?? null }, + currentCycleId.value + ) + notify(`Mission "${name}" saved. It will sync to BlueOS Cloud when online.`) + } + + const editLinkedMission = (payload: MissionFormSubmitPayload): void => { + const { name, description, location } = payload + logUserAction('Edited the linked BlueOS Cloud mission') + missionStore.applyMissionName(name, { isAutomatic: false, startNewMission: false }) + cloudStore.updateLinkedMission({ + name, + description, + latitude: location?.[0] ?? null, + longitude: location?.[1] ?? null, + }) + notify(`Mission "${name}" updated.`) + } + + return { + isCloudActive, + hasMissionThisCycle, + previousMission, + ensureLinkedMissionLoaded, + continuePreviousMission, + selectExistingMission, + createMission, + editLinkedMission, + } +} diff --git a/src/composables/blueos-cloud/useBlueOsCloudMissionStartupDialog.ts b/src/composables/blueos-cloud/useBlueOsCloudMissionStartupDialog.ts new file mode 100644 index 0000000000..4717d7b451 --- /dev/null +++ b/src/composables/blueos-cloud/useBlueOsCloudMissionStartupDialog.ts @@ -0,0 +1,161 @@ +import { type ComputedRef, type Ref, onMounted, ref, watch } from 'vue' + +import { type MissionFormSubmitPayload, useBlueOsCloudMission } from '@/composables/blueos-cloud/useBlueOsCloudMission' +import type { BlueOsCloudMission } from '@/libs/blueos-cloud/types' +import { useBlueOsCloudStore } from '@/stores/blueOsCloud' + +/** + * Public API of {@link useBlueOsCloudMissionStartupDialog}. + */ +type BlueOsCloudMissionStartupDialogApi = { + /** + * Whether the initial decision dialog is open. + */ + showDecisionDialog: Ref + /** + * Whether the existing-mission picker is open. + */ + showMissionPicker: Ref + /** + * Whether the create-mission form is open. + */ + showMissionForm: Ref + /** + * Previous linked mission offered as "continue", when known. + */ + previousMission: ComputedRef + /** + * Relink the previous mission to the current cycle. + */ + continuePreviousMission: () => void + /** + * Open the existing-mission picker from the decision dialog. + */ + openMissionPicker: () => void + /** + * Open the create-mission form from the decision dialog. + */ + openCreateMissionForm: () => void + /** + * Skip cloud missions for this cycle while keeping the previous mission id. + */ + skipMission: () => void + /** + * Dismiss the decision dialog without choosing anything. + */ + dismissDecisionDialog: () => void + /** + * Link a mission chosen from the picker. + * @param {BlueOsCloudMission} mission - Selected cloud mission. + */ + onMissionSelected: (mission: BlueOsCloudMission) => void + /** + * Create a mission from the form submit payload. + * @param {MissionFormSubmitPayload} payload - New mission fields. + */ + onMissionFormSubmit: (payload: MissionFormSubmitPayload) => void +} + +/** + * State and handlers for the BlueOS Cloud mission decision dialog shown when Cockpit starts. + * @returns {BlueOsCloudMissionStartupDialogApi} Dialog visibility flags and action handlers. + */ +export const useBlueOsCloudMissionStartupDialog = (): BlueOsCloudMissionStartupDialogApi => { + const cloudStore = useBlueOsCloudStore() + const { + isCloudActive, + hasMissionThisCycle, + previousMission, + ensureLinkedMissionLoaded, + continuePreviousMission, + selectExistingMission, + createMission, + } = useBlueOsCloudMission() + + const showDecisionDialog = ref(false) + const showMissionPicker = ref(false) + const showMissionForm = ref(false) + + const closeDecisionDialog = (): void => { + showDecisionDialog.value = false + } + + const dismissDecisionDialog = (): void => { + logUserAction('Dismissed the BlueOS Cloud mission question without choosing') + closeDecisionDialog() + } + + const onContinuePreviousMission = (): void => { + continuePreviousMission() + closeDecisionDialog() + } + + const openMissionPicker = (): void => { + closeDecisionDialog() + showMissionPicker.value = true + } + + const openCreateMissionForm = (): void => { + closeDecisionDialog() + showMissionForm.value = true + } + + const skipMission = (): void => { + cloudStore.clearMissionCycleLink() + closeDecisionDialog() + } + + // The picker and the form replace the decision dialog, so closing one without choosing has to bring the question + // back: nothing else would reopen it for the rest of the session. + let isChoiceMade = false + + const onSurfaceVisibilityChange = (isOpen: boolean): void => { + if (isOpen || isChoiceMade) return + showDecisionDialog.value = true + } + + watch(showMissionPicker, onSurfaceVisibilityChange) + watch(showMissionForm, onSurfaceVisibilityChange) + + const openIfEligible = async (): Promise => { + // A mission already linked to this cycle means the session is just being resumed, so there is nothing to decide. + if (!isCloudActive.value || hasMissionThisCycle.value) return + // Never stack the question on top of a surface the user is already answering it with. + if (showDecisionDialog.value || showMissionPicker.value || showMissionForm.value) return + await ensureLinkedMissionLoaded() + isChoiceMade = false + showDecisionDialog.value = true + } + + onMounted(() => { + void openIfEligible() + }) + + watch(isCloudActive, (active, wasActive) => { + if (active && !wasActive) void openIfEligible() + }) + + const onMissionSelected = (mission: BlueOsCloudMission): void => { + isChoiceMade = true + selectExistingMission(mission) + } + + const onMissionFormSubmit = (payload: MissionFormSubmitPayload): void => { + isChoiceMade = true + createMission(payload) + } + + return { + showDecisionDialog, + showMissionPicker, + showMissionForm, + previousMission, + continuePreviousMission: onContinuePreviousMission, + openMissionPicker, + openCreateMissionForm, + skipMission, + dismissDecisionDialog, + onMissionSelected, + onMissionFormSubmit, + } +} diff --git a/src/libs/blueos-cloud/api.ts b/src/libs/blueos-cloud/api.ts new file mode 100644 index 0000000000..cba3ffce82 --- /dev/null +++ b/src/libs/blueos-cloud/api.ts @@ -0,0 +1,209 @@ +import { BlueOsCloudMission, BlueOsCloudPaginatedResponse } from './types' + +export const BLUEOS_CLOUD_API_BASE = 'https://app.blueos.cloud/api/v1' +export const BLUEOS_CLOUD_APP_BASE = 'https://app.blueos.cloud' + +/** + * Error carrying the HTTP status of a failed BlueOS Cloud API call, so callers can react to specific cases + * (e.g. a `404` meaning the mission was deleted on the cloud). + */ +export class BlueOsCloudApiError extends Error { + /** + * HTTP status code returned by the BlueOS Cloud API. + */ + status: number + + /** + * Creates a new BlueOsCloudApiError. + * @param {string} message - Human readable description of the failure. + * @param {number} status - HTTP status code returned by the API. + */ + constructor(message: string, status: number) { + super(message) + this.name = 'BlueOsCloudApiError' + this.status = status + } +} + +/** + * Whether a request was definitively refused by the server, as opposed to never having reached it (offline, so + * nothing was thrown by the API at all) or having hit a transient condition that a retry can clear. + * @param {unknown} error - Error thrown by an API call. + * @returns {boolean} `true` when the server answered with a status that a retry will not change. + */ +export const isPermanentApiError = (error: unknown): boolean => + error instanceof BlueOsCloudApiError && + error.status >= 400 && + error.status < 500 && + error.status !== 408 && + error.status !== 429 + +/** + * Returns the public URL where a BlueOS Cloud mission can be viewed in the user's browser. + * @param {string} missionId - Identifier of the mission as returned by the API. + * @returns {string} Fully-qualified URL pointing at the mission detail page. + */ +export const buildBlueOsCloudMissionUrl = (missionId: string): string => + `${BLUEOS_CLOUD_APP_BASE}/v2/missions/${missionId}` + +// The BlueOS Cloud API takes the Auth0 access token raw, without the `Bearer` scheme Auth0's own endpoints use. +const authHeaders = (accessToken: string): Record => ({ + Authorization: accessToken, +}) + +const authJsonHeaders = (accessToken: string): Record => ({ + 'Authorization': accessToken, + 'Content-Type': 'application/json', +}) + +const fetchAllPages = async (initialUrl: string, accessToken: string): Promise => { + const all: T[] = [] + let nextUrl: string | null = initialUrl + + while (nextUrl) { + const res = await fetch(nextUrl, { headers: authHeaders(accessToken) }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new BlueOsCloudApiError(`BlueOS Cloud API error: ${res.status} ${text || res.statusText}`, res.status) + } + const data = (await res.json()) as BlueOsCloudPaginatedResponse | T[] + if (Array.isArray(data)) { + all.push(...data) + nextUrl = null + } else { + all.push(...data.results) + nextUrl = data.next + } + } + + return all +} + +/** + * Fetches every mission visible to the authenticated user, automatically following pagination. + * @param {string} accessToken - Valid BlueOS Cloud access token. + * @returns {Promise} List of missions sorted as returned by the API. + */ +export const fetchMissions = async (accessToken: string): Promise => { + return fetchAllPages(`${BLUEOS_CLOUD_API_BASE}/missions/`, accessToken) +} + +/** + * Creates a new mission in BlueOS Cloud. + * + * Latitude and longitude are formatted to 6 decimal places to satisfy the API contract. + * @param {object} input - Data describing the new mission. + * @param {string} input.name - Human-readable mission title. + * @param {string} [input.description] - Optional mission description. + * @param {number | null} [input.latitude] - Optional starting latitude in decimal degrees. + * @param {number | null} [input.longitude] - Optional starting longitude in decimal degrees. + * @param {number} [input.startTime] - Epoch of the moment the mission started; defaults to now. + * @param {string} accessToken - Valid BlueOS Cloud access token. + * @returns {Promise} Newly created mission as returned by the API. + */ +export const createMission = async ( + input: { + /** + * Human-readable mission title. + */ + name: string + /** + * Optional mission description. + */ + description?: string + /** + * Optional starting latitude in decimal degrees. + */ + latitude?: number | null + /** + * Optional starting longitude in decimal degrees. + */ + longitude?: number | null + /** + * Epoch of the moment the mission started; defaults to now. + */ + startTime?: number + }, + accessToken: string +): Promise => { + // Taken from the caller rather than from the clock, since a mission created offline is only posted hours later. + const body: Record = { + title: input.name, + start_time: new Date(input.startTime ?? Date.now()).toISOString(), + } + if (input.description) body.description = input.description + if (input.latitude != null) body.start_latitude = input.latitude.toFixed(6) + if (input.longitude != null) body.start_longitude = input.longitude.toFixed(6) + + const res = await fetch(`${BLUEOS_CLOUD_API_BASE}/missions/`, { + method: 'POST', + headers: authJsonHeaders(accessToken), + body: JSON.stringify(body), + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new BlueOsCloudApiError( + `Failed to create BlueOS Cloud mission: ${res.status} ${text || res.statusText}`, + res.status + ) + } + + return res.json() +} + +/** + * Updates an existing mission in BlueOS Cloud (e.g. to rename it or move its start location). + * + * Only the provided fields are sent, so a rename is a `PATCH` carrying just the new title. + * @param {string} id - Identifier of the mission to update. + * @param {object} input - Fields to change. + * @param {string} [input.name] - New mission title. + * @param {string} [input.description] - New mission description. + * @param {number | null} [input.latitude] - New starting latitude in decimal degrees. + * @param {number | null} [input.longitude] - New starting longitude in decimal degrees. + * @param {string} accessToken - Valid BlueOS Cloud access token. + * @returns {Promise} The updated mission as returned by the API. + */ +export const updateMission = async ( + id: string, + input: { + /** + * New mission title. + */ + name?: string + /** + * New mission description. + */ + description?: string + /** + * New starting latitude in decimal degrees. + */ + latitude?: number | null + /** + * New starting longitude in decimal degrees. + */ + longitude?: number | null + }, + accessToken: string +): Promise => { + const body: Record = {} + if (input.name !== undefined) body.title = input.name + if (input.description !== undefined) body.description = input.description + if (input.latitude !== undefined) body.start_latitude = input.latitude != null ? input.latitude.toFixed(6) : null + if (input.longitude !== undefined) body.start_longitude = input.longitude != null ? input.longitude.toFixed(6) : null + + const res = await fetch(`${BLUEOS_CLOUD_API_BASE}/missions/${id}/`, { + method: 'PATCH', + headers: authJsonHeaders(accessToken), + body: JSON.stringify(body), + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new BlueOsCloudApiError( + `Failed to update BlueOS Cloud mission: ${res.status} ${text || res.statusText}`, + res.status + ) + } + + return res.json() +} diff --git a/src/libs/blueos-cloud/auth.ts b/src/libs/blueos-cloud/auth.ts index 2a0149e495..67145ec134 100644 --- a/src/libs/blueos-cloud/auth.ts +++ b/src/libs/blueos-cloud/auth.ts @@ -1,3 +1,4 @@ +import { BlueOsCloudApiError } from './api' import { BlueOsCloudTokens, BlueOsCloudUser, DeviceAuthorizationResponse, TokenResponse } from './types' export const BLUEOS_CLOUD_AUTH0_DOMAIN = 'bcloud-prod.us.auth0.com' @@ -149,6 +150,9 @@ export const pollForDeviceAuthorizationToken = async ( /** * Refreshes the access token using a previously issued refresh token. + * + * Throws a {@link BlueOsCloudApiError} when the server answered and refused the exchange, and whatever `fetch` + * threw when it could not be reached at all, so callers can tell a dead session from a missing connection. * @param {string} refreshToken - Refresh token returned during the original device flow. * @returns {Promise} New token bundle reusing the input refresh token when a new one is not issued. */ @@ -167,7 +171,10 @@ export const refreshAccessToken = async (refreshToken: string): Promise '') - throw new Error(`Failed to refresh BlueOS Cloud session: ${res.status} ${text || res.statusText}`) + throw new BlueOsCloudApiError( + `Failed to refresh BlueOS Cloud session: ${res.status} ${text || res.statusText}`, + res.status + ) } const data: TokenResponse = await res.json() diff --git a/src/libs/blueos-cloud/mission-list.ts b/src/libs/blueos-cloud/mission-list.ts new file mode 100644 index 0000000000..36cddfb034 --- /dev/null +++ b/src/libs/blueos-cloud/mission-list.ts @@ -0,0 +1,67 @@ +import type { BlueOsCloudMission } from './types' + +/** + * Orders offered by the BlueOS Cloud mission picker. + */ +export type MissionSortKey = 'newest' | 'oldest' | 'name' + +const startTimeOf = (mission: BlueOsCloudMission): number | null => { + const time = mission.start_time ? new Date(mission.start_time).getTime() : NaN + return Number.isFinite(time) ? time : null +} + +const locationTextOf = (mission: BlueOsCloudMission): string | null => { + if (!mission.start_latitude || !mission.start_longitude) return null + const lat = parseFloat(mission.start_latitude) + const lng = parseFloat(mission.start_longitude) + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null + return `${lat.toFixed(4)}, ${lng.toFixed(4)}` +} + +const metaPartsOf = (mission: BlueOsCloudMission): string[] => { + const parts: string[] = [] + const startTime = startTimeOf(mission) + if (startTime !== null) parts.push(new Date(startTime).toLocaleString()) + const location = locationTextOf(mission) + if (location) parts.push(location) + return parts +} + +/** + * Start time and start coordinates of a mission, as shown under its name on the picker rows. + * @param {BlueOsCloudMission} mission - Mission to describe. + * @returns {string} Formatted metadata, or a placeholder when the mission carries none. + */ +export const formatMissionMeta = (mission: BlueOsCloudMission): string => + metaPartsOf(mission).join(' • ') || 'No metadata' + +/** + * Filters missions by a free-text query and sorts what is left. + * + * The query is matched against the same text the picker displays (name, description, date and coordinates), so + * searching for a date or a coordinate prefix narrows the list just like searching for a name does. + * @param {BlueOsCloudMission[]} missions - Missions to narrow down. + * @param {string} query - Free-text search, matched case-insensitively against every word the user typed. + * @param {MissionSortKey} sortKey - Order to apply to the remaining missions. + * @returns {BlueOsCloudMission[]} New array with the matching missions in the requested order. + */ +export const filterAndSortMissions = ( + missions: BlueOsCloudMission[], + query: string, + sortKey: MissionSortKey +): BlueOsCloudMission[] => { + const terms = query.toLowerCase().split(/\s+/).filter(Boolean) + + const matching = missions.filter((mission) => { + if (terms.length === 0) return true + const searchable = [mission.title, mission.description, ...metaPartsOf(mission)].join(' ').toLowerCase() + return terms.every((term) => searchable.includes(term)) + }) + + // Missions with no usable start time are treated as the oldest ones, so they never push dated missions down. + return matching.sort((a, b) => { + if (sortKey === 'name') return (a.title || '').localeCompare(b.title || '') + const [first, second] = [startTimeOf(a) ?? 0, startTimeOf(b) ?? 0] + return sortKey === 'oldest' ? first - second : second - first + }) +} diff --git a/src/libs/blueos-cloud/mission-sync-queue.ts b/src/libs/blueos-cloud/mission-sync-queue.ts new file mode 100644 index 0000000000..d380a5b6ab --- /dev/null +++ b/src/libs/blueos-cloud/mission-sync-queue.ts @@ -0,0 +1,219 @@ +// Cockpit usually runs offline in the field, so BlueOS Cloud mission mutations (create, rename, relocate) +// can't be sent right away. They are kept in a persistent queue and replayed once the internet is reachable. +// +// The queue is keyed by a stable client id rather than the cloud id, because a mission created offline has no +// cloud id yet. A later rename of that same mission coalesces into the pending create, so an offline +// "create then rename" flushes as a single create carrying the final title. + +/** + * A mission whose desired state still needs to be pushed to BlueOS Cloud. + */ +export interface PendingCloudMission { + /** + * Stable client-generated id, used as the mission reference until the cloud id is known. + */ + clientId: string + /** + * Real BlueOS Cloud id once the mission has been created there; `null` while creation is still pending. + */ + cloudId: string | null + /** + * Desired mission title, or `undefined` when this field should be left untouched on update. + */ + title?: string + /** + * Desired mission description, or `undefined` when it should be left untouched. + */ + description?: string + /** + * Desired start latitude in decimal degrees, or `undefined` when it should be left untouched. + */ + latitude?: number | null + /** + * Desired start longitude in decimal degrees, or `undefined` when it should be left untouched. + */ + longitude?: number | null + /** + * Epoch of the moment the mission started, captured when the create was queued so a mission created offline + * isn't dated to whenever it reached the cloud. Absent on entries that only carry an update. + */ + startTime?: number + /** + * Number of failed flush attempts, used to drop operations that can never succeed. + */ + attempts: number + /** + * Bumped on every enqueue, so a flush can tell whether the entry it pushed was edited while in flight. + */ + revision: number +} + +/** + * Persistent queue of missions awaiting synchronization, keyed by client id. + */ +export type PendingMissionQueue = Record + +/** + * Data for queuing the creation of a new mission. + */ +export interface EnqueueCreateParams { + /** + * Stable client id to key the mission by. + */ + clientId: string + /** + * Mission title. + */ + title: string + /** + * Optional mission description. + */ + description?: string + /** + * Start latitude in decimal degrees, or null. + */ + latitude: number | null + /** + * Start longitude in decimal degrees, or null. + */ + longitude: number | null + /** + * Epoch of the moment the mission started. + */ + startTime: number +} + +/** + * Fields to change on a queued mission; omit a field to leave it untouched. + */ +export interface MissionPatch { + /** + * New title. + */ + title?: string + /** + * New description. + */ + description?: string + /** + * New start latitude in decimal degrees. + */ + latitude?: number | null + /** + * New start longitude in decimal degrees. + */ + longitude?: number | null +} + +/** + * Finds a pending mission by its client id or by its already-known cloud id. + * @param {PendingMissionQueue} queue - Current queue. + * @param {string} ref - Client id or cloud id to look up. + * @returns {PendingCloudMission | undefined} The matching pending mission, if any. + */ +export const findPending = (queue: PendingMissionQueue, ref: string): PendingCloudMission | undefined => + queue[ref] ?? Object.values(queue).find((mission) => mission.cloudId === ref) + +/** + * Queues the creation of a brand-new mission. + * @param {PendingMissionQueue} queue - Current queue. + * @param {EnqueueCreateParams} params - New mission data. + * @returns {PendingMissionQueue} The queue with the create operation added. + */ +export const enqueueCreate = (queue: PendingMissionQueue, params: EnqueueCreateParams): PendingMissionQueue => ({ + ...queue, + [params.clientId]: { + clientId: params.clientId, + cloudId: null, + title: params.title, + description: params.description, + latitude: params.latitude, + longitude: params.longitude, + startTime: params.startTime, + attempts: 0, + revision: 0, + }, +}) + +/** + * Queues an update (rename and/or relocate), coalescing into an existing pending entry when present so a + * create-then-rename collapses into a single create and repeated renames don't stack. + * @param {PendingMissionQueue} queue - Current queue. + * @param {string} ref - Client id or cloud id of the mission to update. + * @param {MissionPatch} patch - Fields to change; omit a field to leave it untouched. + * @returns {PendingMissionQueue} The queue with the update coalesced in. + */ +export const enqueueUpdate = (queue: PendingMissionQueue, ref: string, patch: MissionPatch): PendingMissionQueue => { + const existing = findPending(queue, ref) + // A mission that was already synced (or a selected existing cloud mission) is tracked by its cloud id. + const base: PendingCloudMission = existing ?? { clientId: ref, cloudId: ref, attempts: 0, revision: 0 } + const merged: PendingCloudMission = { + ...base, + ...(patch.title !== undefined ? { title: patch.title } : {}), + ...(patch.description !== undefined ? { description: patch.description } : {}), + ...(patch.latitude !== undefined ? { latitude: patch.latitude } : {}), + ...(patch.longitude !== undefined ? { longitude: patch.longitude } : {}), + attempts: 0, + revision: base.revision + 1, + } + return { ...queue, [base.clientId]: merged } +} + +/** + * Removes a mission from the queue once it is fully synced (or abandoned). + * @param {PendingMissionQueue} queue - Current queue. + * @param {string} clientId - Client id of the entry to remove. + * @returns {PendingMissionQueue} The queue without that entry. + */ +const removePending = (queue: PendingMissionQueue, clientId: string): PendingMissionQueue => { + if (!(clientId in queue)) return queue + const next = { ...queue } + delete next[clientId] + return next +} + +/** + * Drops an entry that has just been pushed, unless the user edited it while the request was in flight: that newer + * edit stays queued, retargeted at the cloud id the mission holds now, so it is flushed as an update rather than + * being deleted unsent or created a second time. + * @param {PendingMissionQueue} queue - Current queue. + * @param {PendingCloudMission} pushed - The entry as it was when the request was built. + * @param {string} cloudId - Cloud id the mission holds now that the push succeeded. + * @returns {PendingMissionQueue} The queue without the entry, or with the newer edit retargeted. + */ +export const settlePending = ( + queue: PendingMissionQueue, + pushed: PendingCloudMission, + cloudId: string +): PendingMissionQueue => { + const stored = queue[pushed.clientId] + if (!stored || stored.revision === pushed.revision) return removePending(queue, pushed.clientId) + return { ...queue, [pushed.clientId]: { ...stored, cloudId } } +} + +/** + * Records a failed flush attempt, dropping the entry once it has failed `maxAttempts` times so a permanently + * rejected operation can't wedge the queue forever. + * @param {PendingMissionQueue} queue - Current queue. + * @param {string} clientId - Client id of the entry that failed. + * @param {number} maxAttempts - Attempt count at which the entry is dropped. + * @returns {PendingMissionQueue} The queue with the attempt counted or the entry dropped. + */ +export const registerFailedAttempt = ( + queue: PendingMissionQueue, + clientId: string, + maxAttempts: number +): PendingMissionQueue => { + const existing = queue[clientId] + if (!existing) return queue + const attempts = existing.attempts + 1 + if (attempts >= maxAttempts) return removePending(queue, clientId) + return { ...queue, [clientId]: { ...existing, attempts } } +} + +/** + * Lists the missions still awaiting synchronization. + * @param {PendingMissionQueue} queue - Current queue. + * @returns {PendingCloudMission[]} The pending missions. + */ +export const pendingMissions = (queue: PendingMissionQueue): PendingCloudMission[] => Object.values(queue) diff --git a/src/libs/blueos-cloud/types.ts b/src/libs/blueos-cloud/types.ts index 9e092bf223..985f731c18 100644 --- a/src/libs/blueos-cloud/types.ts +++ b/src/libs/blueos-cloud/types.ts @@ -97,3 +97,63 @@ export interface TokenResponse { */ expires_in: number } + +/** + * Mission representation as returned by the BlueOS Cloud API. + */ +export interface BlueOsCloudMission { + /** + * Unique mission identifier. + */ + id: string + /** + * Mission title shown on the cloud UI. + */ + title: string + /** + * Optional mission description. + */ + description: string + /** + * ISO timestamp of when the mission started. + */ + start_time: string | null + /** + * ISO timestamp of when the mission ended. + */ + end_time: string | null + /** + * Identifier of the user that created the mission. + */ + created_by: number | null + /** + * Decimal latitude of the mission start (string to preserve precision). + */ + start_latitude: string | null + /** + * Decimal longitude of the mission start (string to preserve precision). + */ + start_longitude: string | null +} + +/** + * Generic paginated payload used by the BlueOS Cloud API. + */ +export interface BlueOsCloudPaginatedResponse { + /** + * Total number of items across all pages. + */ + count: number + /** + * URL of the next page, or `null` when there are no more pages. + */ + next: string | null + /** + * URL of the previous page, or `null` when on the first page. + */ + previous: string | null + /** + * Items contained in the current page. + */ + results: T[] +} diff --git a/src/stores/blueOsCloud.ts b/src/stores/blueOsCloud.ts index e142a5712e..b4e47080ee 100644 --- a/src/stores/blueOsCloud.ts +++ b/src/stores/blueOsCloud.ts @@ -1,10 +1,80 @@ import { StorageSerializers, useStorage } from '@vueuse/core' import { defineStore } from 'pinia' -import { computed } from 'vue' +import { v4 as uuid } from 'uuid' +import { computed, onScopeDispose, ref, watch } from 'vue' import { useBlueOsStorage } from '@/composables/settingsSyncer' +import { openSnackbar } from '@/composables/snackbar' +import { + BlueOsCloudApiError, + createMission, + fetchMissions, + isPermanentApiError, + updateMission, +} from '@/libs/blueos-cloud/api' import { fetchAuthenticatedUser, isTokenValid, refreshAccessToken } from '@/libs/blueos-cloud/auth' -import { BlueOsCloudTokens, BlueOsCloudUser } from '@/libs/blueos-cloud/types' +import { + type PendingCloudMission, + type PendingMissionQueue, + enqueueCreate, + enqueueUpdate, + findPending, + pendingMissions, + registerFailedAttempt, + settlePending, +} from '@/libs/blueos-cloud/mission-sync-queue' +import { BlueOsCloudMission, BlueOsCloudTokens, BlueOsCloudUser } from '@/libs/blueos-cloud/types' + +// Drop a mission sync operation after this many rejections by the server so a permanently rejected op can't wedge +// the queue. Failures that never reached the server (offline) don't count, or field work would be thrown away. +const MAX_MISSION_SYNC_ATTEMPTS = 5 + +// Backoff before retrying the queue after a failed flush (e.g. still offline). +const MISSION_SYNC_RETRY_MS = 30_000 + +/** + * Fields for creating a new BlueOS Cloud mission from the session. + */ +interface StartCloudMissionInput { + /** + * Mission title. + */ + name: string + /** + * Optional mission description. + */ + description?: string + /** + * Start latitude in decimal degrees. + */ + latitude?: number | null + /** + * Start longitude in decimal degrees. + */ + longitude?: number | null +} + +/** + * Fields to change on the currently linked BlueOS Cloud mission. + */ +interface UpdateLinkedMissionInput { + /** + * New mission title. + */ + name?: string + /** + * New mission description. + */ + description?: string + /** + * New start latitude in decimal degrees. + */ + latitude?: number | null + /** + * New start longitude in decimal degrees. + */ + longitude?: number | null +} export const useBlueOsCloudStore = defineStore('blueOsCloud', () => { const isIntegrationEnabled = useBlueOsStorage('cockpit-blueos-cloud-enabled', false) @@ -15,31 +85,119 @@ export const useBlueOsCloudStore = defineStore('blueOsCloud', () => { serializer: StorageSerializers.object, }) + const missions = ref([]) + const isLoadingMissions = ref(false) + const lastError = ref(null) + + /** + * Identifier of the cloud mission currently linked to the active Cockpit session, set from the mission + * configuration dialog. Upload pickers read this to auto-select the right mission by default. + */ + const linkedMissionId = useStorage('cockpit-blueos-cloud-linked-mission-id', null, undefined, { + serializer: StorageSerializers.object, + }) + + // Persistent queue of mission mutations awaiting internet, so offline field work is replayed once online. + const missionSyncQueue = useStorage('cockpit-blueos-cloud-mission-queue-v1', {}, undefined, { + serializer: StorageSerializers.object, + }) + + // Mission-cycle stamp (the mission start-time epoch) captured when the cloud mission was linked, so the link + // is considered active only during that cycle; a new cycle (6h idle / new day) prompts a fresh select/create. + const linkedMissionCycleId = useStorage('cockpit-blueos-cloud-linked-mission-cycle', null, undefined, { + serializer: StorageSerializers.object, + }) + + // Last known details of the linked mission. The mission list is fetched, so it is empty until something asks for + // it: without this, a reload would show the linked mission as untitled and unlocated, and editing it from there + // would erase its description and start position on the cloud. + const cachedLinkedMission = useStorage( + 'cockpit-blueos-cloud-linked-mission-v1', + null, + undefined, + { serializer: StorageSerializers.object } + ) + + const fetchedLinkedMission = computed(() => { + const missionRef = linkedMissionId.value + if (!missionRef) return null + return missions.value.find((mission) => mission.id === missionRef) ?? null + }) + + watch(fetchedLinkedMission, (mission) => { + if (mission) cachedLinkedMission.value = mission + }) + + const patchedCoordinate = (patched: number | null | undefined, base: string | null): string | null => { + if (patched === undefined) return base + return patched === null ? null : String(patched) + } + + // A queued mutation is newer than both the fetched list and the cache, so it is overlaid on whichever of them + // answered instead of replacing it: a patch carries only the fields the user changed. + const linkedMission = computed(() => { + const missionRef = linkedMissionId.value + if (!missionRef) return null + const cached = cachedLinkedMission.value?.id === missionRef ? cachedLinkedMission.value : null + const base = fetchedLinkedMission.value ?? cached + const pending = findPending(missionSyncQueue.value, missionRef) + if (!pending) return base + return { + id: base?.id ?? pending.clientId, + title: pending.title ?? base?.title ?? '', + description: pending.description ?? base?.description ?? '', + start_time: base?.start_time ?? null, + end_time: base?.end_time ?? null, + created_by: base?.created_by ?? null, + start_latitude: patchedCoordinate(pending.latitude, base?.start_latitude ?? null), + start_longitude: patchedCoordinate(pending.longitude, base?.start_longitude ?? null), + } + }) + const isAuthenticated = computed(() => !!tokens.value && !!user.value) + // Whether the linked mission already exists on BlueOS Cloud (vs. a locally-created one still awaiting sync). + // Read from the queue rather than from the fetched list, so it stays right before any list is loaded. + const isLinkedMissionSynced = computed( + () => !!linkedMissionId.value && !findPending(missionSyncQueue.value, linkedMissionId.value) + ) + const displayName = computed(() => { const currentUser = user.value if (!currentUser) return '' return currentUser.name || currentUser.nickname || currentUser.email || currentUser.sub }) + let retryTimer: ReturnType | null = null + /** * Clears persisted tokens and the cached user profile, effectively logging the user out locally. */ const clearSession = (): void => { + if (retryTimer) { + clearTimeout(retryTimer) + retryTimer = null + } tokens.value = null user.value = null + missions.value = [] + linkedMissionId.value = null + linkedMissionCycleId.value = null + cachedLinkedMission.value = null + // The sync queue is deliberately kept: it holds work the user was told was saved, and it is replayed on sign-in. } /** * Stores a freshly issued token bundle and refreshes the cached user profile from Auth0. * - * Used both at the end of the device-flow login wizard and after a successful refresh-token exchange. + * Used both at the end of the device-flow login wizard and after a successful refresh-token exchange, so it is + * also where anything left queued by a previous session gets its first chance to be uploaded. * @param {BlueOsCloudTokens} newTokens - Token bundle to persist. */ const persistSession = async (newTokens: BlueOsCloudTokens): Promise => { tokens.value = newTokens user.value = await fetchAuthenticatedUser(newTokens.accessToken) + void flushMissionSyncQueue() } // Shared in-flight refresh so concurrent callers exchange the refresh token only once. @@ -70,7 +228,9 @@ export const useBlueOsCloudStore = defineStore('blueOsCloud', () => { return refreshed.accessToken }) .catch((error) => { - clearSession() + // Only a refusal from Auth0 means the session is really over; a refresh that couldn't be attempted (no + // internet) must keep the user signed in, or field work is logged out for being offline. + if (isPermanentApiError(error)) clearSession() throw error }) .finally(() => { @@ -81,14 +241,275 @@ export const useBlueOsCloudStore = defineStore('blueOsCloud', () => { return refreshPromise } + /** + * Refreshes the cached list of cloud missions. + * + * Mutates `missions`, `isLoadingMissions` and `lastError` so the UI can react to the load lifecycle. + * @returns {Promise} The updated list of missions. + */ + const refreshMissions = async (): Promise => { + isLoadingMissions.value = true + lastError.value = null + try { + const accessToken = await ensureValidAccessToken() + const fetched = await fetchMissions(accessToken) + missions.value = fetched + return fetched + } catch (error) { + lastError.value = (error as Error).message + throw error + } finally { + isLoadingMissions.value = false + } + } + + let isFlushingQueue = false + + const scheduleQueueRetry = (): void => { + if (retryTimer) return + retryTimer = setTimeout(() => { + retryTimer = null + void flushMissionSyncQueue() + }, MISSION_SYNC_RETRY_MS) + } + + // Inserts rather than only replacing: the list is empty until something fetches it, so a flush right after a + // restart would otherwise drop the record the server just returned, along with the cache written from it. + const upsertMission = (mission: BlueOsCloudMission): void => { + missions.value = [mission, ...missions.value.filter((existing) => existing.id !== mission.id)] + } + + // An edit saved mid-request survives the push that was already in flight, but the running flush walks a snapshot + // taken before it, so the entry it leaves behind needs a retry of its own to ever be uploaded. + const settleSyncedMission = (mission: PendingCloudMission, cloudId: string): void => { + missionSyncQueue.value = settlePending(missionSyncQueue.value, mission, cloudId) + if (mission.clientId in missionSyncQueue.value) scheduleQueueRetry() + } + + /** + * Creates a queued mission on the cloud and reconciles the local state with the id the server assigned: the + * created mission enters the list, a session link pointing at the local id follows it, and the entry leaves + * the queue. + * @param {PendingCloudMission} mission - Queued mission to create. + * @param {string} accessToken - Valid BlueOS Cloud access token. + */ + const createAndReconcile = async (mission: PendingCloudMission, accessToken: string): Promise => { + const created = await createMission( + { + name: mission.title ?? '', + description: mission.description, + latitude: mission.latitude ?? null, + longitude: mission.longitude ?? null, + startTime: mission.startTime, + }, + accessToken + ) + upsertMission(created) + if (linkedMissionId.value === mission.clientId) linkedMissionId.value = created.id + settleSyncedMission(mission, created.id) + } + + /** + * Tells the user a queued operation was given up on, and clears the link when it was the mission's own creation + * that was refused, so the dialog stops offering a mission that will never be uploaded. A refused update names a + * mission the cloud is still serving, so that one keeps its link and loses only the edit. + * @param {PendingCloudMission} mission - Mission that was dropped from the queue. + */ + const announceDroppedMission = (mission: PendingCloudMission): void => { + const wasNeverUploaded = mission.cloudId === null + if (wasNeverUploaded && linkedMissionId.value === mission.clientId) linkedMissionId.value = null + const missionName = mission.title ?? mission.clientId + const rejected = wasNeverUploaded + ? `mission "${missionName}", so it will not be uploaded` + : `the changes to mission "${missionName}", which stays linked but keeps its previous details` + openSnackbar({ + message: `BlueOS Cloud rejected ${rejected}.`, + variant: 'error', + duration: 6000, + closeButton: true, + }) + } + + /** + * Pushes a single queued mission to BlueOS Cloud, counting a server refusal against its attempt budget. + * @param {PendingCloudMission} mission - Queued mission to push. + * @param {string} accessToken - Valid BlueOS Cloud access token. + * @returns {Promise<'synced' | 'retry' | 'dropped'>} Whether the mission is now on the cloud, should be retried + * later, or exhausted its attempts and left the queue unsent. + */ + const syncPendingMission = async ( + mission: PendingCloudMission, + accessToken: string + ): Promise<'synced' | 'retry' | 'dropped'> => { + const missionName = mission.title ?? mission.clientId + try { + if (mission.cloudId === null) { + await createAndReconcile(mission, accessToken) + } else { + const updated = await updateMission( + mission.cloudId, + { + name: mission.title, + description: mission.description, + latitude: mission.latitude, + longitude: mission.longitude, + }, + accessToken + ) + upsertMission(updated) + settleSyncedMission(mission, mission.cloudId) + } + return 'synced' + } catch (opError) { + // The mission was deleted on the cloud since we linked it: re-create it so the local mission is restored. + const missionWasDeleted = + mission.cloudId !== null && opError instanceof BlueOsCloudApiError && opError.status === 404 + if (missionWasDeleted) { + try { + await createAndReconcile(mission, accessToken) + console.warn(`[BlueOsCloud] Mission '${missionName}' no longer existed on the cloud and was re-created.`) + return 'synced' + } catch (recreateError) { + // Deliberately falls through to the accounting below, which judges the original 404: a mission the server + // says is gone and then refuses to re-create is a refusal, and should spend an attempt like any other. + console.error(`[BlueOsCloud] Failed to re-create missing BlueOS Cloud mission. ${recreateError}`) + } + } else { + console.error(`[BlueOsCloud] Failed to sync mission '${missionName}'. ${opError}`) + } + if (!isPermanentApiError(opError)) return 'retry' + const remaining = registerFailedAttempt(missionSyncQueue.value, mission.clientId, MAX_MISSION_SYNC_ATTEMPTS) + const wasDropped = !(mission.clientId in remaining) + missionSyncQueue.value = remaining + return wasDropped ? 'dropped' : 'retry' + } + } + + /** + * Replays queued mission mutations against BlueOS Cloud, reconciling locally-created missions with the ids the + * server assigns. Safe to call repeatedly; it no-ops while offline, unauthenticated, or already flushing, and + * schedules a retry when an operation fails so field work eventually syncs once the internet is reachable. + * @returns {Promise} Resolves once a flush pass finishes (successfully or by deferring for retry). + */ + const flushMissionSyncQueue = async (): Promise => { + if (isFlushingQueue || !isAuthenticated.value) return + if (pendingMissions(missionSyncQueue.value).length === 0) return + isFlushingQueue = true + try { + const accessToken = await ensureValidAccessToken() + for (const mission of pendingMissions(missionSyncQueue.value)) { + const outcome = await syncPendingMission(mission, accessToken) + if (outcome === 'synced') continue + if (outcome === 'dropped') announceDroppedMission(mission) + // ponytail: abort the flush after the first failure; later queue entries wait for the scheduled retry. + // Upgrade: keep walking the remaining missions in this pass and collect failures. + scheduleQueueRetry() + return + } + } catch { + // Could not obtain a valid token (offline or session expired); keep the queue and retry later. + scheduleQueueRetry() + } finally { + isFlushingQueue = false + } + } + + /** + * Starts a brand-new mission on BlueOS Cloud and links it to the session. Works offline: the mission is queued + * with a local id and created on the cloud once the internet is reachable. + * @param {StartCloudMissionInput} input - New mission data. + * @param {number} cycleId - Mission-cycle stamp (mission start-time epoch) to associate the link with, and the + * start time the mission is created with on the cloud. + * @returns {string} The local client id now linked to the session. + */ + const startCloudMission = (input: StartCloudMissionInput, cycleId: number): string => { + const clientId = uuid() + missionSyncQueue.value = enqueueCreate(missionSyncQueue.value, { + clientId, + title: input.name, + description: input.description, + latitude: input.latitude ?? null, + longitude: input.longitude ?? null, + startTime: cycleId, + }) + linkedMissionId.value = clientId + linkedMissionCycleId.value = cycleId + void flushMissionSyncQueue() + return clientId + } + + /** + * Links an already-existing cloud mission to the current session cycle. + * @param {string} missionId - Cloud mission id to link. + * @param {number} cycleId - Mission-cycle stamp (mission start-time epoch) to associate the link with. + */ + const linkExistingMission = (missionId: string, cycleId: number): void => { + linkedMissionId.value = missionId + linkedMissionCycleId.value = cycleId + } + + /** + * Unlinks the current cloud mission so the next session starts fresh (used when finishing a mission). + */ + const finishMission = (): void => { + linkedMissionId.value = null + linkedMissionCycleId.value = null + } + + /** + * Clears only the cycle link so this session has no active cloud mission, while keeping the last mission id + * available for "continue previous". + */ + const clearMissionCycleLink = (): void => { + linkedMissionCycleId.value = null + } + + /** + * Queues an update (rename, relocate and/or re-describe) for the currently linked mission, coalescing with any + * pending create/update for it. Works offline. + * @param {UpdateLinkedMissionInput} input - Fields to change. + */ + const updateLinkedMission = (input: UpdateLinkedMissionInput): void => { + const missionRef = linkedMissionId.value + if (!missionRef) return + missionSyncQueue.value = enqueueUpdate(missionSyncQueue.value, missionRef, { + // Always carry the title so a later re-create (if the cloud mission was deleted) keeps the mission name. + title: input.name ?? linkedMission.value?.title, + description: input.description, + latitude: input.latitude, + longitude: input.longitude, + }) + void flushMissionSyncQueue() + } + + // Replay the queue whenever the browser regains connectivity, and once now for anything left from a past session. + const replayQueueOnReconnection = (): void => void flushMissionSyncQueue() + window.addEventListener('online', replayQueueOnReconnection) + onScopeDispose(() => window.removeEventListener('online', replayQueueOnReconnection)) + void flushMissionSyncQueue() + return { isIntegrationEnabled, tokens, user, + missions, + isLoadingMissions, + lastError, + linkedMissionId, + linkedMissionCycleId, + linkedMission, + isLinkedMissionSynced, isAuthenticated, displayName, persistSession, clearSession, ensureValidAccessToken, + refreshMissions, + startCloudMission, + linkExistingMission, + finishMission, + clearMissionCycleLink, + updateLinkedMission, + flushMissionSyncQueue, } }) diff --git a/src/stores/mission.ts b/src/stores/mission.ts index 0c8ff71568..c0e3e99962 100644 --- a/src/stores/mission.ts +++ b/src/stores/mission.ts @@ -58,7 +58,6 @@ export const useMissionStore = defineStore('mission', () => { 'cockpit-slide-events-categories-required', eventCategoriesDefaultMapping ) - const lastMissionName = useStorage('cockpit-last-mission-name', '') const missionStartTime = useStorage('cockpit-mission-start-time', new Date()) const defaultMapCenter = useBlueOsStorage('cockpit-default-map-center', DEFAULT_MAP_CENTER) const defaultMapZoom = useBlueOsStorage('cockpit-default-map-zoom', DEFAULT_MAP_ZOOM) @@ -185,11 +184,6 @@ export const useMissionStore = defineStore('mission', () => { // (built-in base maps are tracked separately by `userLastMapTileProvider`). Null when a built-in map is active. const userLastCustomMapProviderId = useBlueOsStorage('cockpit-user-last-custom-map-provider-id', null) - // Only remember user-typed names so the mission-name restore button never brings back an automatic name. - watch(missionName, () => { - if (!missionNameIsAutomatic.value) lastMissionName.value = missionName.value - }) - const applyMissionName = ( name: string, options: { @@ -869,7 +863,6 @@ export const useMissionStore = defineStore('mission', () => { missionName, missionNameIsAutomatic, applyMissionName, - lastMissionName, missionStartTime, currentPlanningWaypoints, currentPlanningSurveys, diff --git a/src/tests/libs/blueos-cloud/mission-list.test.ts b/src/tests/libs/blueos-cloud/mission-list.test.ts new file mode 100644 index 0000000000..33999092c6 --- /dev/null +++ b/src/tests/libs/blueos-cloud/mission-list.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' + +import { filterAndSortMissions, formatMissionMeta } from '@/libs/blueos-cloud/mission-list' +import type { BlueOsCloudMission } from '@/libs/blueos-cloud/types' + +const mission = (overrides: Partial): BlueOsCloudMission => ({ + id: 'id', + title: 'Untitled mission', + description: '', + start_time: null, + end_time: null, + created_by: null, + start_latitude: null, + start_longitude: null, + ...overrides, +}) + +const reefSurvey = mission({ + id: 'reef', + title: 'Reef survey', + description: 'Testing Cockpit/Cloud integration', + start_time: '2026-08-04T12:00:00Z', + start_latitude: '19.641100', + start_longitude: '-156.007600', +}) + +const hullInspection = mission({ + id: 'hull', + title: 'Hull inspection', + start_time: '2026-07-29T12:00:00Z', +}) + +const undated = mission({ id: 'undated', title: 'Auto upload check', start_time: 'not a date' }) + +describe('BlueOS Cloud mission list', () => { + it('sorts by start time, treating missions without a usable one as the oldest', () => { + const missions = [hullInspection, undated, reefSurvey] + + expect(filterAndSortMissions(missions, '', 'newest').map((m) => m.id)).toEqual(['reef', 'hull', 'undated']) + expect(filterAndSortMissions(missions, '', 'oldest').map((m) => m.id)).toEqual(['undated', 'hull', 'reef']) + }) + + it('sorts by name', () => { + expect(filterAndSortMissions([reefSurvey, hullInspection], '', 'name').map((m) => m.id)).toEqual(['hull', 'reef']) + }) + + it('matches the query against name, description and location', () => { + const missions = [reefSurvey, hullInspection] + + expect(filterAndSortMissions(missions, 'reef', 'newest').map((m) => m.id)).toEqual(['reef']) + expect(filterAndSortMissions(missions, 'integration', 'newest').map((m) => m.id)).toEqual(['reef']) + expect(filterAndSortMissions(missions, '-156.00', 'newest').map((m) => m.id)).toEqual(['reef']) + expect(filterAndSortMissions(missions, 'HULL', 'newest').map((m) => m.id)).toEqual(['hull']) + expect(filterAndSortMissions(missions, 'reef hull', 'newest')).toEqual([]) + }) + + it('matches the query against the start date as it is displayed', () => { + const displayedDate = new Date(reefSurvey.start_time as string).toLocaleString() + + expect(filterAndSortMissions([reefSurvey, hullInspection], displayedDate, 'newest').map((m) => m.id)).toEqual([ + 'reef', + ]) + }) + + it('keeps the whole list when the query is empty and never mutates the input', () => { + const missions = [hullInspection, reefSurvey] + + expect(filterAndSortMissions(missions, ' ', 'newest')).toHaveLength(2) + expect(missions.map((m) => m.id)).toEqual(['hull', 'reef']) + }) + + it('formats the metadata shown on a row, falling back when there is none', () => { + expect(formatMissionMeta(reefSurvey)).toContain('19.6411, -156.0076') + expect(formatMissionMeta(mission({}))).toBe('No metadata') + expect(formatMissionMeta(mission({ start_latitude: 'unknown', start_longitude: '1.0' }))).toBe('No metadata') + }) +}) diff --git a/src/tests/libs/blueos-cloud/mission-sync-queue.test.ts b/src/tests/libs/blueos-cloud/mission-sync-queue.test.ts new file mode 100644 index 0000000000..1d95cc88a7 --- /dev/null +++ b/src/tests/libs/blueos-cloud/mission-sync-queue.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { BlueOsCloudApiError, isPermanentApiError } from '@/libs/blueos-cloud/api' +import { + type PendingMissionQueue, + enqueueCreate, + enqueueUpdate, + registerFailedAttempt, + settlePending, +} from '@/libs/blueos-cloud/mission-sync-queue' + +const queueWithOneCreate = (): PendingMissionQueue => + enqueueCreate({}, { clientId: 'local-1', title: 'Reef survey', latitude: null, longitude: null, startTime: 1 }) + +describe('BlueOS Cloud mission sync queue', () => { + it('only treats a server refusal as permanent, so offline failures never spend the attempt budget', () => { + expect(isPermanentApiError(new BlueOsCloudApiError('bad request', 400))).toBe(true) + expect(isPermanentApiError(new BlueOsCloudApiError('not found', 404))).toBe(true) + expect(isPermanentApiError(new BlueOsCloudApiError('timeout', 408))).toBe(false) + expect(isPermanentApiError(new BlueOsCloudApiError('too many requests', 429))).toBe(false) + expect(isPermanentApiError(new BlueOsCloudApiError('server error', 500))).toBe(false) + expect(isPermanentApiError(new TypeError('Failed to fetch'))).toBe(false) + }) + + it('counts attempts and drops the entry only once the budget is spent', () => { + let queue = queueWithOneCreate() + + queue = registerFailedAttempt(queue, 'local-1', 3) + expect(queue['local-1'].attempts).toBe(1) + + queue = registerFailedAttempt(queue, 'local-1', 3) + expect(queue['local-1'].attempts).toBe(2) + + queue = registerFailedAttempt(queue, 'local-1', 3) + expect(queue['local-1']).toBeUndefined() + }) + + it('ignores an attempt on an entry that is no longer queued', () => { + expect(registerFailedAttempt({}, 'gone', 3)).toEqual({}) + }) + + it('keeps an edit saved while the entry was being pushed, retargeted at the new cloud id', () => { + const pushed = queueWithOneCreate()['local-1'] + const editedMidFlight = enqueueUpdate(queueWithOneCreate(), 'local-1', { title: 'Reef survey, south wall' }) + + const settled = settlePending(editedMidFlight, pushed, 'cloud-1') + expect(settled['local-1'].title).toBe('Reef survey, south wall') + // Without the cloud id the next flush would create the mission a second time instead of updating it. + expect(settled['local-1'].cloudId).toBe('cloud-1') + + expect(settlePending(queueWithOneCreate(), pushed, 'cloud-1')).toEqual({}) + }) +}) diff --git a/src/tests/stores/blueOsCloud.test.ts b/src/tests/stores/blueOsCloud.test.ts new file mode 100644 index 0000000000..fcb763b272 --- /dev/null +++ b/src/tests/stores/blueOsCloud.test.ts @@ -0,0 +1,154 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick, ref } from 'vue' + +import { BlueOsCloudApiError, createMission, updateMission } from '@/libs/blueos-cloud/api' +import type { BlueOsCloudMission } from '@/libs/blueos-cloud/types' +import { useBlueOsCloudStore } from '@/stores/blueOsCloud' + +// The vehicle-synced settings backend is not what these tests are about, and it needs a live BlueOS to initialize. +vi.mock('@/composables/settingsSyncer', () => ({ useBlueOsStorage: (_key: string, value: unknown) => ref(value) })) + +vi.mock('@/libs/blueos-cloud/api', async () => ({ + ...(await vi.importActual('@/libs/blueos-cloud/api')), + createMission: vi.fn(), + updateMission: vi.fn(), +})) + +// Recent Node versions expose a localStorage global that jsdom does not replace and that throws on use, so the +// tests bring their own. +const entries = new Map() +Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + getItem: (key: string) => entries.get(key) ?? null, + setItem: (key: string, value: string) => entries.set(key, value), + removeItem: (key: string) => entries.delete(key), + clear: () => entries.clear(), + }, +}) + +const cloudMission: BlueOsCloudMission = { + id: 'cloud-1', + title: 'Reef survey', + description: 'North wall', + start_time: null, + end_time: null, + created_by: null, + start_latitude: '-27.5', + start_longitude: '-48.5', +} + +// A reload keeps only what is in local storage, so the mission list starts empty again. +const reloadStore = (): ReturnType => { + setActivePinia(createPinia()) + return useBlueOsCloudStore() +} + +const signIn = (store: ReturnType): void => { + store.tokens = { accessToken: 'token', refreshToken: null, expiresAt: Date.now() + 3_600_000 } + store.user = { sub: 'auth0|user' } +} + +describe('BlueOS Cloud linked mission', () => { + beforeEach(() => { + window.localStorage.clear() + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('still knows the linked mission after a reload, with no list fetched', async () => { + const store = useBlueOsCloudStore() + store.missions = [cloudMission] + store.linkExistingMission(cloudMission.id, 1) + await nextTick() + + const reloaded = reloadStore() + expect(reloaded.missions).toEqual([]) + expect(reloaded.linkedMission).toEqual(cloudMission) + expect(reloaded.isLinkedMissionSynced).toBe(true) + }) + + it('does not offer the remembered mission once another one is linked', async () => { + const store = useBlueOsCloudStore() + store.missions = [cloudMission] + store.linkExistingMission(cloudMission.id, 1) + await nextTick() + + const reloaded = reloadStore() + reloaded.linkExistingMission('cloud-2', 1) + expect(reloaded.linkedMission).toBeNull() + }) + + it('shows a queued offline edit rather than the stale fetched mission', () => { + const store = useBlueOsCloudStore() + store.missions = [cloudMission] + store.linkExistingMission(cloudMission.id, 1) + + store.updateLinkedMission({ name: 'Reef survey, south wall' }) + + expect(store.linkedMission?.title).toBe('Reef survey, south wall') + // Fields the edit never carried keep the values the cloud returned, so a later edit can't blank them. + expect(store.linkedMission?.description).toBe(cloudMission.description) + expect(store.linkedMission?.start_latitude).toBe(cloudMission.start_latitude) + }) + + it('keeps showing the edit after it syncs on a start with no list fetched', async () => { + const store = useBlueOsCloudStore() + store.missions = [cloudMission] + store.linkExistingMission(cloudMission.id, 1) + await nextTick() + store.updateLinkedMission({ name: 'Reef survey, south wall' }) + await nextTick() + + const editedMission = { ...cloudMission, title: 'Reef survey, south wall' } + vi.mocked(updateMission).mockResolvedValue(editedMission) + + const reloaded = reloadStore() + signIn(reloaded) + await reloaded.flushMissionSyncQueue() + + expect(reloaded.linkedMission).toEqual(editedMission) + }) + + it('reports a mission created offline as not yet synced', () => { + const store = useBlueOsCloudStore() + const clientId = store.startCloudMission({ name: 'Hull inspection' }, 1) + + expect(store.linkedMission?.title).toBe('Hull inspection') + expect(store.isLinkedMissionSynced).toBe(false) + expect(clientId).not.toBe('') + }) + + it('uploads a mission created offline with the time it started, not the time it reached the cloud', async () => { + const startedAt = new Date('2026-08-19T09:00:00.000Z').getTime() + const store = useBlueOsCloudStore() + store.startCloudMission({ name: 'Hull inspection' }, startedAt) + await nextTick() + vi.mocked(createMission).mockResolvedValue({ ...cloudMission, title: 'Hull inspection' }) + + const reloaded = reloadStore() + signIn(reloaded) + await reloaded.flushMissionSyncQueue() + + expect(createMission).toHaveBeenCalledWith(expect.objectContaining({ startTime: startedAt }), 'token') + }) + + it('keeps the mission linked when the cloud gives up on an edit, since the mission itself is fine', async () => { + const store = useBlueOsCloudStore() + store.missions = [cloudMission] + store.linkExistingMission(cloudMission.id, 1) + store.updateLinkedMission({ name: 'Reef survey, south wall' }) + await nextTick() + + vi.mocked(updateMission).mockRejectedValue(new BlueOsCloudApiError('bad request', 400)) + const reloaded = reloadStore() + signIn(reloaded) + // One flush per attempt, since the queue backs off to its retry timer after each refusal. + for (let attempt = 0; attempt < 5; attempt++) await reloaded.flushMissionSyncQueue() + + // The edit was given up on, but the mission it targeted is on the cloud and stays linked. + expect(reloaded.isLinkedMissionSynced).toBe(true) + expect(reloaded.linkedMissionId).toBe(cloudMission.id) + }) +})