refactor: requests handling

This commit is contained in:
axolotle 2024-08-05 16:21:40 +02:00
parent cb344f28fc
commit 254e1aca56
5 changed files with 199 additions and 212 deletions

View file

@ -2,13 +2,15 @@
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { useStore } from 'vuex' import { useStore } from 'vuex'
import { useRequests } from '@/composables/useRequests'
import { useSettings } from '@/composables/useSettings'
import { useStoreGetters } from '@/store/utils' import { useStoreGetters } from '@/store/utils'
import { HistoryConsole, ViewLockOverlay } from '@/views/_partials' import { HistoryConsole, ViewLockOverlay } from '@/views/_partials'
import { useSettings } from '@/composables/useSettings'
const store = useStore() const store = useStore()
const { connected, yunohost, routerKey, waiting, ssoLink } = useStoreGetters() const { connected, yunohost, routerKey, ssoLink } = useStoreGetters()
const { locked } = useRequests()
const { spinner, dark, transitions, transitionName } = useSettings() const { spinner, dark, transitions, transitionName } = useSettings()
async function logout() { async function logout() {
@ -78,7 +80,7 @@ onMounted(() => {
<BNavbar> <BNavbar>
<BNavbarBrand <BNavbarBrand
:to="{ name: 'home' }" :to="{ name: 'home' }"
:disabled="waiting" :disabled="locked"
exact-active-class="active" exact-active-class="active"
> >
<span v-if="dark"> <span v-if="dark">

View file

@ -1,10 +1,5 @@
/** import { useRequests, type APIRequestAction } from '@/composables/useRequests'
* API module.
* @module api
*/
import { useSettings } from '@/composables/useSettings' import { useSettings } from '@/composables/useSettings'
import store from '@/store'
import type { Obj } from '@/types/commons' import type { Obj } from '@/types/commons'
import { APIUnauthorizedError, type APIError } from './errors' import { APIUnauthorizedError, type APIError } from './errors'
import { getError, getResponseData, openWebSocket } from './handlers' import { getError, getResponseData, openWebSocket } from './handlers'
@ -34,30 +29,7 @@ export type APIErrorData = {
error_key?: string error_key?: string
log_ref?: string log_ref?: string
traceback?: string traceback?: string
name?: string // FIXME name is field id right? name?: string
}
type RequestStatus = 'pending' | 'success' | 'warning' | 'error'
export type APIRequest = {
method: RequestMethod
uri: string
humanRouteKey: HumanKey['key']
humanRoute: string
initial: boolean
status: RequestStatus
}
type WebsocketMessage = {
text: string
status: 'info' | 'success' | 'warning' | 'error'
}
export type APIRequestAction = APIRequest & {
messages: WebsocketMessage[]
date: number
warning: number
errors: number
} }
/** /**
@ -131,21 +103,21 @@ export default {
asFormData = true, asFormData = true,
}: APIQuery): Promise<T> { }: APIQuery): Promise<T> {
const { locale } = useSettings() const { locale } = useSettings()
// `await` because Vuex actions returns promises by default. const { startRequest, endRequest } = useRequests()
const request: APIRequest = await store.dispatch('INIT_REQUEST', {
const request = startRequest({
method, method,
uri, uri,
humanKey, humanKey,
initial, initial,
wait: showModal, showModal,
websocket, websocket,
}) })
if (websocket) { if (websocket) {
await openWebSocket(request as APIRequestAction) await openWebSocket(request as APIRequestAction)
} }
let options = this.options let options = { ...this.options }
if (method === 'GET') { if (method === 'GET') {
uri += `${uri.includes('?') ? '&' : '?'}locale=${locale.value}` uri += `${uri.includes('?') ? '&' : '?'}locale=${locale.value}`
} else { } else {
@ -160,7 +132,7 @@ export default {
const response = await fetch('/yunohost/api/' + uri, options) const response = await fetch('/yunohost/api/' + uri, options)
const responseData = await getResponseData(response) const responseData = await getResponseData(response)
store.dispatch('END_REQUEST', { request, success: response.ok, wait }) endRequest({ request, success: response.ok })
if (!response.ok) { if (!response.ok) {
throw getError(request, response, responseData as string | APIErrorData) throw getError(request, response, responseData as string | APIErrorData)

View file

@ -3,10 +3,15 @@
* @module api/handlers * @module api/handlers
*/ */
import store from '@/store' import errors, { APIError } from '@/api/errors'
import errors, { APIError } from './errors' import {
STATUS_VARIANT,
type APIRequest,
type APIRequestAction,
} from '@/composables/useRequests'
import { toEntries } from '@/helpers/commons'
import type { Obj } from '@/types/commons' import type { Obj } from '@/types/commons'
import type { APIErrorData, APIRequest, APIRequestAction } from './api' import type { APIErrorData } from './api'
/** /**
* Try to get response content as json and if it's not as text. * Try to get response content as json and if it's not as text.
@ -39,9 +44,26 @@ export function openWebSocket(request: APIRequestAction): Promise<Event> {
`wss://${store.getters.host}/yunohost/api/messages`, `wss://${store.getters.host}/yunohost/api/messages`,
) )
ws.onmessage = ({ data }) => { ws.onmessage = ({ data }) => {
store.dispatch('DISPATCH_MESSAGE', { const messages: Record<'info' | 'success' | 'warning' | 'error', string> =
request, JSON.parse(data)
messages: JSON.parse(data), toEntries(messages).forEach(([status, text]) => {
text = text.replaceAll('\n', '<br>')
const progressBar = text.match(/^\[#*\+*\.*\] > /)?.[0]
if (progressBar) {
text = text.replace(progressBar, '')
const progress: Obj<number> = { '#': 0, '+': 0, '.': 0 }
for (const char of progressBar) {
if (char in progress) progress[char] += 1
}
request.action.progress = Object.values(progress)
}
request.action.messages.push({
text,
variant: STATUS_VARIANT[status],
})
if (['error', 'warning'].includes(status)) {
request.action[`${status as 'error' | 'warning'}s`]++
}
}) })
} }
// ws.onclose = (e) => {} // ws.onclose = (e) => {}

View file

@ -0,0 +1,157 @@
import { createGlobalState } from '@vueuse/core'
import { v4 as uuid } from 'uuid'
import { computed, reactive, shallowRef } from 'vue'
import type { APIQuery, RequestMethod } from '@/api/api'
import { type APIError } from '@/api/errors'
import { isObjectLiteral } from '@/helpers/commons'
import i18n from '@/i18n'
import type { StateVariant } from '@/types/commons'
export type RequestStatus = 'pending' | 'success' | 'warning' | 'error'
export type APIRequest = {
status: RequestStatus
method: RequestMethod
uri: string
id: string
humanRoute: string
initial: boolean
date: number
err?: APIError
action?: APIActionProps
showModal?: boolean
}
type APIActionProps = {
messages: RequestMessage[]
errors: number
warnings: number
progress?: number[]
}
export type APIRequestAction = APIRequest & {
action: APIActionProps
}
export type RequestMessage = {
text: string
variant: StateVariant
}
export const STATUS_VARIANT = {
pending: 'primary',
success: 'success',
warning: 'warning',
error: 'danger',
info: 'info',
} as const
export const useRequests = createGlobalState(() => {
const requests = shallowRef<APIRequest[]>([])
const currentRequest = computed(() => {
return requests.value.find((r) => r.showModal)
})
const locked = computed(() => currentRequest.value?.showModal)
const historyList = computed<APIRequestAction[]>(() => {
return requests.value
.filter((r) => !!r.action || !!r.err)
.reverse() as APIRequestAction[]
})
function startRequest({
uri,
method,
humanKey,
initial,
websocket,
showModal,
}: {
uri: string
method: RequestMethod
humanKey?: APIQuery['humanKey']
showModal: boolean
websocket: boolean
initial: boolean
}): APIRequest {
// Try to find a description for an API route to display in history and modals
const { key, ...args } = isObjectLiteral(humanKey)
? humanKey
: { key: humanKey }
const humanRoute = key
? i18n.global.t(`human_routes.${key}`, args)
: `[${method}] /${uri.split('?')[0]}`
const request: APIRequest = reactive({
method,
uri,
status: 'pending',
humanRoute,
initial,
showModal: false,
id: uuid(),
date: Date.now(),
err: undefined,
action: websocket
? {
messages: [],
warnings: 0,
errors: 0,
}
: undefined,
})
requests.value = [...requests.value, request]
const r = requests.value[requests.value.length - 1]!
if (showModal) {
setTimeout(() => {
// Display the waiting modal only if the request takes some time.
if (r.status === 'pending') {
r.showModal = true
}
}, 300)
}
return r
}
function endRequest({
request,
success,
}: {
request: APIRequest
success: boolean
}) {
let status: RequestStatus = success ? 'success' : 'error'
let hideModal = success
if (success && request.action) {
const { warnings, errors, messages } = request.action
const msgCount = messages.length
if (msgCount && messages[msgCount - 1].variant === 'warning') {
hideModal = false
}
if (errors || warnings) status = 'warning'
}
setTimeout(() => {
request.status = status
if (request.showModal && hideModal) {
request.showModal = false
// We can remove requests that are not actions or has no errors
requests.value = requests.value.filter(
(r) => r.showModal || !!r.action || !!r.err,
)
}
}, 350)
}
return {
requests,
historyList,
currentRequest,
locked,
startRequest,
endRequest,
}
})

View file

@ -1,7 +1,7 @@
import router from '@/router' import router from '@/router'
import i18n from '@/i18n' import i18n from '@/i18n'
import api from '@/api' import api from '@/api'
import { timeout, isEmptyValue, isObjectLiteral } from '@/helpers/commons' import { timeout, isEmptyValue } from '@/helpers/commons'
export default { export default {
state: { state: {
@ -9,14 +9,8 @@ export default {
installed: null, installed: null,
connected: localStorage.getItem('connected') === 'true', // Boolean connected: localStorage.getItem('connected') === 'true', // Boolean
yunohost: null, // Object { version, repo } yunohost: null, // Object { version, repo }
waiting: false, // Boolean
reconnecting: null, // null|Object { attemps, delay, initialDelay } reconnecting: null, // null|Object { attemps, delay, initialDelay }
history: [], // Array of `request`
requests: [], // Array of `request`
currentRequest: null,
error: null, // null || request error: null, // null || request
historyTimer: null, // null || setTimeout id
tempMessages: [], // Array of messages
routerKey: undefined, // String if current route has params routerKey: undefined, // String if current route has params
breadcrumb: [], // Array of routes breadcrumb: [], // Array of routes
transitionName: null, // String of CSS class if transitions are enabled transitionName: null, // String of CSS class if transitions are enabled
@ -36,67 +30,10 @@ export default {
state.yunohost = yunohost state.yunohost = yunohost
}, },
SET_WAITING(state, boolean) {
state.waiting = boolean
},
SET_RECONNECTING(state, args) { SET_RECONNECTING(state, args) {
state.reconnecting = args state.reconnecting = args
}, },
SET_CURRENT_REQUEST(state, request) {
state.currentRequest = request
},
ADD_REQUEST(state, request) {
if (state.requests.length > 10) {
// We do not remove requests right after it resolves since an error might bring
// one back to life but we can safely remove some here.
state.requests.shift()
}
state.requests.push(request)
},
UPDATE_REQUEST(state, { key, value }) {
// This rely on data persistance and reactivity.
state.currentRequest[key] = value
},
REMOVE_REQUEST(state, request) {
const index = state.requests.lastIndexOf(request)
state.requests.splice(index, 1)
},
ADD_HISTORY_ACTION(state, request) {
state.history.push(request)
},
ADD_TEMP_MESSAGE(state, { request, message, type }) {
state.tempMessages.push([message, type])
},
UPDATE_DISPLAYED_MESSAGES(state) {
if (!state.tempMessages.length) {
state.historyTimer = null
return
}
const { messages, warnings, errors } = state.tempMessages.reduce(
(acc, [message, type]) => {
acc.messages.push(message)
if (['error', 'warning'].includes(type)) acc[type + 's']++
return acc
},
{ messages: [], warnings: 0, errors: 0 },
)
state.tempMessages = []
state.historyTimer = null
state.currentRequest.messages =
state.currentRequest.messages.concat(messages)
state.currentRequest.warnings += warnings
state.currentRequest.errors += errors
},
SET_ERROR(state, request) { SET_ERROR(state, request) {
if (request) { if (request) {
state.error = request state.error = request
@ -197,108 +134,6 @@ export default {
}) })
}, },
INIT_REQUEST(
{ commit },
{ method, uri, humanKey, initial, wait, websocket },
) {
// Try to find a description for an API route to display in history and modals
const { key, ...args } = isObjectLiteral(humanKey)
? humanKey
: { key: humanKey }
const humanRoute = key
? i18n.global.t('human_routes.' + key, args)
: `[${method}] /${uri}`
let request = {
method,
uri,
humanRouteKey: key,
humanRoute,
initial,
status: 'pending',
}
if (websocket) {
request = {
...request,
messages: [],
date: Date.now(),
warnings: 0,
errors: 0,
}
commit('ADD_HISTORY_ACTION', request)
}
commit('ADD_REQUEST', request)
commit('SET_CURRENT_REQUEST', request)
if (wait) {
setTimeout(() => {
// Display the waiting modal only if the request takes some time.
if (request.status === 'pending') {
commit('SET_WAITING', true)
}
}, 400)
}
return request
},
END_REQUEST({ state, commit }, { request, success, wait }) {
// Update last messages before finishing this request
clearTimeout(state.historyTimer)
commit('UPDATE_DISPLAYED_MESSAGES', { request })
let status = success ? 'success' : 'error'
if (success && (request.warnings || request.errors)) {
const messages = request.messages
if (
messages.length &&
messages[messages.length - 1].color === 'warning'
) {
state.currentRequest.showWarningMessage = true
}
status = 'warning'
}
commit('UPDATE_REQUEST', { request, key: 'status', value: status })
if (wait && !state.currentRequest.showWarningMessage) {
// Remove the overlay after a short delay to allow an error to display withtout flickering.
setTimeout(() => {
commit('SET_WAITING', false)
}, 100)
}
},
DISPATCH_MESSAGE({ state, commit, dispatch }, { request, messages }) {
for (const type in messages) {
const message = {
text: messages[type].replaceAll('\n', '<br>'),
color: type === 'error' ? 'danger' : type,
}
let progressBar = message.text.match(/^\[#*\+*\.*\] > /)
if (progressBar) {
progressBar = progressBar[0]
message.text = message.text.replace(progressBar, '')
const progress = { '#': 0, '+': 0, '.': 0 }
for (const char of progressBar) {
if (char in progress) progress[char] += 1
}
commit('UPDATE_REQUEST', {
request,
key: 'progress',
value: Object.values(progress),
})
}
if (message.text) {
// To avoid rendering lag issues, limit the flow of websocket messages to batches of 50ms.
if (state.historyTimer === null) {
state.historyTimer = setTimeout(() => {
commit('UPDATE_DISPLAYED_MESSAGES', { request })
}, 50)
}
commit('ADD_TEMP_MESSAGE', { request, message, type })
}
}
},
HANDLE_ERROR({ commit, dispatch }, error) { HANDLE_ERROR({ commit, dispatch }, error) {
if (error.code === 401) { if (error.code === 401) {
// Unauthorized // Unauthorized
@ -423,7 +258,6 @@ export default {
connected: (state) => state.connected, connected: (state) => state.connected,
yunohost: (state) => state.yunohost, yunohost: (state) => state.yunohost,
error: (state) => state.error, error: (state) => state.error,
waiting: (state) => state.waiting,
reconnecting: (state) => state.reconnecting, reconnecting: (state) => state.reconnecting,
history: (state) => state.history, history: (state) => state.history,
lastAction: (state) => state.history[state.history.length - 1], lastAction: (state) => state.history[state.history.length - 1],