Compare commits

...

5 Commits

Author SHA1 Message Date
Sebastian CZECH
d62433148b update 2025-05-07 16:08:49 +02:00
Sebastian CZECH
d44364d4d5 update 2025-05-07 14:08:15 +02:00
Sebastian CZECH
9118dc0835 update 2025-05-07 14:05:35 +02:00
Sebastian CZECH
97bf035a9f update 2025-05-07 14:03:28 +02:00
Sebastian CZECH
1e7ac62c51 feat(web-core): create task item component 2025-04-30 03:17:11 +02:00
12 changed files with 419 additions and 5 deletions

View File

@@ -0,0 +1,66 @@
# Example
```vue-template
<div class="ui-task-item">
<div v-if="task.name" v-tooltip class="text-ellipsis typo-body-regular">
{{ task.name }}
</div>
<div class="container">
<div class="content">
<UiTag v-if="task.tag" accent="neutral" variant="primary" size="medium">
{{ task.tag }}
</UiTag>
<div v-if="task.subtasks" class="subtasks">
<VtsIcon accent="current" class="icon" :icon="faCircleNotch" />
<p class="typo-form-info text-ellipsis">
{{ t('tasks.n-subtasks', { n: subtasks }) }}
</p>
</div>
<UiInfo v-for="(item, index) in messageTypes" :key="index" v-tooltip :accent="item.type as any">
{{ item.message }}
</UiInfo>
</div>
<div class="content">
<div class="user typo-body-regular-small">
<div v-if="user" v-tooltip class="user-name text-ellipsis">
<span>{{ t('by') }}</span>
<UiUserLink :username="user" />
</div>
<span v-if="taskIsComplete" class="text-ellipsis">
{{ $t('task.finished') }}
<RelativeTime v-if="task.end" :date="task.end" />
</span>
<span v-else class="text-ellipsis">
{{ $t('task.started') }}
<RelativeTime v-if="task.start" :date="task.start" />
</span>
</div>
<div class="progress-circle-bar">
<UiCircleProgressBar
v-if="circleProgress"
:accent="getEffectiveStatus"
size="small"
:value="circleProgress"
/>
</div>
</div>
</div>
</div>
```
```vue-script
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import RelativeTime from '@core/components/relative-time/RelativeTime.vue'
import UiCircleProgressBar, {
type CircleProgressBarAccent,
} from '@core/components/ui/circle-progress-bar/UiCircleProgressBar.vue'
import UiInfo from '@core/components/ui/info/UiInfo.vue'
import UiTag from '@core/components/ui/tag/UiTag.vue'
import UiUserLink from '@core/components/ui/user-link/UiUserLink.vue'
import { useMapper } from '@core/composables/mapper.composable.ts'
import { vTooltip } from '@core/directives/tooltip.directive'
import type { Message, Task, TaskStatus } from '@core/types/task.type.ts'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
```

View File

@@ -0,0 +1,45 @@
<template>
<ComponentStory
v-slot="{ properties }"
:params="[prop('task').required(), prop('user').str().preset('Username').widget()]"
>
<UiTaskItem v-bind="properties" :task />
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { prop } from '@/libs/story/story-param.ts'
import UiTaskItem from '@core/components/ui/task-item/UiTaskItem.vue'
const task = {
id: '0m96rljcx',
status: 'pending',
name: 'Task name',
tag: 'xo:task:action',
start: 1744011802689,
end: 1745395694000,
infos: [
{
message: 'Started',
},
{
message: 'info bar',
},
],
errors: [
{
message: 'error foo',
},
{
message: 'error bar',
},
],
warnings: [
{
message: 'info bar',
},
],
progress: 25,
}
</script>

View File

@@ -79,7 +79,8 @@ export class VmController extends XapiXoController<XoVm> {
property: 'resident_on',
})
) {
/* throw */ invalidParameters(`VM ${id} is halted or host could not be found.`, error)
/* throw */
invalidParameters(`VM ${id} is halted or host could not be found.`, error)
}
throw error
}

View File

@@ -0,0 +1,18 @@
<template>
<span :title="date.toLocaleString()">{{ relativeTime }}</span>
</template>
<script lang="ts" setup>
import useRelativeTime from '@core/composables/relative-time.composable'
import { parseDateTime } from '@core/utils/time.util.ts'
import { useNow } from '@vueuse/core'
import { computed } from 'vue'
const props = defineProps<{
date: Date | number | string
}>()
const date = computed(() => new Date(parseDateTime(props.date)))
const now = useNow({ interval: 1000 })
const relativeTime = useRelativeTime(date, now)
</script>

View File

@@ -46,7 +46,7 @@ const {
const { n } = useI18n()
type CircleProgressBarAccent = 'info' | 'warning' | 'danger'
export type CircleProgressBarAccent = 'info' | 'warning' | 'danger'
type CircleProgressBarSize = 'extra-small' | 'small' | 'medium' | 'large'
const circleSizeMap = {

View File

@@ -0,0 +1,244 @@
<!-- v3 -->
<template>
<div class="ui-task-item">
<div v-if="task.name" v-tooltip class="text-ellipsis typo-body-regular">
{{ task.name }}
</div>
<div class="container">
<div class="content">
<UiTag v-if="task.tag" accent="neutral" variant="primary" size="medium">
{{ task.tag }}
</UiTag>
<div v-if="task.subtasks" class="subtasks">
<VtsIcon accent="current" class="icon" :icon="faCircleNotch" />
<p class="typo-form-info text-ellipsis">
{{ t('tasks.n-subtasks', { n: subtasks }) }}
</p>
</div>
<UiInfo v-for="(item, index) in messageTypes" :key="index" v-tooltip :accent="item.type as any">
{{ item.message }}
</UiInfo>
</div>
<div class="content">
<div class="user typo-body-regular-small">
<div v-if="user" v-tooltip class="user-name text-ellipsis">
<span>{{ t('by') }}</span>
<UiUserLink :username="user" />
</div>
<span v-if="taskIsComplete" class="text-ellipsis">
{{ $t('task.finished') }}
<VtsRelativeTime v-if="task.end" :date="task.end" />
</span>
<span v-else class="text-ellipsis">
{{ $t('task.started') }}
<VtsRelativeTime v-if="task.start" :date="task.start" />
</span>
</div>
<div class="progress-circle-bar">
<UiCircleProgressBar
v-if="circleProgress"
:accent="getEffectiveStatus"
size="small"
:value="circleProgress"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsRelativeTime from '@core/components/relative-time/VtsRelativeTime.vue'
import UiCircleProgressBar, {
type CircleProgressBarAccent,
} from '@core/components/ui/circle-progress-bar/UiCircleProgressBar.vue'
import UiInfo from '@core/components/ui/info/UiInfo.vue'
import UiTag from '@core/components/ui/tag/UiTag.vue'
import UiUserLink from '@core/components/ui/user-link/UiUserLink.vue'
import { useMapper } from '@core/composables/mapper.composable.ts'
import { vTooltip } from '@core/directives/tooltip.directive'
import type { Message, Task, TaskStatus } from '@core/types/task.type.ts'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { task, user } = defineProps<{
task: Task
user?: string
}>()
const { t } = useI18n()
const taskIsComplete = computed(() => {
if (!task.end || !task.start) {
return
}
return task.end >= task.start || task.progress === 100
})
const countSubtasks = (task: Task): number => {
if (!task.subtasks || task.subtasks.length === 0) {
return 0
}
return task.subtasks.length + task.subtasks.reduce((sum: number, subtask: any) => sum + countSubtasks(subtask), 0)
}
const subtasks = computed(() => countSubtasks(task))
const circleProgress = computed(() => {
if (['success', 'failure'].includes(task.status)) {
return 100
} else {
return task.progress
}
})
const getEffectiveStatus = computed<CircleProgressBarAccent>(() => {
const evaluate = (task: Task) => {
const subtasks = task.subtasks || []
if (subtasks.length === 0) {
return useMapper<TaskStatus, CircleProgressBarAccent>(
() => task.status,
{
pending: 'warning',
interrupted: 'warning',
success: 'info',
failure: 'danger',
},
() => 'info'
).value
}
const subStatuses = subtasks.map(evaluate)
const allFailure = subStatuses.every(status => status === 'danger')
const someFailure = subStatuses.some(status => status === 'danger')
const allSuccess = subStatuses.every(status => status === 'info')
if (allFailure) {
return 'danger'
}
if (someFailure) {
return 'warning'
}
if (allSuccess) {
return 'info'
}
return 'warning'
}
return evaluate(task)
})
const generateMessages = (task: Task) => {
const results = {
info: { count: 0, messages: [] as Message[] },
danger: { count: 0, messages: [] as Message[] },
warning: { count: 0, messages: [] as Message[] },
}
const accumulate = (task: Task) => {
const infos = task.infos || []
const errors = task.errors || []
const warnings = task.warnings || []
results.info.count += infos.length
results.info.messages.push(...infos)
results.danger.count += errors.length
results.danger.messages.push(...errors)
results.warning.count += warnings.length
results.warning.messages.push(...warnings)
task.subtasks?.forEach(accumulate)
}
accumulate(task)
return [
...(results.info.count > 0
? [{ type: 'info', message: t('information', { n: results.info.count }), messages: results.info.messages }]
: []),
...(results.danger.count > 0
? [{ type: 'danger', message: t('error', { n: results.danger.count }), messages: results.danger.messages }]
: []),
...(results.warning.count > 0
? [{ type: 'warning', message: t('warning', { n: results.warning.count }), messages: results.warning.messages }]
: []),
]
}
const messageTypes = computed(() => generateMessages(task))
</script>
<style scoped lang="postcss">
.ui-task-item {
padding: 0.2rem;
margin-right: 1.6rem;
display: flex;
height: 4.8rem;
align-items: center;
cursor: pointer;
gap: 1.6rem;
padding-inline: 0.8rem;
.container {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
.content {
display: flex;
gap: 1.6rem;
align-items: center;
/*keep the same space even iif circleProgress is undefined*/
.progress-circle-bar {
height: 40px;
width: 40px;
}
.subtasks {
display: flex;
gap: 0.8rem;
align-items: center;
}
}
}
.user {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 1rem;
color: var(--color-neutral-txt-secondary);
> div {
display: flex;
gap: 0.4rem;
}
> span::before {
content: '•';
margin: 0 0.5rem;
}
span:first-of-type::before {
content: '';
}
&:has(.user-name) > span::before {
content: '•';
margin: 0 0.5rem;
}
}
.user:has(.user-name) > div > span:first-child {
margin: 0 0.5rem;
}
}
</style>

View File

@@ -54,7 +54,7 @@ export default function useRelativeTime(fromDate: MaybeRef<Date>, toDate: MaybeR
parts.push(t('relative-time.minute', { n: minutes }))
}
if (days === 0 && seconds > 0) {
if (minutes === 0 && seconds > 0) {
parts.push(t('relative-time.second', { n: seconds }))
}
}

View File

@@ -55,6 +55,7 @@
"boot-firmware-bios": "The template already contains the BIOS strings",
"boot-firmware-uefi": "The boot firmware is UEFI",
"boot-vm": "Boot VM",
"by": "By",
"bytes.gi": "GiB",
"bytes.ki": "KiB",
"bytes.mi": "MiB",
@@ -151,6 +152,7 @@
"enabled": "Enabled",
"end-of-life": "End of life",
"eol": "EOL",
"error": "{n} error | {n} errors",
"error-no-data": "Error, can't collect data.",
"error-occurred": "An error has occurred",
"exit-fullscreen": "Exit fullscreen",
@@ -206,6 +208,7 @@
"hosts-status.unknown.tooltip": "Hosts metrics are unavailable",
"id": "Id",
"in-last-three-jobs": "In their last three jobs",
"information": "{n} information | {n} informations",
"install-settings": "Install settings",
"interfaces": "Interface | Interface | Interfaces",
"invalid-field": "Invalid field",
@@ -430,6 +433,8 @@
"table-actions": "Table actions",
"tags": "Tags",
"task.estimated-end": "Estimated end",
"task.estimated-end-in": "Estimated end in {time}",
"task.finished": "Finished",
"task.progress": "Progress",
"task.started": "Started",
"tasks": "Tasks",
@@ -491,6 +496,7 @@
"vms-status.suspended": "Suspended",
"vms-status.unknown": "Unknown",
"vms-status.unknown.tooltip": "For which XO has lost connection to the pool",
"warning": "{n} warning | {n} warnings",
"xo-backups": "XO backups",
"xo-lite-under-construction": "XOLite is under construction",
"xo-replications": "XO replications",

View File

@@ -55,6 +55,7 @@
"boot-firmware-bios": "Le modèle contient déjà les BIOS strings",
"boot-firmware-uefi": "Le boot firmware est UEFI",
"boot-vm": "Démarrer la VM",
"by": "Par",
"bytes.gi": "Gio",
"bytes.ki": "Kio",
"bytes.mi": "Mio",
@@ -151,6 +152,7 @@
"enabled": "Activé",
"end-of-life": "Fin de vie",
"eol": "EOL",
"error": "{n} erreur | {n} erreurs",
"error-no-data": "Erreur, impossible de collecter les données.",
"error-occurred": "Une erreur est survenue",
"exit-fullscreen": "Quitter le plein écran",
@@ -206,6 +208,7 @@
"hosts-status.unknown.tooltip": "Les métriques de l'hôte sont inaccessibles",
"id": "Id",
"in-last-three-jobs": "Dans leurs trois derniers jobs",
"information": "{n} information | {n} informations",
"install-settings": "Paramètres d'installation",
"interfaces": "Interface | Interface | Interfaces",
"invalid-field": "Champ invalide",
@@ -430,8 +433,10 @@
"table-actions": "Actions du tableau",
"tags": "Tags",
"task.estimated-end": "Fin estimée",
"task.estimated-end-in": "Fin estimée dans {time}",
"task.finished": "terminée",
"task.progress": "Progression",
"task.started": "Démarré",
"task.started": "Démarrée",
"tasks": "Tâches",
"tasks.n-subtasks": "{n} sous-tâche | {n} sous-tâches",
"tasks.no-tasks": "Aucune tâche",
@@ -491,6 +496,7 @@
"vms-status.suspended": "Suspendues",
"vms-status.unknown": "Inconnu",
"vms-status.unknown.tooltip": "Dont XO a perdu la connexion avec le pool",
"warning": "{n} avertissement | {n} avertissements",
"xo-backups": "Sauvegardes XO",
"xo-lite-under-construction": "XOLite est en construction",
"xo-replications": "Réplications XO",

View File

@@ -0,0 +1,20 @@
export type TaskStatus = 'pending' | 'success' | 'failure' | 'interrupted'
export type Message = {
message: string
}
export type Task = {
id: string | number
name: string
status: TaskStatus
tag: string
start: number
end?: number
userId?: string
subtasks?: Task[]
progress?: number
infos?: Message[]
errors?: Message[]
warnings?: Message[]
}

View File

@@ -0,0 +1,8 @@
import type { Branded } from '@core/types/utility.type.ts'
export type XoUser = {
id: Branded<'user'>
email: string
name?: string
permission: string
}

View File

@@ -41,7 +41,7 @@ export const xoApiDefinition = {
task: {
type: 'collection',
path: 'tasks',
fields: 'id,start,end,properties,status,progress,tasks',
fields: 'id,properties,start,status,updatedAt,end,tasks,infos,errors,warnings',
handler: (record: XoTask) => record,
},
dashboard: {