add sse messages handling

This commit is contained in:
axolotle 2023-06-09 15:27:48 +02:00
parent a3558f54e3
commit 0b1030a2fb
8 changed files with 85 additions and 20 deletions

View file

@ -81,7 +81,7 @@
<script> <script>
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import { connectSSE } from '@/api/handlers'
import { HistoryConsole, ViewLockOverlay } from '@/views/_partials' import { HistoryConsole, ViewLockOverlay } from '@/views/_partials'
export default { export default {
@ -118,6 +118,7 @@ export default {
// state will be automaticly reseted and user will be prompt with the login view. // state will be automaticly reseted and user will be prompt with the login view.
if (this.connected) { if (this.connected) {
this.$store.dispatch('GET_YUNOHOST_INFOS') this.$store.dispatch('GET_YUNOHOST_INFOS')
connectSSE()
} }
}, },

View file

@ -85,13 +85,10 @@ export default {
humanKey = null, humanKey = null,
{ wait = true, websocket = true, initial = false, asFormData = false } = {} { wait = true, websocket = true, initial = false, asFormData = false } = {}
) { ) {
// FIXME remove websocket mentions
// `await` because Vuex actions returns promises by default. // `await` because Vuex actions returns promises by default.
const request = await store.dispatch('INIT_REQUEST', { method, uri, humanKey, initial, wait, websocket }) const request = await store.dispatch('INIT_REQUEST', { method, uri, humanKey, initial, wait, websocket })
if (websocket) {
await openWebSocket(request)
}
let options = this.options let options = this.options
if (method === 'GET') { if (method === 'GET') {
uri += `${uri.includes('?') ? '&' : '?'}locale=${store.getters.locale}` uri += `${uri.includes('?') ? '&' : '?'}locale=${store.getters.locale}`

View file

@ -46,6 +46,17 @@ export function openWebSocket (request) {
}) })
} }
export function connectSSE () {
const host = store.getters.host.split(':')[0]
const evtSource = new EventSource(`https://${host}/yunohost/api/sse`)
evtSource.onmessage = (event) => {
store.dispatch('ON_SSE_MESSAGE', JSON.parse(atob(event.data)))
}
// FIXME handle 'onerror' hook
}
/** /**
* Handler for API errors. * Handler for API errors.

View file

@ -5,7 +5,7 @@
<!-- REQUEST DESCRIPTION --> <!-- REQUEST DESCRIPTION -->
<strong class="request-desc"> <strong class="request-desc">
{{ request.humanRoute }} {{ request.humanRoute || request.operationId }}
</strong> </strong>
<div v-if="request.errors || request.warnings"> <div v-if="request.errors || request.warnings">

View file

@ -17,6 +17,7 @@
"api": { "api": {
"partial_logs": "[...] (check in history for full logs)", "partial_logs": "[...] (check in history for full logs)",
"processing": "The server is processing the action...", "processing": "The server is processing the action...",
"external_action": "This action has been ran from another tab or from the cli",
"query_status": { "query_status": {
"error": "Unsuccessful", "error": "Unsuccessful",
"pending": "In progress", "pending": "In progress",

View file

@ -2,6 +2,7 @@ import Vue from 'vue'
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 { connectSSE } from '@/api/handlers'
import { timeout, isEmptyValue, isObjectLiteral } from '@/helpers/commons' import { timeout, isEmptyValue, isObjectLiteral } from '@/helpers/commons'
export default { export default {
@ -122,6 +123,7 @@ export default {
'CONNECT' ({ commit, dispatch }) { 'CONNECT' ({ commit, dispatch }) {
commit('SET_CONNECTED', true) commit('SET_CONNECTED', true)
connectSSE()
dispatch('GET_YUNOHOST_INFOS') dispatch('GET_YUNOHOST_INFOS')
}, },
@ -170,9 +172,21 @@ export default {
const { key, ...args } = isObjectLiteral(humanKey) ? humanKey : { key: humanKey } const { key, ...args } = isObjectLiteral(humanKey) ? humanKey : { key: humanKey }
const humanRoute = key ? i18n.t('human_routes.' + key, args) : `[${method}] /${uri}` const humanRoute = key ? i18n.t('human_routes.' + key, args) : `[${method}] /${uri}`
let request = { method, uri, humanRouteKey: key, humanRoute, initial, status: 'pending' } let request = {
method,
uri,
humanRouteKey: key,
humanRoute,
initial,
status: 'pending',
messages: [],
date: Date.now(),
operation_id: null, // will be given on sse start if request is an action
warnings: 0,
errors: 0,
external: false
}
if (websocket) { if (websocket) {
request = { ...request, messages: [], date: Date.now(), warnings: 0, errors: 0 }
commit('ADD_HISTORY_ACTION', request) commit('ADD_HISTORY_ACTION', request)
} }
commit('ADD_REQUEST', request) commit('ADD_REQUEST', request)
@ -197,6 +211,7 @@ export default {
if (success && (request.warnings || request.errors)) { if (success && (request.warnings || request.errors)) {
const messages = request.messages const messages = request.messages
if (messages.length && messages[messages.length - 1].color === 'warning') { if (messages.length && messages[messages.length - 1].color === 'warning') {
// request ends up with a warning, display it as a modal
request.showWarningMessage = true request.showWarningMessage = true
} }
status = 'warning' status = 'warning'
@ -211,10 +226,47 @@ export default {
} }
}, },
'DISPATCH_MESSAGE' ({ state, commit, dispatch }, { request, messages }) { 'START_EXTERNAL_ACTION' ({ state, commit, dispatch }, { timestamp, operationId }) {
for (const type in messages) { // Action triggered by another client/cli
const action = {
status: 'pending',
messages: [],
date: timestamp,
operationId,
warnings: 0,
errors: 0,
external: true
}
commit('ADD_HISTORY_ACTION', action)
setTimeout(() => {
// Display the waiting modal only if the request takes some time.
if (action.status === 'pending') {
commit('SET_WAITING', true)
}
}, 400)
return action
},
async 'ON_SSE_MESSAGE' ({ state, commit, dispatch }, data) {
let action = state.history.findLast((action) => action.operationId === data.operation_id)
if (!action) {
action = await dispatch('START_EXTERNAL_ACTION', { operationId: data.operation_id, timestamp: data.timestamp })
}
if (data.type === 'start') {
if (!action.external) {
commit('UPDATE_REQUEST', { request: action, key: 'operationId', value: data.operation_id })
}
} else if (data.type === 'end') {
// End request on this last message if the action was external (else default http response will end it)
if (action.external) {
dispatch('END_REQUEST', { request: action, success: data.success, wait: true })
}
} else {
const type = data.level.toLowerCase()
const message = { const message = {
text: messages[type].replaceAll('\n', '<br>'), text: data.msg.replaceAll('\n', '<br>'),
color: type === 'error' ? 'danger' : type color: type === 'error' ? 'danger' : type
} }
let progressBar = message.text.match(/^\[#*\+*\.*\] > /) let progressBar = message.text.match(/^\[#*\+*\.*\] > /)
@ -225,17 +277,18 @@ export default {
for (const char of progressBar) { for (const char of progressBar) {
if (char in progress) progress[char] += 1 if (char in progress) progress[char] += 1
} }
commit('UPDATE_REQUEST', { request, key: 'progress', value: Object.values(progress) }) Vue.set(action, 'progress', Object.values(progress))
} }
if (message.text) { if (message.text) {
// To avoid rendering lag issues, limit the flow of websocket messages to batches of 50ms. // To avoid rendering lag issues, limit the flow of websocket messages to batches of 50ms.
if (state.historyTimer === null) { if (state.historyTimer === null) {
state.historyTimer = setTimeout(() => { state.historyTimer = setTimeout(() => {
commit('UPDATE_DISPLAYED_MESSAGES', { request }) commit('UPDATE_DISPLAYED_MESSAGES', { request: action })
}, 50) }, 50)
} }
commit('ADD_TEMP_MESSAGE', { request, message, type }) commit('ADD_TEMP_MESSAGE', { request: action, message, type })
} }
action.messages.push(message)
} }
}, },
@ -358,8 +411,7 @@ export default {
history: state => state.history, history: state => state.history,
lastAction: state => state.history[state.history.length - 1], lastAction: state => state.history[state.history.length - 1],
currentRequest: state => { currentRequest: state => {
const request = state.requests.find(({ status }) => status === 'pending') return state.history.findLast(({ status }) => status === 'pending')
return request || state.requests[state.requests.length - 1]
}, },
routerKey: state => state.routerKey, routerKey: state => state.routerKey,
breadcrumb: state => state.breadcrumb, breadcrumb: state => state.breadcrumb,

View file

@ -2,7 +2,7 @@
<b-overlay <b-overlay
variant="white" opacity="0.75" variant="white" opacity="0.75"
no-center no-center
:show="waiting || reconnecting || error !== null" :show="(currentRequest && waiting) || reconnecting || error !== null"
> >
<slot name="default" /> <slot name="default" />
@ -42,11 +42,12 @@ export default {
if (error) { if (error) {
return { name: 'ErrorDisplay', request: error } return { name: 'ErrorDisplay', request: error }
} else if (request.showWarningMessage) {
return { name: 'WarningDisplay', request }
} else if (reconnecting) { } else if (reconnecting) {
return { name: 'ReconnectingDisplay' } return { name: 'ReconnectingDisplay' }
} else { } else if (request) {
if (request.showWarningMessage) {
return { name: 'WarningDisplay', request }
}
return { name: 'WaitingDisplay', request } return { name: 'WaitingDisplay', request }
} }
} }

View file

@ -3,6 +3,8 @@
<b-card-body> <b-card-body>
<b-card-title class="text-center mt-4" v-t="hasMessages ? 'api.processing' : 'api_waiting'" /> <b-card-title class="text-center mt-4" v-t="hasMessages ? 'api.processing' : 'api_waiting'" />
<p v-if="request.external" v-t="'api.external_action'" />
<!-- PROGRESS BAR --> <!-- PROGRESS BAR -->
<b-progress <b-progress
v-if="progress" class="my-4" v-if="progress" class="my-4"