Skip to content

Commit

Permalink
Merge pull request #603 from YunoHost/sse
Browse files Browse the repository at this point in the history
New log streaming API
  • Loading branch information
alexAubin authored Jan 20, 2025
2 parents ff1d730 + 2ffddf2 commit d5590a2
Show file tree
Hide file tree
Showing 43 changed files with 604 additions and 545 deletions.
4 changes: 4 additions & 0 deletions app/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ declare module 'vue' {
BCardHeader: typeof import('bootstrap-vue-next/components/BCard')['BCardHeader']
BCardText: typeof import('bootstrap-vue-next/components/BCard')['BCardText']
BCardTitle: typeof import('bootstrap-vue-next/components/BCard')['BCardTitle']
BCloseButton: typeof import('bootstrap-vue-next/components/BButton')['BCloseButton']
BCol: typeof import('bootstrap-vue-next/components/BContainer')['BCol']
BCollapse: typeof import('bootstrap-vue-next/components/BCollapse')['BCollapse']
BDropdown: typeof import('bootstrap-vue-next/components/BDropdown')['BDropdown']
Expand Down Expand Up @@ -70,6 +71,8 @@ declare module 'vue' {
BTab: typeof import('bootstrap-vue-next/components/BTabs')['BTab']
BTable: typeof import('bootstrap-vue-next/components/BTable')['BTable']
BTabs: typeof import('bootstrap-vue-next/components/BTabs')['BTabs']
BToast: typeof import('bootstrap-vue-next/components/BToast')['BToast']
BToastOrchestrator: typeof import('bootstrap-vue-next/components/BToast')['BToastOrchestrator']
ButtonItem: typeof import('./src/components/globals/formItems/ButtonItem.vue')['default']
CardCollapse: typeof import('./src/components/CardCollapse.vue')['default']
CardDeckFeed: typeof import('./src/components/CardDeckFeed.vue')['default']
Expand Down Expand Up @@ -116,6 +119,7 @@ declare module 'vue' {
YListGroupItem: typeof import('./src/components/globals/YListGroupItem.vue')['default']
YListItem: typeof import('./src/components/globals/YListItem.vue')['default']
YSpinner: typeof import('./src/components/globals/YSpinner.vue')['default']
YToast: typeof import('./src/components/YToast.vue')['default']
}
export interface ComponentCustomProperties {
vBModal: typeof import('bootstrap-vue-next/directives/BModal')['vBModal']
Expand Down
4 changes: 4 additions & 0 deletions app/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
<script setup lang="ts">
import { useToastController } from 'bootstrap-vue-next'
import { onMounted, ref } from 'vue'
import { useAutoToast } from '@/composables/useAutoToast'
import { useInfos } from '@/composables/useInfos'
import { useRequests } from '@/composables/useRequests'
import { useSettings } from '@/composables/useSettings'
import { HistoryConsole } from '@/views/_partials'
useAutoToast().init(useToastController())
const { ssoLink, connected, yunohost, logout, onAppCreated } = useInfos()
const { locked } = useRequests()
const { spinner, dark } = useSettings()
Expand Down Expand Up @@ -115,6 +118,7 @@ onMounted(() => {
<MainLayout v-if="ready" />

<BModalOrchestrator />
<BToastOrchestrator />

<!-- HISTORY CONSOLE -->
<HistoryConsole />
Expand Down
102 changes: 25 additions & 77 deletions app/src/api/api.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
import { v4 as uuid } from 'uuid'

import { useCache, type StorePath } from '@/composables/data'
import { useInfos } from '@/composables/useInfos'
import {
useRequests,
type APIRequestAction,
type ReconnectingArgs,
} from '@/composables/useRequests'
import { useRequests } from '@/composables/useRequests'
import { useSettings } from '@/composables/useSettings'
import type { Obj } from '@/types/commons'
import {
APIBadRequestError,
APIErrorLog,
APIUnauthorizedError,
type APIError,
} from './errors'
import { getError, getResponseData, openWebSocket } from './handlers'
import { APIBadRequestError, APIErrorLog, APIUnauthorizedError } from './errors'
import { getError, getResponseData } from './handlers'

export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'

Expand All @@ -23,16 +16,15 @@ export type HumanKey = {
}

export type APIQuery = {
method?: RequestMethod
uri: string
method?: RequestMethod
cachePath?: StorePath
cacheForce?: boolean
data?: Obj
humanKey?: string | HumanKey
showModal?: boolean
ignoreError?: boolean
websocket?: boolean
isAction?: boolean
initial?: boolean
asFormData?: boolean
}

export type APIErrorData = {
Expand All @@ -51,10 +43,7 @@ export type APIErrorData = {
* @param addLocale - Append the locale to the returned object
* @param formData - Returns a `FormData` instead of `URLSearchParams`
*/
export function objectToParams(
obj: Obj,
{ addLocale = false, formData = false } = {},
) {
export function objectToParams(obj: Obj, { formData = false } = {}) {
const urlParams = formData ? new FormData() : new URLSearchParams()
for (const [key, value] of Object.entries(obj)) {
if (Array.isArray(value)) {
Expand All @@ -63,10 +52,6 @@ export function objectToParams(
urlParams.append(key, value)
}
}
if (addLocale) {
const { locale } = useSettings()
urlParams.append('locale', locale.value)
}
return urlParams
}

Expand All @@ -92,11 +77,9 @@ export default {
* @param cacheParams - Cache params to get or update data
* @param method - An HTTP method in `'GET' | 'POST' | 'PUT' | 'DELETE'`
* @param data - Data to send as body
* @param humanKey - Key and eventually some data to build the query's description
* @param showModal - Lock view and display the waiting modal
* @param websocket - Open a websocket connection to receive server messages
* @param isAction - Expects to receive server messages
* @param initial - If an error occurs, the dismiss button will trigger a go back in history
* @param asFormData - Send the data with a body encoded as `"multipart/form-data"` instead of `"x-www-form-urlencoded"`)
*
* @returns Promise that resolve the api response data
* @throws Throw an `APIError` or subclass depending on server response
Expand All @@ -105,44 +88,44 @@ export default {
uri,
method = 'GET',
cachePath = undefined,
cacheForce = false,
data = undefined,
humanKey = undefined,
showModal = method !== 'GET',
ignoreError = false,
websocket = method !== 'GET',
isAction = method !== 'GET',
initial = false,
asFormData = true,
}: APIQuery): Promise<T> {
const cache = cachePath ? useCache<T>(method, cachePath) : undefined
if (method === 'GET' && cache?.content.value !== undefined) {
if (!cacheForce && method === 'GET' && cache?.content.value !== undefined) {
return cache.content.value
}

const { locale } = useSettings()
const { startRequest, endRequest } = useRequests()

// Try to find a description for an API route to display in history and modals

const request = startRequest({
id: uuid(),
method,
uri,
humanKey,
date: Date.now(),
initial,
showModal,
websocket,
isAction,
})
if (websocket) {
await openWebSocket(request as APIRequestAction)
}

let options = { ...this.options }
if (method === 'GET') {
uri += `${uri.includes('?') ? '&' : '?'}locale=${locale.value}`
} else {
Object.assign(options.headers!, {
locale: locale.value,
'ref-id': request.id,
})

if (method !== 'GET') {
options = {
...options,
method,
body: data
? objectToParams(data, { addLocale: true, formData: asFormData })
: null,
body: data ? objectToParams(data, { formData: true }) : null,
}
}

Expand Down Expand Up @@ -252,39 +235,4 @@ export default {
const { updateRouterKey } = useInfos()
updateRouterKey()
},

/**
* Api reconnection helper. Resolve when server is reachable or fail after n attemps
*
* @param attemps - Number of attemps before rejecting
* @param delay - Delay between calls to the API in ms
* @param initialDelay - Delay before calling the API for the first time in ms
*
* @returns Promise that resolve yunohost version infos
* @throws Throw an `APIError` or subclass depending on server response
*/
tryToReconnect({
attemps = 5,
delay = 2000,
initialDelay = 0,
}: ReconnectingArgs = {}) {
const { getYunoHostVersion } = useInfos()
return new Promise((resolve, reject) => {
function reconnect(n: number) {
getYunoHostVersion()
.then(resolve)
.catch((err: APIError) => {
if (err instanceof APIUnauthorizedError) {
reject(err)
} else if (n < 1) {
reject(err)
} else {
setTimeout(() => reconnect(n - 1), delay)
}
})
}
if (initialDelay > 0) setTimeout(() => reconnect(attemps), initialDelay)
else reconnect(attemps)
})
},
}
2 changes: 1 addition & 1 deletion app/src/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class APIError extends Error {
const urlObj = new URL(url)
this.code = status
this.status = statusText
this.method = request.method
this.method = request.method!
this.requestId = request.id
this.path = urlObj.pathname + urlObj.search
}
Expand Down
46 changes: 1 addition & 45 deletions app/src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@
*/

import errors from '@/api/errors'
import { useInfos } from '@/composables/useInfos'
import type { APIRequest, APIRequestAction } from '@/composables/useRequests'
import { toEntries } from '@/helpers/commons'
import { STATUS_VARIANT, isOkStatus } from '@/helpers/yunohostArguments'
import type { StateStatus, Obj } from '@/types/commons'
import type { APIRequest } from '@/composables/useRequests'
import type { APIErrorData } from './api'

/**
Expand All @@ -27,46 +23,6 @@ export async function getResponseData<T>(response: Response): Promise<T> {
}
}

/**
* Opens a WebSocket connection to the server in case it sends messages.
* Currently, the connection is closed by the server right after an API call so
* we have to open it for every calls.
* Messages are dispatch to the store so it can handle them.
*
* @param request - Request info data.
* @returns Promise that resolve on websocket 'open' or 'error' event.
*/
export function openWebSocket(request: APIRequestAction): Promise<Event> {
const { host } = useInfos()
return new Promise((resolve) => {
const ws = new WebSocket(`wss://${host.value}/yunohost/api/messages`)
ws.onmessage = ({ data }) => {
const messages: Record<StateStatus, string> = 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 (!isOkStatus(status)) request.action[`${status}s`]++
})
}
// ws.onclose = (e) => {}
ws.onopen = resolve
// Resolve also on error so the actual fetch may be called.
ws.onerror = resolve
})
}

/**
* Handler for API errors.
*
Expand Down
29 changes: 22 additions & 7 deletions app/src/components/QueryHeader.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, toRefs } from 'vue'
import { useInfos } from '@/composables/useInfos'
import type { APIRequest } from '@/composables/useRequests'
import { STATUS_VARIANT } from '@/helpers/yunohostArguments'
Expand All @@ -11,12 +12,23 @@ const props = defineProps<{
const emit = defineEmits<{ showError: [id: string] }>()
const { currentUser } = useInfos()
const statusVariant = computed(() => STATUS_VARIANT[props.request.status])
const { errors, warnings } = toRefs(
props.request.action || { errors: 0, warnings: 0 },
)
const hour = computed(() => {
return new Date(props.request.date).toLocaleTimeString()
const dateOrTime = computed(() => {
// returns date if date < today else time
const date = new Date(props.request.date)
const dateString = date.toLocaleDateString()
if (new Date().toLocaleDateString() !== dateString) return dateString
return date.toLocaleTimeString()
})
const caller = computed(() => {
const caller = props.request.action?.caller
return caller && caller !== currentUser.value ? caller : null
})
</script>

Expand All @@ -29,9 +41,12 @@ const hour = computed(() => {
/>

<!-- tabindex 0 on title for focus-trap when no tabable elements -->
<strong :tabindex="type === 'overlay' ? 0 : undefined">
{{ request.humanRoute }}
</strong>
<div>
<strong :tabindex="type === 'overlay' ? 0 : undefined">
{{ request.title }}
</strong>
<span v-if="caller"> ({{ $t('history.started_by', { caller }) }})</span>
</div>

<div v-if="errors || warnings">
<span v-if="errors" class="ms-2">
Expand All @@ -54,8 +69,8 @@ const hour = computed(() => {
<small v-t="'api_error.view_error'" />
</BButton>

<time :datetime="hour" :class="request.err ? 'ms-2' : 'ms-auto'">
{{ hour }}
<time :datetime="dateOrTime" :class="request.err ? 'ms-2' : 'ms-auto'">
{{ dateOrTime }}
</time>
</template>
</div>
Expand Down
28 changes: 28 additions & 0 deletions app/src/components/YToast.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { BToastProps } from 'bootstrap-vue-next'
import { computed } from 'vue'
const props = defineProps<BToastProps>()
const cancelable = computed(() => {
return ['warning', 'danger'].some((variant) => variant === props.variant)
})
</script>

<template>
<BToast v-bind="props" :model-value="cancelable ? 30000 : 5000" show-on-pause>
<template #default="{ hide }">
<div class="d-flex">
<div v-html="body" />
<BCloseButton class="ms-auto" @click="hide()" />
</div>
</template>
</BToast>
</template>

<style lang="scss" scoped>
.btn-close {
filter: unset !important;
width: 1rem;
}
</style>
Loading

0 comments on commit d5590a2

Please sign in to comment.