Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Refactor] Desktop maintenance task runner #2311

Merged
merged 2 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions src/components/maintenance/TaskCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
>
<Card
class="max-w-48 relative h-full overflow-hidden"
:class="{ 'opacity-65': state.state !== 'error' }"
:class="{ 'opacity-65': runner.state !== 'error' }"
v-bind="(({ onClick, ...rest }) => rest)($attrs)"
>
<template #header>
<i
v-if="state.state === 'error'"
v-if="runner.state === 'error'"
class="pi pi-exclamation-triangle text-red-500 absolute m-2 top-0 -right-14 opacity-15"
style="font-size: 10rem"
/>
Expand Down Expand Up @@ -38,7 +38,7 @@
</Card>

<i
v-if="!isLoading && state.state === 'OK'"
v-if="!isLoading && runner.state === 'OK'"
class="task-card-ok pi pi-check"
/>
</div>
Expand All @@ -54,7 +54,7 @@ import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { useMinLoadingDurationRef } from '@/utils/refUtil'

const taskStore = useMaintenanceTaskStore()
const state = computed(() => taskStore.getState(props.task))
const runner = computed(() => taskStore.getRunner(props.task))

// Properties
const props = defineProps<{
Expand All @@ -68,14 +68,14 @@ defineEmits<{

// Bindings
const description = computed(() =>
state.value.state === 'error'
runner.value.state === 'error'
? props.task.errorDescription ?? props.task.shortDescription
: props.task.shortDescription
)

// Use a minimum run time to ensure tasks "feel" like they have run
const reactiveLoading = computed(() => state.value.refreshing)
const reactiveExecuting = computed(() => state.value.executing)
const reactiveLoading = computed(() => runner.value.refreshing)
const reactiveExecuting = computed(() => runner.value.executing)

const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
Expand Down
14 changes: 7 additions & 7 deletions src/components/maintenance/TaskListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
<tr
class="border-neutral-700 border-solid border-y"
:class="{
'opacity-50': state.state === 'resolved',
'opacity-75': isLoading && state.state !== 'resolved'
'opacity-50': runner.resolved,
'opacity-75': isLoading && runner.resolved
}"
>
<td class="text-center w-16">
<TaskListStatusIcon :state="state.state" :loading="isLoading" />
<TaskListStatusIcon :state="runner.state" :loading="isLoading" />
</td>
<td>
<p class="inline-block">{{ task.name }}</p>
Expand Down Expand Up @@ -51,7 +51,7 @@ import { useMinLoadingDurationRef } from '@/utils/refUtil'
import TaskListStatusIcon from './TaskListStatusIcon.vue'

const taskStore = useMaintenanceTaskStore()
const state = computed(() => taskStore.getState(props.task))
const runner = computed(() => taskStore.getRunner(props.task))

// Properties
const props = defineProps<{
Expand All @@ -65,14 +65,14 @@ defineEmits<{

// Binding
const severity = computed<VueSeverity>(() =>
state.value.state === 'error' || state.value.state === 'warning'
runner.value.state === 'error' || runner.value.state === 'warning'
? 'primary'
: 'secondary'
)

// Use a minimum run time to ensure tasks "feel" like they have run
const reactiveLoading = computed(() => state.value.refreshing)
const reactiveExecuting = computed(() => state.value.executing)
const reactiveLoading = computed(() => runner.value.refreshing)
const reactiveExecuting = computed(() => runner.value.executing)

const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
Expand Down
134 changes: 83 additions & 51 deletions src/stores/maintenanceTaskStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,77 @@ import { defineStore } from 'pinia'
import { computed, ref } from 'vue'

import { DESKTOP_MAINTENANCE_TASKS } from '@/constants/desktopMaintenanceTasks'
import type {
MaintenanceTask,
MaintenanceTaskState
} from '@/types/desktop/maintenanceTypes'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { electronAPI } from '@/utils/envUtil'

/** State of a maintenance task, managed by the maintenance task store. */
type MaintenanceTaskState = 'warning' | 'error' | 'OK' | 'skipped'

// Type not exported by API
type ValidationState = InstallValidation['basePath']
// Add index to API type
type IndexedUpdate = InstallValidation & Record<string, ValidationState>

/** State of a maintenance task, managed by the maintenance task store. */
export class MaintenanceTaskRunner {
constructor(readonly task: MaintenanceTask) {}

private _state?: MaintenanceTaskState
/** The current state of the task. Setter also controls {@link resolved} as a side-effect. */
get state() {
return this._state
}

/** Updates the task state and {@link resolved} status. */
setState(value: MaintenanceTaskState) {
// Mark resolved
if (this._state === 'error' && value === 'OK') this.resolved = true
// Mark unresolved (if previously resolved)
if (value === 'error') this.resolved &&= false

this._state = value
}

/** `true` if the task has been resolved (was `error`, now `OK`). This is a side-effect of the {@link state} setter. */
resolved?: boolean

/** Whether the task state is currently being refreshed. */
refreshing?: boolean
/** Whether the task is currently running. */
executing?: boolean
/** The error message that occurred when the task failed. */
error?: string

update(update: IndexedUpdate) {
const state = update[this.task.id]

this.refreshing = state === undefined
if (state) this.setState(state)
}

finaliseUpdate(update: IndexedUpdate) {
this.refreshing = false
this.setState(update[this.task.id] ?? 'skipped')
}

/** Wraps the execution of a maintenance task, updating state and rethrowing errors. */
async execute(task: MaintenanceTask) {
try {
this.executing = true
const success = await task.execute()
if (!success) return false

this.error = undefined
return true
} catch (error) {
this.error = (error as Error)?.message
throw error
} finally {
this.executing = false
}
}
}

/**
* User-initiated maintenance tasks. Currently only used by the desktop app maintenance view.
*
Expand All @@ -24,99 +89,62 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
const isRunningTerminalCommand = computed(() =>
tasks.value
.filter((task) => task.usesTerminal)
.some((task) => getState(task)?.executing)
.some((task) => getRunner(task)?.executing)
)
const isRunningInstallationFix = computed(() =>
tasks.value
.filter((task) => task.isInstallationFix)
.some((task) => getState(task)?.executing)
.some((task) => getRunner(task)?.executing)
)

// Task list
const tasks = ref(DESKTOP_MAINTENANCE_TASKS)

const taskStates = ref(
new Map<MaintenanceTask['id'], MaintenanceTaskState>(
DESKTOP_MAINTENANCE_TASKS.map((x) => [x.id, {}])
new Map<MaintenanceTask['id'], MaintenanceTaskRunner>(
DESKTOP_MAINTENANCE_TASKS.map((x) => [x.id, new MaintenanceTaskRunner(x)])
)
)

/** True if any tasks are in an error state. */
const anyErrors = computed(() =>
tasks.value.some((task) => getState(task).state === 'error')
tasks.value.some((task) => getRunner(task).state === 'error')
)

/** Wraps the execution of a maintenance task, updating state and rethrowing errors. */
const execute = async (task: MaintenanceTask) => {
const state = getState(task)

try {
state.executing = true
const success = await task.execute()
if (!success) return false

state.error = undefined
return true
} catch (error) {
state.error = (error as Error)?.message
throw error
} finally {
state.executing = false
}
}

/**
* Returns the matching state object for a task.
* @param task Task to get the matching state object for
* @returns The state object for this task
*/
const getState = (task: MaintenanceTask) => taskStates.value.get(task.id)!
const getRunner = (task: MaintenanceTask) => taskStates.value.get(task.id)!

/**
* Updates the task list with the latest validation state.
* @param validationUpdate Update details passed in by electron
*/
const processUpdate = (validationUpdate: InstallValidation) => {
// Type not exported by API
type ValidationState = InstallValidation['basePath']
// Add index to API type
type IndexedUpdate = InstallValidation & Record<string, ValidationState>

const update = validationUpdate as IndexedUpdate
isRefreshing.value = true

// Update each task state
for (const task of tasks.value) {
const state = getState(task)

state.refreshing = update[task.id] === undefined
// Mark resolved
if (state.state === 'error' && update[task.id] === 'OK')
state.state = 'resolved'
if (update[task.id] === 'OK' && state.state === 'resolved') continue

if (update[task.id]) state.state = update[task.id]
getRunner(task).update(update)
}

// Final update
if (!update.inProgress && isRefreshing.value) {
isRefreshing.value = false

for (const task of tasks.value) {
const state = getState(task)
state.refreshing = false
if (state.state === 'resolved') continue

state.state = update[task.id] ?? 'skipped'
getRunner(task).finaliseUpdate(update)
}
}
}

/** Clears the resolved status of tasks (when changing filters) */
const clearResolved = () => {
for (const task of tasks.value) {
const state = getState(task)
if (state?.state === 'resolved') state.state = 'OK'
getRunner(task).resolved &&= false
}
}

Expand All @@ -127,13 +155,17 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
await electron.Validation.validateInstallation(processUpdate)
}

const execute = async (task: MaintenanceTask) => {
return getRunner(task).execute(task)
}

return {
tasks,
isRefreshing,
isRunningTerminalCommand,
isRunningInstallationFix,
execute,
getState,
getRunner,
processUpdate,
clearResolved,
/** True if any tasks are in an error state. */
Expand Down
12 changes: 0 additions & 12 deletions src/types/desktop/maintenanceTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,6 @@ export interface MaintenanceTask {
isInstallationFix?: boolean
}

/** State of a maintenance task, managed by the maintenance task store. */
export interface MaintenanceTaskState {
/** The current state of the task. */
state?: 'warning' | 'error' | 'resolved' | 'OK' | 'skipped'
/** Whether the task state is currently being refreshed. */
refreshing?: boolean
/** Whether the task is currently running. */
executing?: boolean
/** The error message that occurred when the task failed. */
error?: string
}

/** The filter options for the maintenance task list. */
export interface MaintenanceFilter {
/** CSS classes used for the filter button icon, e.g. 'pi pi-cross' */
Expand Down
4 changes: 2 additions & 2 deletions src/views/MaintenanceView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,8 @@ const displayAsList = ref(PrimeIcons.TH_LARGE)

const errorFilter = computed(() =>
taskStore.tasks.filter((x) => {
const { state } = taskStore.getState(x)
return state === 'error' || state === 'resolved'
const { state, resolved } = taskStore.getRunner(x)
return state === 'error' || resolved
})
)

Expand Down
Loading