Compare commits

...

16 Commits

Author SHA1 Message Date
Elise-F
abdcbc1088 update changelog 2026-11-09 16:26:44 +01:00
Elise-F
3c4ae9c70f feat(xo6): add edit order action within network for traffic rules 2026-11-09 16:26:24 +01:00
Elise-F
8e81250ca3 fix(xo6,lite): translate VM and host state tooltips in tree view (#10643) 2026-11-09 16:25:00 +01:00
Joris K
c8c11ae63c feat(xo6/web-core): show a loader in VM and host icons during power state changes (#10720) 2026-11-09 10:49:12 +01:00
Elise-F
8df236a512 feat(xo6): displaying traffic rules by priority within network (#10688)
* feat(xo6): displaying traffic rules by priority within network

* update changelog

* clean code
2026-11-09 09:51:51 +01:00
Joris K
8cc3384850 feat(xo6/network): add dedicated network page (#10704) 2026-11-09 09:39:47 +01:00
Olivier Lambert
e4cc8df323 feat(xo6): compact treeview (#10727) 2026-11-06 19:45:39 +01:00
Joris K
43a7e3d3ac feat(web-core): adapt card rows to their content in side panels (#10733)
* feat(web-core): adapt card rows to their content in side panels

- Key and value stay on one line when they fit, the value moves below the key otherwise
- New `values` prop on `VtsCardRowKeyValue`: one copy button per value, a "Copy all"
  action and an optional "see more" limit for fields with multiple values
- New `VtsCardRowList` component holding the 0.8rem spacing between rows
- `UiInfo` text is small inside side panels (icon stays medium), medium elsewhere
- Side panel links use the small size

* docs: add changelog entries
2026-11-06 18:38:09 +01:00
Joris K
2e586f0369 feat(web-core): make the side panel resizable (#10725)
* feat(web-core): make the side panel resizable

- Resize the side panel like the treeview, with a shared `VtsResizeHandle`
  component and `useResizableWidth` composable
- Default width of the treeview and the side panel is 1/5 of the screen
  (280px minimum), max width is 1/3 of the screen (400px minimum)
- Double-click (or Enter) on the handle restores the default width
- The handle is keyboard accessible (arrows, Home, End) and exposed as an
  ARIA separator

* docs: add changelog entries
2026-11-06 18:27:57 +01:00
Bastien Nollet
93dc079f71 docs: remove british spellings (#10724) 2026-11-06 16:58:11 +01:00
Joris K
9c6a59bb89 feat(web-core): show UiButton loader on the right of the label (#10723) 2026-11-06 12:57:52 +01:00
Thomas Moraine
12aa9576b9 docs: use US spelling for licensing (#10666) 2026-11-06 10:21:31 +01:00
Joris K
85541ce5d7 fix(xo6): Fix some issue in the pool tab (#10714) 2026-11-05 15:42:09 +01:00
Joris K
56a33c1639 fix(xo6): change padding tasks (#10713) 2026-11-05 14:49:03 +01:00
Olivier Lambert
7f6ea616ca feat(docs): add optional email to page feedback (#10697) 2026-11-04 16:58:50 +01:00
spacotte-vates
5601a64703 feat(complex-matcher): use complex matcher for backup jobs (#10064)
Use complex matcher for healtcheck and full backup interval in backup jobs

(XO-2435)
2026-11-02 17:49:16 +01:00
280 changed files with 5332 additions and 1869 deletions

View File

@@ -11,6 +11,10 @@
"./xo": "./dist/xo.d.mts",
"./xo-app": "./dist/xo-app.d.mts",
"./xen-api": "./dist/xen-api.d.mts",
"./backup-expression-context": {
"default": "./dist/backup-expression-context.mjs",
"types": "./dist/backup-expression-context.d.mts"
},
"./common": {
"default": "./dist/common.mjs",
"types": "./dist/common.d.mts"

View File

@@ -0,0 +1,38 @@
export const BACKUP_EXPRESSION_CONTEXT = {
run: {
dayOfWeek: { type: 'number' },
dayOfMonth: { type: 'number' },
hour: { type: 'number' },
month: { type: 'number' },
year: { type: 'number' },
},
sr: {
name_label: { type: 'string' },
type: { type: 'string' },
description: { type: 'string' },
tags: { type: 'string[]' },
},
backupRepository: {
name: { type: 'string' },
type: { type: 'string' },
tags: { type: 'string[]' },
},
vm: {
name_label: { type: 'string' },
tags: { type: 'string[]' },
power_state: { type: 'string' },
},
chainLength: { type: 'number' },
} as const
export type BACKUP_EXPRESSION_CONTEXT = (typeof BACKUP_EXPRESSION_CONTEXT)[keyof typeof BACKUP_EXPRESSION_CONTEXT]
export const BACKUP_SETTING_CONTEXT = {
healthCheckVmsWithTags: ['vm', 'run'],
fullInterval: ['vm', 'run', 'chainLength'],
vmFilter: ['vm', 'run'],
srs: ['sr', 'run'],
remotes: ['backupRepository', 'run'],
} as const
export type BACKUP_SETTING_CONTEXT = (typeof BACKUP_SETTING_CONTEXT)[keyof typeof BACKUP_SETTING_CONTEXT]

View File

@@ -772,6 +772,7 @@ export type RawTrafficRule = {
ipRange: string
direction: TrafficRuleDirection
port?: string
priority?: number
}
type BaseTrafficRule = RawTrafficRule & {

View File

@@ -552,8 +552,8 @@ export type XoVmBackupJobGeneralSettings = XoBackupJobGeneralSettings & {
}
export type XoVmBackupJobScheduleSettings = {
exportRetention?: number
healthCheckVmsWithTags?: XoVm['tags']
fullInterval?: number
healthCheckVmsWithTags?: XoVm['tags'] | string
fullInterval?: number | string
copyRetention?: number
snapshotRetention?: number
cbtDestroySnapshotData?: boolean
@@ -564,9 +564,9 @@ export type XoVmBackupJob = BaseXoJob & {
compression?: 'native' | 'zstd' | ''
proxy?: XoProxy['id']
mode: 'full' | 'delta'
remotes?: CMType.IdOr<XoBackupRepository['id']>
remotes?: CMType.IdOr<XoBackupRepository['id']> | string
vms: CMType.IdOr<XoVm['id']> | Record<string, unknown>
srs?: CMType.IdOr<XoSr['id']>
srs?: CMType.IdOr<XoSr['id']> | string
type: 'backup'
settings: {
'': XoVmBackupJobGeneralSettings
@@ -598,13 +598,15 @@ export type XoMirrorBackupGeneralSettings = XoBackupJobGeneralSettings & {
nRetriesVmBackupFailures?: number
mergeBackupsSynchronously?: boolean
timeout?: number
timezone?: string
maxExportRate?: number
backupReportTpl?: 'compactMjml'
reportWhen: 'failure'
}
export type XoMirrorBackupScheduleSettings = {
exportRetention?: number
healthCheckVmsWithTags?: XoVm['tags']
healthCheckVmsWithTags?: XoVm['tags'] | string
vmFilter?: string
healthCheckSr?: XoSr['id']
[key: string]: unknown
}

View File

@@ -0,0 +1,111 @@
// @ts-check
import { createLogger } from '@xen-orchestra/log'
import moment from 'moment-timezone'
import { parse } from 'xo-remote-parser'
const { warn } = createLogger('xo:backups:buildContext')
/**
* @typedef {import('@vates/types/xen-api').XenApiVm} XenApiVm
* @typedef {import('@vates/types/xo').XoSr} XoSr
* @typedef {import('@vates/types/xo').XoBackupRepository} XoBackupRepository
*
* @typedef {object} RunContext
* @property {number} dayOfWeek
* @property {number} dayOfMonth
* @property {number} hour
* @property {number} month
* @property {number} year
*
* @typedef {object} VmContext
* @property {Record<string, unknown>} vm
* @property {RunContext} run
* @property {number} [chainLength]
*
* @typedef {object} SrContext
* @property {{name_label: string, type: string, description: string, tags: string[]}} sr
* @property {RunContext} run
*
* @typedef {object} BackupRepositoryContext
* @property {{name: string, type: string | undefined, tags: string[]}} backupRepository
* @property {RunContext} run
*/
/**
* @param {Date} date
* @param {string | undefined} timezone
* @returns {RunContext}
*/
export function buildRunContext(date, timezone) {
const momentDate = timezone ? moment.tz(date, timezone) : moment(date)
return {
dayOfWeek: momentDate.day(),
dayOfMonth: momentDate.date(),
hour: momentDate.hour(),
month: momentDate.month() + 1,
year: momentDate.year(),
}
}
/**
* @param {XenApiVm} vm
* @param {RunContext} run
* @param {number} [chainLength]
* @returns {VmContext}
*/
export function buildVmContext(vm, run, chainLength) {
/** @type {Record<string, unknown>} */
const vmObject = {}
for (const [key, value] of Object.entries(vm)) {
if (!key.startsWith('$') && typeof value !== 'function') {
vmObject[key] = value
}
}
if (!Array.isArray(vmObject.tags)) {
vmObject.tags = []
}
return { vm: vmObject, run, chainLength }
}
/**
* @param {XoSr} sr
* @param {RunContext} run
* @returns {SrContext}
*/
export function buildSrContext(sr, run) {
return {
sr: {
name_label: sr.name_label,
type: sr.SR_type,
description: sr.name_description,
tags: Array.isArray(sr.tags) ? sr.tags : [],
},
run,
}
}
/**
* @param {XoBackupRepository} backupRepository
* @param {RunContext} run
* @returns {BackupRepositoryContext}
*/
export function buildBackupRepositoryContext(backupRepository, run) {
let type
try {
type = parse(backupRepository.url).type
} catch (error) {
warn('cannot parse backup repository URL', { backupRepositoryId: backupRepository.id, error })
}
return {
backupRepository: {
name: backupRepository.name,
type,
tags: [], // Placeholder - Backup repositories don't have tags yet
},
run,
}
}

View File

@@ -0,0 +1,147 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { buildRunContext, buildVmContext, buildSrContext, buildBackupRepositoryContext } from './_buildContext.mjs'
import { BACKUP_EXPRESSION_CONTEXT } from '@vates/types/backup-expression-context'
describe('buildContext', () => {
const date = new Date('2025-01-05T03:00:00Z')
const runContext = buildRunContext(date, 'America/New_York')
const expectedRunContext = {
dayOfWeek: 6,
dayOfMonth: 4,
hour: 22,
month: 1,
year: 2025,
}
it('buildRunContext', () => {
assert.deepEqual(runContext, expectedRunContext)
})
it('buildVmContext', () => {
const vm1 = { name: 'name', $value: '$value', func: () => {} }
const vmContext1 = buildVmContext(vm1, runContext, 3)
assert.deepEqual(vmContext1, {
vm: {
name: 'name',
tags: [],
},
run: expectedRunContext,
chainLength: 3,
})
const vm2 = { name: 'name', $value: '$value', func: () => {}, tags: ['tag'] }
const vmContext2 = buildVmContext(vm2, runContext, undefined)
assert.deepEqual(vmContext2, {
vm: {
name: 'name',
tags: ['tag'],
},
run: expectedRunContext,
chainLength: undefined,
})
const vm3 = { name: 'name', $value: '$value', func: () => {}, tags: 'tag' }
const vmContext3 = buildVmContext(vm3, runContext, undefined)
assert.deepEqual(vmContext3, {
vm: {
name: 'name',
tags: [],
},
run: expectedRunContext,
chainLength: undefined,
})
})
it('buildVmContext exposes every field advertised by the schema', () => {
// vm is an open context (buildVmContext copies through all real VM fields),
// so we assert the schema's curated fields are a subset of the output, not equality.
const vm = { name_label: 'name', power_state: 'Running', tags: ['tag'], $ref: 'ref', func: () => {} }
const { vm: vmContext } = buildVmContext(vm, runContext)
for (const field of Object.keys(BACKUP_EXPRESSION_CONTEXT.vm)) {
assert.ok(field in vmContext, `vm context is missing schema field: ${field}`)
}
})
it('buildSrContext', () => {
const sr1 = {
name_label: 'name_label',
SR_type: 'type',
name_description: 'description',
ignored: 'ignored',
}
const srContext1 = buildSrContext(sr1, runContext)
assert.deepEqual(srContext1, {
sr: {
name_label: 'name_label',
type: 'type',
description: 'description',
tags: [],
},
run: expectedRunContext,
})
assert.deepEqual(Object.keys(srContext1.sr).sort(), Object.keys(BACKUP_EXPRESSION_CONTEXT.sr).sort())
const sr2 = {
name_label: 'name_label',
SR_type: 'type',
name_description: 'description',
tags: ['tag'],
ignored: 'ignored',
}
const srContext2 = buildSrContext(sr2, runContext)
assert.deepEqual(srContext2, {
sr: {
name_label: 'name_label',
type: 'type',
description: 'description',
tags: ['tag'],
},
run: expectedRunContext,
})
assert.deepEqual(Object.keys(srContext2.sr).sort(), Object.keys(BACKUP_EXPRESSION_CONTEXT.sr).sort())
})
it('buildBackupRepositoryContext', () => {
const backupRepository1 = {
name: 'name',
url: 'nfs://host/path',
ignored: 'ignored',
}
const backupRepositoryContext1 = buildBackupRepositoryContext(backupRepository1, runContext)
assert.deepEqual(backupRepositoryContext1, {
backupRepository: {
name: 'name',
type: 'nfs',
tags: [],
},
run: expectedRunContext,
})
assert.deepEqual(
Object.keys(backupRepositoryContext1.backupRepository).sort(),
Object.keys(BACKUP_EXPRESSION_CONTEXT.backupRepository).sort()
)
const backupRepository2 = {
name: 'name',
url: 'nfs://host/path',
tags: [],
ignored: 'ignored',
}
const backupRepositoryContext2 = buildBackupRepositoryContext(backupRepository2, runContext)
assert.deepEqual(backupRepositoryContext2, {
backupRepository: {
name: 'name',
type: 'nfs',
tags: [],
},
run: expectedRunContext,
})
assert.deepEqual(
Object.keys(backupRepositoryContext2.backupRepository).sort(),
Object.keys(BACKUP_EXPRESSION_CONTEXT.backupRepository).sort()
)
})
})

View File

@@ -0,0 +1,24 @@
// @ts-check
import * as CM from 'complex-matcher'
/**
* Parse and compile a complex-matcher expression into a predicate function.
* Throws synchronously if the expression is syntactically invalid.
*
* @param {string} expression
* @returns {(context: object) => boolean}
*/
export function compileExpression(expression) {
return CM.parse(expression).createPredicate()
}
/**
* Returns true if value is a non-empty string — i.e. a complex-matcher expression.
*
* @param {unknown} value
* @returns {value is string}
*/
export function isExpression(value) {
return typeof value === 'string' && value.length > 0 && !Number.isFinite(Number(value))
}

View File

@@ -0,0 +1,95 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { compileExpression, isExpression } from './_expressionPredicate.mjs'
describe('compileExpression', () => {
it('run:dayOfWeek:0', () => {
const predicate = compileExpression('run:dayOfWeek:0')
assert(predicate({ run: { dayOfWeek: 0 } }))
assert(!predicate({ run: { dayOfWeek: 1 } }))
})
it('chainLength:>5', () => {
const predicate = compileExpression('chainLength:>5')
assert(predicate({ chainLength: 6 }))
assert(!predicate({ chainLength: 5 }))
})
it('vm:tags:prod', () => {
const predicate = compileExpression('vm:tags:prod')
assert(predicate({ vm: { tags: ['prod'] } }))
assert(predicate({ vm: { tags: ['prod', 'test'] } }))
assert(!predicate({ vm: { tags: ['test'] } }))
assert(!predicate({ vm: { tags: [] } }))
})
it('run:dayOfWeek:0 chainLength:>3', () => {
const predicate = compileExpression('run:dayOfWeek:0 chainLength:>3')
assert(predicate({ run: { dayOfWeek: 0 }, chainLength: 6 }))
assert(!predicate({ run: { dayOfWeek: 0 }, chainLength: 2 }))
assert(!predicate({ run: { dayOfWeek: 1 }, chainLength: 6 }))
})
it('|(run:dayOfWeek:0 chainLength:>6)', () => {
const predicate = compileExpression('|(run:dayOfWeek:0 chainLength:>6)')
assert(predicate({ run: { dayOfWeek: 0 }, chainLength: 7 }))
assert(predicate({ run: { dayOfWeek: 1 }, chainLength: 7 }))
assert(predicate({ run: { dayOfWeek: 0 }, chainLength: 6 }))
assert(!predicate({ run: { dayOfWeek: 1 }, chainLength: 6 }))
})
it('missing field', () => {
const predicate = compileExpression('run:dayOfWeek:0')
assert(predicate({ run: { dayOfWeek: 0 }, chainLength: 6 }))
assert(!predicate({ chainLength: 6 }))
assert(!predicate({ run: {}, chainLength: 6 }))
})
it('null-checking', () => {
const predicate = compileExpression('!vm')
assert(predicate({}))
assert(!predicate({ vm: [{ vm: 'vm' }] }))
})
it('throws on invalid expression', () => {
assert.throws(() => compileExpression('vm:('))
})
})
describe('isExpression', () => {
it('non-empty string', () => {
assert(isExpression('vm:tags:test'))
})
it('number', () => {
assert(!isExpression(0))
})
it('string[]', () => {
assert(!isExpression(['string']))
})
it('undefined', () => {
assert(!isExpression(undefined))
})
it('empty string', () => {
assert(!isExpression(''))
})
it('number', () => {
assert(!isExpression('3'))
})
it('number with spaces', () => {
assert(!isExpression(' 5 '))
})
})

View File

@@ -10,6 +10,8 @@ import { FullRemote } from './_vmRunners/FullRemote.mjs'
import { IncrementalRemote } from './_vmRunners/IncrementalRemote.mjs'
import { Throttle } from '@vates/generator-toolbox'
import createStreamThrottle from './_createStreamThrottle.mjs'
import { compileExpression, isExpression } from '../_expressionPredicate.mjs'
import { buildRunContext, buildVmContext } from '../_buildContext.mjs'
const noop = Function.prototype
@@ -60,21 +62,25 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
return
}
const runContext = job.runContext ?? buildRunContext(new Date(), settings.timezone)
const predicate = isExpression(settings.vmFilter) ? compileExpression(settings.vmFilter) : null
const vmsUuids = await sourceRemoteAdapter.listAllVms()
const queue = new Set(vmsUuids)
Task.info('vms', { vms: vmsUuids })
const nbVms = vmsUuids.length
let nbVms = vmsUuids.length
let nbVmsDone = 0
remoteAdapters = getAdaptersByRemote(remoteAdapters)
const allSettings = this._job.settings
const baseSettings = this._baseSettings
const queue = new Set(vmsUuids)
const taskByVmId = {}
const nTriesByVmId = {}
const handleVm = vmUuid => {
const handleVm = async vmUuid => {
if (nTriesByVmId[vmUuid] === undefined) {
nTriesByVmId[vmUuid] = 0
}
@@ -84,12 +90,26 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
const vmSettings = { ...settings, ...allSettings[vmUuid] }
const isLastRun = nTriesByVmId[vmUuid] === vmSettings.nRetriesVmBackupFailures + 1
const vmBackups = await sourceRemoteAdapter.listVmBackups(vmUuid, ({ mode }) => mode === job.mode)
// avoiding to create tasks for empty directories
if (vmBackups.length === 0) {
nbVms--
return
}
if (predicate !== null && !predicate(buildVmContext(vmBackups.at(-1).vm, runContext))) {
nbVms--
return
}
const opts = {
baseSettings,
config,
job,
healthCheckSr,
remoteAdapters,
runContext,
schedule,
settings: vmSettings,
sourceRemoteAdapter,
@@ -106,45 +126,39 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
throw new Error(`Job mode ${job.mode} not implemented for mirror backup`)
}
return sourceRemoteAdapter
.listVmBackups(vmUuid, ({ mode }) => mode === job.mode)
.then(vmBackups => {
// avoiding to create tasks for empty directories
if (vmBackups.length > 0) {
if (taskByVmId[vmUuid] === undefined) {
taskByVmId[vmUuid] = new Task(taskStart)
}
const task = taskByVmId[vmUuid]
// error has to be caught in the task to prevent its failure, but handled outside the task to execute another task.run()
let taskError
return task
.runInside(async () =>
vmBackup.run().catch(error => {
taskError = error
})
)
.then(result => {
if (taskError === undefined) {
nbVmsDone++
return task.success(result)
}
if (isLastRun) {
nbVmsDone++
return task.failure(taskError)
}
Task.set('progress', Math.round((nbVmsDone * 100) / nbVms))
// don't end the task
task.warning(`Retry the VM mirror backup due to an error`, {
attempt: nTriesByVmId[vmUuid],
error: taskError.message,
isRetry: true,
})
queue.add(vmUuid)
})
.catch(noop)
if (taskByVmId[vmUuid] === undefined) {
taskByVmId[vmUuid] = new Task(taskStart)
}
const task = taskByVmId[vmUuid]
// error has to be caught in the task to prevent its failure, but handled outside the task to execute another task.run()
let taskError
return task
.runInside(async () =>
vmBackup.run().catch(error => {
taskError = error
})
)
.then(result => {
if (taskError === undefined) {
nbVmsDone++
return task.success(result)
}
if (isLastRun) {
nbVmsDone++
return task.failure(taskError)
}
Task.set('progress', Math.round((nbVmsDone * 100) / nbVms))
// don't end the task
task.warning(`Retry the VM mirror backup due to an error`, {
attempt: nTriesByVmId[vmUuid],
error: taskError.message,
isRetry: true,
})
queue.add(vmUuid)
})
.catch(noop)
}
const { concurrency } = settings
const _handleVm = !concurrency ? handleVm : limitConcurrency(concurrency)(handleVm)

View File

@@ -12,6 +12,7 @@ import { Throttle } from '@vates/generator-toolbox'
import { asyncEach } from '@vates/async-each'
import createStreamThrottle from './_createStreamThrottle.mjs'
import { selectSynchronizedSnapshotVms } from './_selectSynchronizedSnapshotVms.mjs'
import { buildRunContext } from '../_buildContext.mjs'
const noop = Function.prototype
@@ -68,6 +69,7 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
const getSnapshotNameLabel = this._getSnapshotNameLabel
const schedule = this._schedule
const settings = this._settings
const runContext = job.runContext ?? buildRunContext(new Date(), settings.timezone)
const throttleGenerator = new Throttle(settings.maxExportRate)
const throttleStream = createStreamThrottle(settings.maxExportRate)
@@ -151,6 +153,7 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
healthCheckSr,
job,
remoteAdapters,
runContext,
schedule,
settings: vmSettings,
srs,
@@ -249,6 +252,7 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
throttleGenerator,
throttleStream,
vm,
runContext,
}
const vmBackup = this._getVmBackup(job.mode, opts)

View File

@@ -53,7 +53,7 @@ export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote
'writer.run()'
)
// for healthcheck
this._tags = metadata.vm.tags
this._vm = metadata.vm
nbTransferredVms++
Task.set('progress', Math.round((nbTransferredVms * 100) / nbTransferrableVms))
}

View File

@@ -131,7 +131,7 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
await this._callWriters(writer => writer.cleanup(), 'writer.cleanup()')
// for healthcheck
this._tags = metadata.vm.tags
this._vm = metadata.vm
nbTransferredVms++
Task.set('progress', Math.round((nbTransferredVms * 100) / nbTransferrableVms))
}

View File

@@ -18,10 +18,19 @@ import { ThrottledDisk, SynchronizedDisk } from '@xen-orchestra/disk-transform'
import { AggregatedIncrementalRemoteWriter } from '../_writers/AggregatedIncrementalRemoteWriter.mjs'
import { AggregatedIncrementalXapiWriter } from '../_writers/AggregatedIncrementalXapiWriter.mjs'
import { TaskProgressHandler } from './_TaskProgressHandler.mjs'
import { compileExpression, isExpression } from '../../_expressionPredicate.mjs'
import { buildVmContext } from '../../_buildContext.mjs'
const { debug } = createLogger('xo:backups:IncrementalXapiVmBackup')
export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends AbstractXapi {
constructor(props) {
super(props)
const fullInterval = this._settings.fullInterval
this._fullIntervalPredicate = isExpression(fullInterval) ? compileExpression(fullInterval) : null
}
_getWriters() {
return [
IncrementalRemoteWriter,
@@ -229,15 +238,17 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
}
// we do tafter checkbasevdis because we want the writer to know the target VM
// especially on replication when we alwayr update the same VM
// especially on replication when we always update the same VM
const fullInterval = this._settings.fullInterval
if (fullInterval !== 0 && fullInterval <= deltaChainLength + 1) {
debug('not using base VM because fullInterval reached', {
fullInterval,
deltaChainLength,
eq: fullInterval < deltaChainLength + 1,
dc1: deltaChainLength + 1,
})
const chainLength = (deltaChainLength ?? 0) + 1
if (isExpression(fullInterval)) {
const context = buildVmContext(this._vm, this._runContext, chainLength)
if (this._fullIntervalPredicate(context)) {
debug('forcing full backup: fullInterval expression matched', { fullInterval })
return
}
} else if (fullInterval !== 0 && fullInterval <= chainLength) {
debug('not using base VM because fullInterval reached', { fullInterval, chainLength })
return
}

View File

@@ -1,6 +1,8 @@
import { asyncMap } from '@xen-orchestra/async-map'
import { createLogger } from '@xen-orchestra/log'
import { Task } from '@vates/task'
import { compileExpression, isExpression } from '../../_expressionPredicate.mjs'
import { buildVmContext } from '../../_buildContext.mjs'
const { debug, warn } = createLogger('xo:backups:AbstractVmRunner')
@@ -73,24 +75,58 @@ export const Abstract = class AbstractVmBackupRunner {
}
// check if current VM has tags
const tags = this._tags
const tags = this._vm.tags
// accept both 'xo:no-health-check' and 'xo:no-health-check=reason'
const vmAlwaysIgnored = tags.some(t => t.startsWith('xo:no-health-check'))
const intersect = !vmAlwaysIgnored && settings.healthCheckVmsWithTags.some(t => tags.includes(t))
if (vmAlwaysIgnored || (settings.healthCheckVmsWithTags.length !== 0 && !intersect)) {
// create a task to have an info in the logs and reports
if (tags.some(t => t.startsWith('xo:no-health-check'))) {
return Task.run(
{
properties: { name: 'health check' },
properties: {
name: 'health check',
},
},
() => {
Task.info(`This VM doesn't match the health check's tags for this schedule`)
Task.info(`This VM is excluded from health checks via tag`)
}
)
}
if (isExpression(settings.healthCheckVmsWithTags)) {
const context = buildVmContext(this._vm, this._runContext)
if (!this._healthCheckPredicate(context)) {
return Task.run(
{
properties: { name: 'health check' },
},
() => {
Task.info(`VM did not match healthCheckVmsWithTags expression`)
}
)
}
} else {
const intersect = settings.healthCheckVmsWithTags.some(t => tags.includes(t))
if (settings.healthCheckVmsWithTags.length !== 0 && !intersect) {
// create a task to have an info in the logs and reports
return Task.run(
{
properties: { name: 'health check' },
},
() => {
Task.info(`This VM doesn't match the health check's tags for this schedule`)
}
)
}
}
await this._callWriters(writer => writer.healthCheck(), 'writer.healthCheck()')
}
_compileHealthCheckPredicate() {
const { healthCheckVmsWithTags } = this._settings
if (isExpression(healthCheckVmsWithTags)) {
this._healthCheckPredicate = compileExpression(healthCheckVmsWithTags)
} else {
this._healthCheckPredicate = undefined
}
}
}

View File

@@ -20,6 +20,7 @@ export const AbstractRemote = class AbstractRemoteVmBackupRunner extends Abstrac
job,
healthCheckSr,
remoteAdapters,
runContext,
schedule,
settings,
sourceRemoteAdapter,
@@ -40,11 +41,14 @@ export const AbstractRemote = class AbstractRemoteVmBackupRunner extends Abstrac
this._throttleGenerator = throttleGenerator
this._throttleStream = throttleStream
this._vmUuid = vmUuid
this._runContext = runContext
const allSettings = job.settings
const writers = new Set()
this._writers = writers
this._compileHealthCheckPredicate()
const [BackupWriter, AggregratedBackupWriter] = this._getRemoteWriters()
if (settings.distributeBackups) {
writers.add(

View File

@@ -36,6 +36,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
job,
remoteAdapters,
remotes,
runContext,
schedule,
settings,
srs,
@@ -63,12 +64,13 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
this.timestamp = undefined
// VM currently backed up
const tags = (this._tags = vm.tags)
const tags = vm.tags
// VM (snapshot) that is really exported
this._exportedVm = undefined
this._synchronizedSnapshotTimestamp = synchronizedSnapshotTimestamp
this._vm = vm
this._runContext = runContext
this._getSnapshotNameLabel = getSnapshotNameLabel
this._isIncremental = job.mode === 'delta'
@@ -90,6 +92,9 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
settings.offlineSnapshot = true
}
this._settings = settings
this._compileHealthCheckPredicate()
// Create writers
{
const writers = new Set()

View File

@@ -40,6 +40,7 @@
"@xen-orchestra/backup-archive": "^1.2.0",
"app-conf": "^3.0.0",
"compare-versions": "^6.0.0",
"complex-matcher": "^1.1.1",
"d3-time-format": "^4.1.0",
"decorator-synchronized": "^0.6.0",
"golike-defer": "^0.5.1",
@@ -57,6 +58,7 @@
"value-matcher": "^0.2.0",
"vhd-lib": "^4.17.0",
"xen-api": "^5.1.0",
"xo-remote-parser": "^2.0.0",
"yazl": "^2.5.1"
},
"devDependencies": {

View File

@@ -9,7 +9,7 @@ async function consumeGenerator(source: AsyncGenerator) {
return 'finished'
}
test('Disk class', async t => {
const testRes = await t.test('synchronised fork disk ', async () => {
const testRes = await t.test('synchronized fork disk ', async () => {
const blockSize = 1024
const nbBlocks = 20
const source = new DebugDisk({ nbBlocks, blockSize, fillRate: 100 })

View File

@@ -18,7 +18,7 @@ ee.on('start', async function () {
// listeners have settled
await ee.emitAsync('start')
// by default, it will rejects as soon as one listener reject, you can customise
// by default, it will rejects as soon as one listener reject, you can customize
// error handling though:
await ee.emitAsync(
{

View File

@@ -36,7 +36,7 @@ ee.on('start', async function () {
// listeners have settled
await ee.emitAsync('start')
// by default, it will rejects as soon as one listener reject, you can customise
// by default, it will rejects as soon as one listener reject, you can customize
// error handling though:
await ee.emitAsync(
{

View File

@@ -1,5 +1,11 @@
# ChangeLog
## **next**
- [Side panels] Side panels can be resized like the treeview, whose default width now adapts to the screen size (PR [#10725](https://github.com/vatesfr/xen-orchestra/pull/10725))
- [Side panels] Values stay next to their label when they fit and move below it otherwise, fields with several values (IP addresses, bond devices) get a copy button per value and a "Copy all" action, and rows, links and statuses are more evenly spaced and sized (PR [#10733](https://github.com/vatesfr/xen-orchestra/pull/10733))
- [Treeview] Fix translations of the tooltip for VM and host statuses in treeview (PR [#10643](https://github.com/vatesfr/xen-orchestra/pull/10643))
## **0.27.0** (2026-10-29)
- [New network] Choose between no NBD, secure NBD and insecure NBD (not encrypted) when creating a network (PR [#10540](https://github.com/vatesfr/xen-orchestra/pull/10540))

View File

@@ -12,7 +12,7 @@
<!-- PIF -->
<UiPanelCard>
<UiPanelCardTitle :id="pif.uuid" size="medium" :label="isBond ? t('bond') : t('pif')" />
<div class="content">
<VtsCardRowList>
<!-- NETWORK -->
<VtsCardRowKeyValue>
<template #key>
@@ -82,34 +82,15 @@
</UiTagsList>
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
<!-- NETWORK INFORMATION -->
<UiPanelCard>
<UiPanelCardTitle size="medium" :label="t('network-information')" />
<div class="content">
<VtsCardRowList>
<!-- IP ADDRESSES -->
<div v-if="ipAddresses.length">
<VtsCardRowKeyValue v-for="(ip, index) in ipAddresses" :key="ip">
<template #key>
<div v-if="index === 0">{{ t('ip-addresses') }}</div>
</template>
<template #value>
<span v-tooltip class="text-ellipsis">{{ ip }}</span>
</template>
<template #addons>
<VtsCopyButton :value="ip" />
<VtsCopyAllMenuItem v-if="index === 0 && ipAddresses.length > 1" :values="ipAddresses" />
</template>
</VtsCardRowKeyValue>
</div>
<VtsCardRowKeyValue v-else>
<template #key>
{{ t('ip-addresses') }}
</template>
<template #value>
<span class="value" />
</template>
<VtsCardRowKeyValue :values="ipAddresses">
<template #key>{{ t('ip-addresses') }}</template>
</VtsCardRowKeyValue>
<!-- MAC ADDRESSES -->
<VtsCardRowKeyValue>
@@ -173,26 +154,15 @@
</template>
</VtsCardRowKeyValue>
<!-- BOND DEVICES -->
<div>
<VtsCardRowKeyValue v-for="(device, index) in bondDevices" :key="device">
<template #key>
<div v-if="index === 0">{{ t('bond-devices') }}</div>
</template>
<template #value>
<span v-tooltip class="text-ellipsis">{{ device }}</span>
</template>
<template v-if="device" #addons>
<VtsCopyButton :value="device" />
<VtsCopyAllMenuItem v-if="index === 0 && bondDevices.length > 1" :values="bondDevicesFiltered" />
</template>
</VtsCardRowKeyValue>
</div>
</div>
<VtsCardRowKeyValue v-if="bondDevicesFiltered.length > 0" :values="bondDevicesFiltered">
<template #key>{{ t('bond-devices') }}</template>
</VtsCardRowKeyValue>
</VtsCardRowList>
</UiPanelCard>
<!-- PROPERTIES -->
<UiPanelCard>
<UiPanelCardTitle size="medium" :label="t('properties')" />
<div class="content">
<VtsCardRowList>
<!-- MTU -->
<VtsCardRowKeyValue>
<template #key>
@@ -226,7 +196,7 @@
<VtsCopyButton :value="networkPurpose" />
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
</VtsSidePanel>
@@ -239,7 +209,7 @@ import { useNetworkStore } from '@/stores/xen-api/network.store.ts'
import { usePifMetricsStore } from '@/stores/xen-api/pif-metrics.store.ts'
import { usePifStore } from '@/stores/xen-api/pif.store.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyAllMenuItem from '@core/components/copy-all-menu-item/VtsCopyAllMenuItem.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsForgetButton from '@core/components/forget-button/VtsForgetButton.vue'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
@@ -331,12 +301,6 @@ const speed = computed(() => {
<style scoped lang="postcss">
.host-pif-side-panel {
.content {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.network {
display: flex;
gap: 0.8rem;

View File

@@ -21,7 +21,7 @@
<template v-if="network" #default>
<UiPanelCard>
<UiPanelCardTitle :id="network.uuid" size="medium" :label="network.name_label" />
<div class="content">
<VtsCardRowList>
<!-- DESCRIPTION -->
<VtsCardRowKeyValue truncate align-top>
<template #key>{{ t('description') }}</template>
@@ -63,7 +63,7 @@
<template #key>{{ t('locking-mode-default') }}</template>
<template #value>{{ networkDefaultLockingMode }}</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
<UiPanelCard v-if="pifsCount && pifsCount > 0">
<UiPanelCardTitle size="medium" :label="t('pifs')" :counter="pifsCount" />
@@ -97,6 +97,7 @@ import type { XenApiNetwork } from '@/libs/xen-api/xen-api.types.ts'
import { useNetworkDelete } from '@/modules/network/composables/use-network-delete.composable.ts'
import { usePifStore } from '@/stores/xen-api/pif.store.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsDeleteButton from '@core/components/delete-button/VtsDeleteButton.vue'
import MenuItem from '@core/components/menu/MenuItem.vue'
@@ -149,12 +150,6 @@ const { copy, copied, isSupported: isClipboardSupported } = useClipboard({ sourc
<style scoped lang="postcss">
.pool-network-side-panel {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.text-left {
text-align: left;
}

View File

@@ -1,5 +1,5 @@
<template>
<VtsQuickInfoCard class="vm-dashboard-quick-info" :loading="!isReady">
<VtsQuickInfoCard :loading="!isReady" class="vm-dashboard-quick-info">
<VtsKeyValueList>
<VtsKeyValueRow :label="t('state')">
<template #value>
@@ -19,11 +19,11 @@
<template #value>
<UiLink
v-if="host"
:to="`/host/${host.uuid}`"
size="medium"
:icon="hostIcon"
:is-primary="isPoolMaster"
:primary-tooltip="t('master')"
:to="`/host/${host.uuid}`"
size="medium"
>
{{ host.name_label }}
</UiLink>
@@ -54,9 +54,9 @@
</template>
<script lang="ts" setup>
import { VM_POWER_STATE } from '@/libs/xen-api/xen-api.enums.ts'
import type { XenApiVm } from '@/libs/xen-api/xen-api.types.ts'
import { getHostIcon } from '@/modules/host/utils/host.util.ts'
import { useVmUtils } from '@/modules/vm/composables/vm-utils.composable.ts'
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
import { usePoolStore } from '@/stores/xen-api/pool.store.ts'
import { useVmGuestMetricsStore } from '@/stores/xen-api/vm-guest-metrics.store.ts'
@@ -70,7 +70,6 @@ import VtsTag from '@core/components/tag/VtsTag.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiTagsList from '@core/components/ui/tag/UiTagsList.vue'
import { useTimeAgo } from '@core/composables/locale-time-ago.composable.ts'
import { useMapper } from '@core/packages/mapper'
import { formatSizeRaw } from '@core/utils/size.util.ts'
import { formatDate, parseDateTime } from '@core/utils/time.util.ts'
import { computed } from 'vue'
@@ -98,16 +97,7 @@ const isPoolMaster = computed(() => pool.value?.master === host.value?.$ref)
const hostIcon = computed(() => getHostIcon(host.value, getHostPowerState(host.value)))
const powerState = useMapper(
() => vm.power_state,
{
[VM_POWER_STATE.RUNNING]: { icon: 'status:running-circle', text: t('vm:status:running') },
[VM_POWER_STATE.HALTED]: { icon: 'status:halted-circle', text: t('vm:status:halted') },
[VM_POWER_STATE.PAUSED]: { icon: 'status:paused-circle', text: t('vm:status:paused') },
[VM_POWER_STATE.SUSPENDED]: { icon: 'status:suspended-circle', text: t('vm:status:suspended') },
},
VM_POWER_STATE.RUNNING
)
const { powerState } = useVmUtils(() => vm)
function getValidDateTime(dateTime: string | undefined): string | undefined {
return dateTime === '19700101T00:00:00Z' ? undefined : dateTime

View File

@@ -1,7 +1,7 @@
<template>
<UiPanelCard class="host-hardware-specifications-card">
<UiPanelCard>
<UiPanelCardTitle size="medium" :label="t('hardware-specifications')" />
<div class="content">
<VtsCardRowList>
<VtsCardRowKeyValue>
<template #key>{{ t('manufacturer-info') }}</template>
<template #value>{{ manufacturerInfo }}</template>
@@ -16,7 +16,7 @@
<VtsCopyButton :value="coreSocketInfo" />
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
@@ -24,6 +24,7 @@
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
import { getHostCoreSocketInfo, getHostManufacturerInfo } from '@/modules/host/utils/host.util.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
@@ -40,13 +41,3 @@ const manufacturerInfo = computed(() => getHostManufacturerInfo(host))
const coreSocketInfo = computed(() => getHostCoreSocketInfo(host))
</script>
<style lang="postcss" scoped>
.host-hardware-specifications-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -7,7 +7,7 @@
:icon="hostIcon"
:to="{ name: '/host/[uuid]/dashboard', params: { uuid: host.uuid } }"
/>
<div class="content">
<VtsCardRowList class="content">
<VtsCardRowKeyValue>
<template #key>{{ t('state') }}</template>
<template #value>
@@ -98,7 +98,7 @@
<VtsCopyButton :value="noMissingPatches ? t('patches-up-to-date') : t('n-missing', nMissingPatches)" />
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
@@ -110,6 +110,7 @@ import { getHostIcon } from '@/modules/host/utils/host.util.ts'
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
import { usePoolStore } from '@/stores/xen-api/pool.store.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsEnabledState from '@core/components/enabled-state/VtsEnabledState.vue'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
@@ -146,10 +147,6 @@ const noMissingPatches = computed(() => nMissingPatches.value === 0)
<style lang="postcss" scoped>
.host-infos-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
.value {
display: flex;
align-items: center;

View File

@@ -1,7 +1,7 @@
<template>
<UiPanelCard class="host-network-card">
<UiPanelCard>
<UiPanelCardTitle :label="t('network')" size="medium" />
<div class="content">
<VtsCardRowList>
<VtsCardRowKeyValue>
<template #key>{{ t('management-ip') }}</template>
<template #value>{{ host.address }}</template>
@@ -9,37 +9,13 @@
<VtsCopyButton :value="host.address" />
</template>
</VtsCardRowKeyValue>
<template v-if="ipV4Addresses.length > 0">
<VtsCardRowKeyValue v-for="(ip, index) in ipV4Addresses" :key="ip">
<template #key>
<div v-if="index === 0">{{ t('ipv4-addresses') }}</div>
</template>
<template #value>{{ ip }}</template>
<template #addons>
<VtsCopyButton :value="ip" />
<VtsCopyAllMenuItem v-if="index === 0 && ipV4Addresses.length > 1" :values="ipV4Addresses" />
</template>
</VtsCardRowKeyValue>
</template>
<VtsCardRowKeyValue v-else>
<VtsCardRowKeyValue :values="ipV4Addresses">
<template #key>{{ t('ipv4-addresses') }}</template>
</VtsCardRowKeyValue>
<template v-if="ipV6Addresses.length > 0">
<VtsCardRowKeyValue v-for="(ip, index) in ipV6Addresses" :key="ip">
<template #key>
<div v-if="index === 0">{{ t('ipv6-addresses') }}</div>
</template>
<template #value>{{ ip }}</template>
<template #addons>
<VtsCopyButton :value="ip" />
<VtsCopyAllMenuItem v-if="index === 0 && ipV6Addresses.length > 1" :values="ipV6Addresses" />
</template>
</VtsCardRowKeyValue>
</template>
<VtsCardRowKeyValue v-else>
<VtsCardRowKeyValue :values="ipV6Addresses">
<template #key>{{ t('ipv6-addresses') }}</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
@@ -47,7 +23,7 @@
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
import { usePifStore } from '@/stores/xen-api/pif.store.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyAllMenuItem from '@core/components/copy-all-menu-item/VtsCopyAllMenuItem.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
@@ -66,13 +42,3 @@ const pifs = computed(() => pifsByHost.value.get(host.$ref))
const ipV4Addresses = computed(() => pifs.value?.map(pif => pif.IP).filter(ip => ip) ?? [])
const ipV6Addresses = computed(() => pifs.value?.flatMap(pif => pif.IPv6.filter(ip => ip)) ?? [])
</script>
<style lang="postcss" scoped>
.host-network-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<UiPanelCard class="host-software-card">
<UiPanelCard>
<UiPanelCardTitle size="medium" :label="t('software')" />
<div class="content">
<VtsCardRowList>
<VtsCardRowKeyValue>
<template #key>{{ t('version') }}</template>
<template #value>{{ host.software_version.product_version }}</template>
@@ -9,13 +9,14 @@
<VtsCopyButton :value="host.software_version.product_version" />
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
<script lang="ts" setup>
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
@@ -27,13 +28,3 @@ defineProps<{
const { t } = useI18n()
</script>
<style lang="postcss" scoped>
.host-software-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -5,7 +5,7 @@ import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
import { usePoolStore } from '@/stores/xen-api/pool.store.ts'
import type { IconName } from '@core/icons'
import { useTimeAgo } from '@core/composables/locale-time-ago.composable.ts'
import { useMapper } from '@core/packages/mapper'
import { createMapper, useMapper } from '@core/packages/mapper'
import { parseDateTime } from '@core/utils/time.util.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import { HOST_POWER_STATE } from '@vates/types'
@@ -70,20 +70,31 @@ export function useHostUtils(rawHost: MaybeRefOrGetter<XenApiHost | undefined>)
const currentOperation = useMapper<string, string>(
() => pendingStateOperation.value,
{
() => ({
[HOST_OPERATION.ENABLE]: t('operation:enable'),
[HOST_OPERATION.EVACUATE]: t('operation:evacuate'),
[HOST_OPERATION.POWER_ON]: t('operation:start'),
[HOST_OPERATION.REBOOT]: t('operation:clean-reboot'),
[HOST_OPERATION.SHUTDOWN]: t('operation:shutdown'),
unknown: '',
},
}),
'unknown'
)
const getHostStatus = createMapper<HostState, { text: string; icon: IconName | undefined }>(
() => ({
running: { text: t('host:status:running'), icon: 'status:running-circle' },
halted: { text: t('host:status:halted'), icon: 'status:halted-circle' },
disabled: { text: t('host:status:disabled'), icon: 'status:host-disabled-circle' },
unknown: { text: t('host:status:unknown'), icon: undefined },
}),
'unknown'
)
return {
isMaster,
isRunning,
getHostStatus,
hostState,
powerState,
isChangingState,

View File

@@ -1,12 +1,12 @@
<template>
<UiPanelCard class="storage-repository-infos-card">
<UiPanelCard>
<UiPanelCardTitle
:id="sr.uuid"
size="medium"
:label="sr.name_label"
:icon="sr.name_label ? srStatusIcon : undefined"
/>
<div class="content">
<VtsCardRowList>
<VtsCardRowKeyValue>
<template #key>{{ t('status') }}</template>
<template #value>
@@ -56,7 +56,7 @@
<template #key>{{ t('high-availability') }}</template>
<template #value><VtsStatus :status="isHaSr" /></template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
@@ -66,6 +66,7 @@ import { useSrUtils } from '@/modules/storage-repository/composables/sr-utils.co
import { useSrStore } from '@/stores/xen-api/sr.store.ts'
import type { SrScope } from '@core/types/storage-repository.type.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import VtsTag from '@core/components/tag/VtsTag.vue'
@@ -96,13 +97,3 @@ const allocationStrategy = computed(() => getAllocationStrategy(sr) ?? t('unknow
const isHaSr = computed(() => isHaSrForPool(sr, pool))
</script>
<style scoped lang="postcss">
.storage-repository-infos-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -5,10 +5,10 @@
{{ t('no-pbd-attached') }}
</VtsStateHero>
<template v-else>
<div class="content">
<VtsCardRowList class="content">
<VtsStatus :status="srConnectionStatus" />
</div>
<div v-if="areSomePbdsDisconnected" class="content">
</VtsCardRowList>
<VtsCardRowList v-if="areSomePbdsDisconnected" class="content">
<template v-for="(pbd, index) in disconnectedPbds" :key="pbd.uuid">
<VtsDivider v-if="index > 0" class="divider" type="stretch" />
<UiPanelCardTitle size="small" :label="t('disconnected-pbd-number', { n: index + 1 })" class="subtitle" />
@@ -31,7 +31,7 @@
accent="info"
/>
</template>
</div>
</VtsCardRowList>
</template>
</UiPanelCard>
</template>
@@ -43,6 +43,7 @@ import { usePbdUtils } from '@/modules/storage-repository/composables/pbd-utils.
import { useSrUtils } from '@/modules/storage-repository/composables/sr-utils.composable.ts'
import type { SrScope } from '@core/types/storage-repository.type.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsDivider from '@core/components/divider/VtsDivider.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
@@ -70,10 +71,6 @@ const { areSomePbdsDisconnected, disconnectedPbds } = usePbdUtils(pbdsInScope)
<style scoped lang="postcss">
.storage-repository-pbds-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
.subtitle {
margin-block-end: 0.4rem;
}

View File

@@ -7,7 +7,7 @@
>
{{ branch.data.name_label || '(Host)' }}
<template #icon>
<VtsObjectIcon v-tooltip="hostState" type="host" size="medium" :state="hostState" />
<VtsObjectIcon v-tooltip="powerState.text" type="host" size="medium" :state="hostState" />
</template>
<template #addons>
<UiLoader v-if="isChangingState" v-tooltip="currentOperation" />
@@ -63,7 +63,8 @@ const { t } = useI18n()
const { isMasterHost } = usePoolStore().subscribe()
const { runningVmsCountByHostRef } = useVmStore().subscribe()
const { hostState, isChangingState, currentOperation } = useHostUtils(() => branch.data)
const { getHostStatus, hostState, isChangingState, currentOperation } = useHostUtils(() => branch.data)
const powerState = computed(() => getHostStatus(hostState.value))
const isMaster = computed(() => isMasterHost(branch.data.$ref))

View File

@@ -1,9 +1,9 @@
<template>
<VtsTreeItem expanded :node-id="leaf.dataId">
<VtsTreeItem :node-id="leaf.dataId" expanded>
<UiTreeItemLabel :route="{ name: '/vm/[uuid]', params: { uuid: leaf.data.uuid } }" no-indent>
{{ leaf.data.name_label || '(VM)' }}
<template #icon>
<VtsObjectIcon size="medium" :state="powerState" type="vm" />
<VtsObjectIcon v-tooltip="powerState.text" :state="vmState" size="medium" type="vm" />
</template>
<template #addons>
<UiLoader v-if="isChangingState" v-tooltip="{ placement: 'top', content: currentOperation }" />
@@ -11,10 +11,10 @@
<template #trigger="{ open, isOpen }">
<UiButtonIcon
v-tooltip="{ placement: 'top', content: t('quick-actions') }"
icon="action:more-actions"
accent="brand"
size="small"
:selected="isOpen"
accent="brand"
icon="action:more-actions"
size="small"
@click="open($event)"
/>
</template>
@@ -26,10 +26,10 @@
</template>
<script lang="ts" setup>
import type { VM_POWER_STATE } from '@/libs/xen-api/xen-api.enums.ts'
import type { VmLeaf } from '@/modules/treeview/types/tree.type.ts'
import VmTreeActions from '@/modules/vm/components/actions/VmTreeActions.vue'
import { useVmOperation } from '@/modules/vm/composables/vm-operation.composable.ts'
import { useVmUtils } from '@/modules/vm/composables/vm-utils.composable.ts'
import MenuList from '@core/components/menu/MenuList.vue'
import VtsObjectIcon from '@core/components/object-icon/VtsObjectIcon.vue'
import VtsTreeItem from '@core/components/tree/VtsTreeItem.vue'
@@ -37,6 +37,7 @@ import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import UiLoader from '@core/components/ui/loader/UiLoader.vue'
import UiTreeItemLabel from '@core/components/ui/tree-item-label/UiTreeItemLabel.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { toLower } from 'lodash-es'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -47,6 +48,7 @@ const { leaf } = defineProps<{
const { t } = useI18n()
const { isChangingState, currentOperation } = useVmOperation(() => leaf.data)
const { powerState } = useVmUtils(() => leaf.data)
const powerState = computed(() => leaf.data.power_state.toLowerCase() as Lowercase<VM_POWER_STATE>)
const vmState = computed(() => toLower(leaf.data.power_state))
</script>

View File

@@ -1,7 +1,7 @@
<template>
<UiPanelCard class="card-container">
<UiPanelCardTitle size="medium" :label="t('configuration')" />
<div class="content">
<VtsCardRowList>
<VdiFormatCardItem :format="vdi.sm_config['image-format']" />
<VtsCardRowKeyValue>
<template #key>
@@ -43,7 +43,7 @@
<VtsStatus :status="isBootable" />
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
@@ -53,6 +53,7 @@ import type { XenApiVbd, XenApiVdi } from '@/libs/xen-api/xen-api.types.ts'
import VdiFormatCardItem from '@/modules/vdi/components/list/panel/card-items/VdiFormatCardItem.vue'
import { useSrStore } from '@/stores/xen-api/sr.store.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
@@ -73,11 +74,6 @@ const isBootable = computed(() => vbd?.bootable ?? false)
<style scoped lang="postcss">
.card-container {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.storage {
display: flex;
gap: 0.8rem;

View File

@@ -1,7 +1,7 @@
<template>
<UiPanelCard class="card-container">
<UiPanelCard>
<UiPanelCardTitle :id="vdi.uuid" size="medium" :label="vdi.name_label" :icon="vdiIcon" />
<div class="content">
<VtsCardRowList>
<VtsCardRowKeyValue truncate align-top>
<template #key>{{ t('description') }}</template>
<template #value>{{ vdi.name_description }}</template>
@@ -36,7 +36,7 @@
<VtsCopyButton :value="vbd.device" />
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
@@ -45,6 +45,7 @@ import type { XenApiVbd, XenApiVdi } from '@/libs/xen-api/xen-api.types.ts'
import { useVbdsStatus, type VbdAttachmentStatus } from '@/modules/vbd/composables/use-vbds-status.composable.ts'
import type { IconName } from '@core/icons'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import VtsTag from '@core/components/tag/VtsTag.vue'
@@ -79,13 +80,3 @@ const vbdsStatus = useMapper<VbdAttachmentStatus, (typeof CONNECTION_STATUS)[key
'noneAttached'
)
</script>
<style scoped lang="postcss">
.card-container {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -8,7 +8,7 @@
<!-- VIF -->
<UiPanelCard>
<UiPanelCardTitle :id="vif.uuid" size="medium" :label="t('vif')" />
<div class="content">
<VtsCardRowList>
<!-- NETWORK -->
<VtsCardRowKeyValue>
<template #key>
@@ -79,32 +79,15 @@
{{ isTxChecksummingEnabled(vif) }}
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
<!-- VIF NETWORK INFORMATION -->
<UiPanelCard>
<UiPanelCardTitle size="medium" :label="t('network-information')" />
<div class="content">
<VtsCardRowList>
<!-- IP ADDRESSES -->
<div v-if="ipAddresses.length">
<VtsCardRowKeyValue v-for="(ip, index) in ipAddresses" :key="ip">
<template #key>
<div v-if="index === 0">{{ t('ip-addresses') }}</div>
</template>
<template #value>{{ ip }}</template>
<template #addons>
<VtsCopyButton :value="ip" />
<VtsCopyAllMenuItem v-if="index === 0 && ipAddresses.length > 1" :values="ipAddresses" />
</template>
</VtsCardRowKeyValue>
</div>
<VtsCardRowKeyValue v-else>
<template #key>
{{ t('ip-addresses') }}
</template>
<template #value>
<span class="value" />
</template>
<VtsCardRowKeyValue :values="ipAddresses">
<template #key>{{ t('ip-addresses') }}</template>
</VtsCardRowKeyValue>
<!-- MAC ADDRESSES -->
<VtsCardRowKeyValue>
@@ -118,7 +101,7 @@
<VtsCopyButton :value="vif.MAC" />
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
</VtsSidePanel>
@@ -133,7 +116,7 @@ import { useNetworkStore } from '@/stores/xen-api/network.store.ts'
import { useVmGuestMetricsStore } from '@/stores/xen-api/vm-guest-metrics.store.ts'
import { useVmStore } from '@/stores/xen-api/vm.store.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyAllMenuItem from '@core/components/copy-all-menu-item/VtsCopyAllMenuItem.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsSidePanel from '@core/components/panel/VtsSidePanel.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
@@ -187,12 +170,6 @@ const rateLimit = computed(() => {
<style scoped lang="postcss">
.vm-vifs-side-panel {
.content {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.value:empty::before {
content: '-';
}

View File

@@ -17,7 +17,7 @@ export function useVmOperation(rawVm: MaybeRefOrGetter<XenApiVm | undefined>) {
const currentOperation = useMapper<string, string>(
() => Object.values(vm.value?.current_operations ?? {})[0],
{
() => ({
start: t('operation:start'),
start_on: t('operation:start-on-host'),
pause: t('operation:pause'),
@@ -36,7 +36,7 @@ export function useVmOperation(rawVm: MaybeRefOrGetter<XenApiVm | undefined>) {
export: t('operation:export'),
import: t('operation:import'),
unknown: '',
},
}),
'unknown'
)

View File

@@ -0,0 +1,28 @@
import { VM_POWER_STATE } from '@/libs/xen-api/xen-api.enums.ts'
import type { XenApiVm } from '@/libs/xen-api/xen-api.types.ts'
import type { IconName } from '@core/icons'
import { useMapper } from '@core/packages/mapper'
import { toComputed } from '@core/utils/to-computed.util.ts'
import type { MaybeRefOrGetter } from 'vue'
import { useI18n } from 'vue-i18n'
export function useVmUtils(rawVm: MaybeRefOrGetter<XenApiVm>) {
const { t } = useI18n()
const vm = toComputed(rawVm)
const powerState = useMapper<VM_POWER_STATE, { icon: IconName; text: string }>(
() => vm.value.power_state,
() => ({
[VM_POWER_STATE.RUNNING]: { icon: 'status:running-circle', text: t('vm:status:running') },
[VM_POWER_STATE.HALTED]: { icon: 'status:halted-circle', text: t('vm:status:halted') },
[VM_POWER_STATE.PAUSED]: { icon: 'status:paused-circle', text: t('vm:status:paused') },
[VM_POWER_STATE.SUSPENDED]: { icon: 'status:suspended-circle', text: t('vm:status:suspended') },
}),
VM_POWER_STATE.RUNNING
)
return {
powerState,
}
}

View File

@@ -317,6 +317,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/story/web-core/card/vts-card-row-list': RouteRecordInfo<
'/story/web-core/card/vts-card-row-list',
'/story/web-core/card/vts-card-row-list',
Record<never, never>,
Record<never, never>,
| never
>,
'/story/web-core/cell-object/vts-cell-object': RouteRecordInfo<
'/story/web-core/cell-object/vts-cell-object',
'/story/web-core/cell-object/vts-cell-object',
@@ -1385,6 +1392,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/stories/web-core/card/vts-card-row-list.story.vue': {
routes:
| '/story/web-core/card/vts-card-row-list'
views:
| never
}
'src/stories/web-core/cell-object/vts-cell-object.story.vue': {
routes:
| '/story/web-core/cell-object/vts-cell-object'

View File

@@ -3,7 +3,7 @@
v-slot="{ properties, settings }"
:params="[
slot('key').help('Meant to receive a title'),
slot('value').help('Meant to receive text, tags or other value'),
slot('value').help('Meant to receive text, tags or other value. Overrides the value rendered from `values`'),
slot('addons').help('Meant to receive icons or other actions buttons '),
prop('truncate')
.type('boolean | number')
@@ -21,6 +21,16 @@
.help('Whether to align the key on the first line in case of multiline value')
.type('boolean')
.widget(),
prop('values')
.help('Values rendered as text with a copy button each, plus a copy all action when there are several')
.type('string[]')
.widget(object())
.preset(['192.168.1.10', '192.168.1.11', '10.0.0.12', 'fe80::a00:27ff:fe4e:66a1']),
prop('maxVisibleValues')
.help('Number of values shown before a see more button')
.type('number')
.widget()
.preset(3),
setting('valueSlotDescription')
.preset(
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Velit facilis pariatur maiores officia fugit odit, doloremque obcaecati quisquam distinctio adipisci fugiat incidunt hic rerum? Ullam, natus. Iste eum porro dolorum!'
@@ -29,12 +39,16 @@
]"
>
<UiCard class="card">
<VtsCardRowKeyValue v-bind="properties">
<VtsCardRowKeyValue :values="properties.values" :max-visible-values="properties.maxVisibleValues">
<template #key>IP addresses</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue v-bind="{ ...properties, values: undefined }">
<template #key>Description</template>
<template #value>{{ settings.valueSlotDescription }}</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue v-bind="properties">
<VtsCardRowKeyValue v-bind="{ ...properties, values: undefined }">
<template #key>Network</template>
<template #value>
<UiObjectLink route="`/vm/test/console`" icon="object:network:connected">
@@ -46,7 +60,7 @@
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue v-bind="properties">
<VtsCardRowKeyValue v-bind="{ ...properties, values: undefined }">
<template #key>Status</template>
<template #value>
<UiInfo accent="success">Connected</UiInfo>
@@ -57,7 +71,7 @@
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue v-bind="{ ...properties, truncate: false }">
<VtsCardRowKeyValue v-bind="{ ...properties, truncate: false, values: undefined }">
<template #key>Tags</template>
<template #value>
<UiTagsList v-if="tags.length > 0">
@@ -75,7 +89,7 @@
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { prop, setting, slot } from '@/libs/story/story-param.ts'
import { choice, text } from '@/libs/story/story-widget.ts'
import { choice, object, text } from '@/libs/story/story-widget.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'

View File

@@ -0,0 +1,35 @@
<template>
<ComponentStory :params="[slot().help('Meant to receive VtsCardRowKeyValue components')]">
<UiCard class="card">
<VtsCardRowList>
<VtsCardRowKeyValue :values="['10.0.0.12']">
<template #key>IP address</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue :values="['1500']">
<template #key>MTU</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>Status</template>
<template #value>
<UiInfo accent="success">Connected</UiInfo>
</template>
</VtsCardRowKeyValue>
</VtsCardRowList>
</UiCard>
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { slot } from '@/libs/story/story-param.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiInfo from '@core/components/ui/info/UiInfo.vue'
</script>
<style lang="postcss" scoped>
.card {
width: 400px;
}
</style>

View File

@@ -1,5 +1,11 @@
<template>
<ComponentStory v-slot="{ properties }" :params="[iconProp().preset('object:host').required()]">
<ComponentStory
v-slot="{ properties }"
:params="[
iconProp().preset('object:host').required(),
prop('size').enum('small', 'medium').preset('medium').widget(),
]"
>
<VtsTreeList>
<VtsTreeLoadingItem v-bind="properties" />
</VtsTreeList>
@@ -8,7 +14,7 @@
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { iconProp } from '@/libs/story/story-param.ts'
import { iconProp, prop } from '@/libs/story/story-param.ts'
import VtsTreeList from '@core/components/tree/VtsTreeList.vue'
import VtsTreeLoadingItem from '@core/components/tree/VtsTreeLoadingItem.vue'
</script>

View File

@@ -4,7 +4,10 @@
:params="[
prop('accent').enum('info', 'success', 'warning', 'danger', 'muted').required().preset('info').widget(),
prop('wrap').bool().help('Choose if the text should wrap if too long').widget(),
prop('size').enum('small', 'medium').preset('medium').widget(),
prop('size')
.enum('small', 'medium')
.help('Text size only, the icon is always medium. Defaults to small inside a VtsSidePanel, medium elsewhere')
.widget(),
slot(),
setting('defaultSlot').widget(text()).preset('message'),
]"

View File

@@ -9,6 +9,7 @@
.preset({ name: '/pool/[uuid]', params: { uuid: '355ee47d-ff4c-4924-3db2-fd86ae629676' } })
.widget(),
prop('active').bool().widget(),
prop('size').enum('small', 'medium').preset('medium').widget(),
prop('no-indent').bool().help('Remove indentation based on component depth in the tree'),
slot('default'),
slot('icon').help('Meant to receive a VtsIcon or another icon component in case the icon prop is not used'),

View File

@@ -280,7 +280,8 @@ export class CleanupClient {
if (config.deleteBackupFiles) {
try {
const jobDetails = await this.dispatchClient.backup.details(job.id)
const jobRemotes = extractIdsFromSimplePattern(jobDetails.remotes)
const jobRemotes =
typeof jobDetails.remotes === 'string' ? [] : extractIdsFromSimplePattern(jobDetails.remotes)
if (jobRemotes.length > 0) {
const backupsByRemote = await this.dispatchClient.backup.listVmBackups(jobRemotes)

View File

@@ -267,7 +267,7 @@ Throw `noSuchObject(id, type)` to get an automatic 404 when the routing doesn't
## Streaming & file-download routes
- Obtain and validate the resource **before** setting any response header, so a `noSuchObject` returns a clean JSON 404 instead of a body labelled as a download
- Obtain and validate the resource **before** setting any response header, so a `noSuchObject` returns a clean JSON 404 instead of a body labeled as a download
- Set `content-disposition: attachment; filename="…"` with a meaningful name built from domain labels (VM / disk / partition). Fold accents to ASCII and collapse anything outside `[\w.-]` so the header and the on-disk filename stay safe (also blocks header injection)
- Tie the stream to the request lifetime (`req.on('close', () => stream.destroy())`) so a client disconnect doesn't leak the export
- Export: return the stream (`Promise<Readable>`) and let the REST API pipe it to the response. Don't pipe it manually (`pipeline(stream, res)` + `Promise<void>`)

View File

@@ -13,9 +13,9 @@ import { Unbrand } from '../open-api/common/response.common.mjs'
import type * as CMType from '@vates/types/lib/complex-matcher'
type UnbrandedXoVmBackupJob = Omit<XoVmBackupJob, 'settings' | 'remotes' | 'vms' | 'srs'> & {
remotes?: CMType.IdOr<string>
remotes?: CMType.IdOr<string> | string
vms: CMType.IdOr<string> | Record<string, unknown>
srs?: CMType.IdOr<string>
srs?: CMType.IdOr<string> | string
settings: {
'': XoVmBackupJobGeneralSettings
[scheduleId: string]: Unbrand<XoVmBackupJobScheduleSettings> | undefined

View File

@@ -8,18 +8,21 @@ export class BackupRepositoryService {
constructor(@inject(RestApi) restApi: RestApi) {
this.#restApi = restApi
}
isBackupRepositoryReferenced(
idsToCheck: AnyXoBackupJob['remotes'],
repositoryId: XoBackupRepository['id']
): boolean {
isBackupRepositoryReferenced(idsToCheck: AnyXoBackupJob['remotes'], repositoryId: XoBackupRepository['id']): boolean {
if (idsToCheck === undefined) {
return false
}
const { id } = idsToCheck
const ids = typeof id === 'string' ? [id] : id.__or
return ids.includes(repositoryId)
if (typeof idsToCheck === 'string') {
return true
} else {
const { id } = idsToCheck
const ids = typeof id === 'string' ? [id] : id.__or
return ids.includes(repositoryId)
}
}
public async getReferencingJobs(repositoryId: XoBackupRepository['id']): Promise<AnyXoBackupJob['id'][]> {
const allJobs = await this.#restApi.xoApp.getAllJobs()
const referencingJobs: AnyXoBackupJob['id'][] = []

View File

@@ -0,0 +1,85 @@
<template>
<div class="card-row-value-list">
<div v-for="(value, index) in visibleValues" :key="index" class="value-row">
<div class="value">{{ value }}</div>
<div class="addons">
<VtsCopyButton :value />
<slot v-if="index === 0" name="first-row-addons" />
</div>
</div>
<UiButton
v-if="hiddenValuesCount > 0"
class="see-more"
size="small"
accent="brand"
variant="tertiary"
@click="toggleExpanded()"
>
{{ isExpanded ? t('action:see-less') : t('action:see-n-more', { n: hiddenValuesCount }) }}
</UiButton>
</div>
</template>
<script lang="ts" setup>
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import { useToggle } from '@vueuse/shared'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { values, maxVisibleValues } = defineProps<{
values: string[]
maxVisibleValues?: number
}>()
defineSlots<{
'first-row-addons'?(): any
}>()
const { t } = useI18n()
const [isExpanded, toggleExpanded] = useToggle(false)
const hiddenValuesCount = computed(() =>
maxVisibleValues === undefined ? 0 : Math.max(values.length - maxVisibleValues, 0)
)
const visibleValues = computed(() => (isExpanded.value ? values : values.slice(0, maxVisibleValues)))
</script>
<style lang="postcss" scoped>
.card-row-value-list {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 0.8rem;
.value-row {
display: flex;
align-items: flex-start;
gap: 1.6rem;
}
.value {
flex: 1 1 auto;
min-width: 0;
overflow-wrap: anywhere;
color: var(--color-neutral-txt-primary);
&:empty::before {
content: '-';
}
}
.addons {
display: flex;
gap: 0.8rem;
height: 1lh;
align-items: center;
}
.see-more {
align-self: flex-start;
}
}
</style>

View File

@@ -1,48 +1,67 @@
<template>
<div
class="vts-card-row-key-value typo-body-regular-small"
:class="{ truncate: shouldTruncate, 'align-top': alignTop }"
>
<div class="key">
<slot name="key" />
</div>
<div class="value-container">
<div ref="truncatableValueElement" class="value" :class="{ truncated: shouldTruncate && !isExpanded }">
<slot name="value" />
<div class="vts-card-row-key-value typo-body-regular-small" :class="className">
<div ref="keyValue" class="key-value">
<div class="key">
<slot name="key" />
<div v-if="multipleValues && isStacked" class="addons">
<slot name="addons" />
<VtsCopyAllMenuItem :values="multipleValues" />
</div>
</div>
<CardRowValueList v-if="multipleValues" :values="multipleValues" :max-visible-values>
<template v-if="!isStacked" #first-row-addons>
<slot name="addons" />
<VtsCopyAllMenuItem :values="multipleValues" />
</template>
</CardRowValueList>
<div v-else class="value-container">
<div ref="truncatableValueElement" class="value" :class="{ truncated: shouldTruncate && !isExpanded }">
<slot name="value">
<template v-if="singleValue !== undefined">{{ singleValue }}</template>
</slot>
</div>
</div>
<UiButtonIcon
v-if="shouldTruncate"
v-tooltip="isExpanded ? t('action:show-less') : t('action:show-more')"
:icon="isExpanded ? 'fa:chevron-up' : 'fa:chevron-down'"
class="show-more"
size="small"
accent="brand"
@click="toggleExpanded()"
/>
</div>
<div v-if="slots.addons" class="addons">
<slot name="addons" />
<div v-if="!multipleValues && (slots.addons || singleValue !== undefined || shouldTruncate)" class="actions">
<div v-if="slots.addons || singleValue !== undefined" class="addons">
<slot name="addons" />
<VtsCopyButton v-if="singleValue !== undefined" :value="singleValue" />
</div>
<div v-if="shouldTruncate" class="show-more">
<UiButtonIcon
v-tooltip="isExpanded ? t('action:show-less') : t('action:show-more')"
:icon="isExpanded ? 'fa:chevron-up' : 'fa:chevron-down'"
size="small"
accent="brand"
@click="toggleExpanded()"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import CardRowValueList from '@core/components/card/CardRowValueList.vue'
import VtsCopyAllMenuItem from '@core/components/copy-all-menu-item/VtsCopyAllMenuItem.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { calculateLineCount } from '@core/utils/line-height.util.ts'
import { useStyleTag } from '@vueuse/core'
import { useResizeObserver, useStyleTag } from '@vueuse/core'
import { useToggle } from '@vueuse/shared'
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { truncate } = defineProps<{
const { truncate, alignTop, values } = defineProps<{
truncate?: number | boolean
alignTop?: boolean
values?: string[]
maxVisibleValues?: number
}>()
const slots = defineSlots<{
key(): any
value(): any
value?(): any
addons?(): any
}>()
@@ -50,6 +69,24 @@ const DEFAULT_MAX_LINES = 5
const { t } = useI18n()
const multipleValues = computed(() => (values !== undefined && values.length > 1 ? values : undefined))
const singleValue = computed(() => (values?.length === 1 ? values[0] : undefined))
const keyValueRef = useTemplateRef('keyValue')
const isStacked = ref(false)
useResizeObserver(
() => (multipleValues.value !== undefined ? keyValueRef.value : undefined),
() => {
const key = keyValueRef.value?.firstElementChild
const valueList = keyValueRef.value?.lastElementChild
isStacked.value =
key instanceof HTMLElement && valueList instanceof HTMLElement && valueList.offsetTop > key.offsetTop
}
)
const truncatableValueElementRef = useTemplateRef('truncatableValueElement')
const lineCount = ref(0)
@@ -67,6 +104,13 @@ const shouldTruncate = computed(() => isTruncationEnabled.value && lineCount.val
const [isExpanded, toggleExpanded] = useToggle(false)
const className = computed(() => ({
truncate: shouldTruncate.value,
'align-top': alignTop,
'multiple-values': multipleValues.value !== undefined,
stacked: isStacked.value,
}))
const calculateLines = async () => {
await nextTick()
@@ -89,42 +133,36 @@ useStyleTag(style)
onMounted(() => calculateLines())
watch(
() => slots.value?.(),
() => calculateLines()
)
watch([() => slots.value?.(), () => values], () => calculateLines())
</script>
<style lang="postcss" scoped>
.vts-card-row-key-value {
display: flex;
align-items: center;
align-items: flex-start;
column-gap: 1.6rem;
&.truncate {
display: grid;
row-gap: 0.4rem;
grid-template-columns: 1fr auto;
.key {
grid-column: 1 / -1;
grid-row: 1;
}
.addons {
grid-column: 2;
grid-row: 1;
}
.value-container {
grid-column: 1 / -1;
}
.key-value {
display: flex;
flex: 1 1 auto;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 1.6rem;
min-width: 0;
}
&.align-top:not(.truncate) {
&.align-top .key-value,
&.multiple-values .key-value {
align-items: flex-start;
}
&.multiple-values.stacked .key {
display: flex;
flex-grow: 1;
justify-content: space-between;
gap: 0.8rem;
}
.key {
overflow-wrap: break-word;
color: var(--color-neutral-txt-secondary);
@@ -132,15 +170,16 @@ watch(
.value-container {
display: flex;
align-items: end;
gap: 0.8rem;
flex: 1 1 auto;
min-width: 0;
.value {
min-width: 0;
color: var(--color-neutral-txt-primary);
}
.show-more {
flex-shrink: 0;
:slotted(*) {
vertical-align: middle;
}
}
}
@@ -148,12 +187,23 @@ watch(
content: '-';
}
.addons {
.actions {
display: flex;
flex-direction: column;
align-self: stretch;
align-items: flex-end;
}
.addons,
.show-more {
display: flex;
gap: 0.8rem;
margin-left: auto;
font-size: 1.6rem;
height: 1lh;
align-items: center;
}
.show-more {
margin-block-start: auto;
}
}
</style>

View File

@@ -0,0 +1,19 @@
<template>
<div class="vts-card-row-list">
<slot />
</div>
</template>
<script lang="ts" setup>
defineSlots<{
default(): any
}>()
</script>
<style lang="postcss" scoped>
.vts-card-row-list {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
</style>

View File

@@ -1,5 +1,6 @@
<template>
<UiLoader v-if="busy" v-tooltip="busyTooltip ?? false" />
<DisplayIcon v-else-if="icon && tooltip" v-tooltip="tooltip" class="vts-icon" :class="className" :icon />
<DisplayIcon v-else-if="icon" class="vts-icon" :class="className" :icon />
</template>
@@ -18,6 +19,7 @@ const { size, name } = defineProps<{
name: IconName | undefined
busy?: boolean
busyTooltip?: string
tooltip?: string
}>()
const className = computed(() =>

View File

@@ -4,6 +4,7 @@
:class="{
mobile: uiStore.isSmall,
'has-panel-column': hasPanelColumn,
toggling: isToggling,
}"
>
<slot />
@@ -11,30 +12,40 @@
</template>
<script setup lang="ts">
import { usePanelStore } from '@core/stores/panel.store.ts'
import { PANEL_TRANSITION_DURATION, usePanelStore } from '@core/stores/panel.store.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { computed } from 'vue'
import { refAutoReset } from '@vueuse/core'
import { computed, watch } from 'vue'
const panelStore = usePanelStore()
const uiStore = useUiStore()
const hasPanelColumn = computed(() => (panelStore.isLocked || panelStore.isExpanded) && !uiStore.isSmall)
const isToggling = refAutoReset(false, PANEL_TRANSITION_DURATION)
watch(hasPanelColumn, () => {
isToggling.value = true
})
const columnTransition = `grid-template-columns ${PANEL_TRANSITION_DURATION}ms`
</script>
<style scoped lang="postcss">
.vts-content-side-panel {
--side-panel-width: 40rem;
&:not(.mobile) {
display: grid;
grid-template-columns: minmax(0, 1fr) 0;
align-items: start;
overflow-x: clip;
min-height: min-content;
transition: grid-template-columns 0.25s;
&.has-panel-column {
grid-template-columns: minmax(0, 1fr) var(--side-panel-width);
grid-template-columns: minmax(0, 1fr) v-bind('panelStore.cssWidth');
}
&.toggling {
transition: v-bind(columnTransition);
}
}
}

View File

@@ -1,11 +1,6 @@
<template>
<div :class="className" class="vts-layout-sidebar">
<div
v-if="!ui.isSmall"
:class="{ active: sidebar.isResizing }"
class="resize-handle"
@mousedown="sidebar.startResize"
/>
<VtsResizeHandle v-if="showResizeHandle" :position="resizeHandlePosition" :resizable="sidebar" />
<div v-if="slots.header || (showLock && !ui.isSmall)" class="header">
<div v-if="slots.header" class="start">
<slot name="header" />
@@ -16,6 +11,7 @@
content: sidebar.isLocked ? t('action:sidebar-unlock') : t('action:sidebar-lock'),
placement: lockTooltipPosition,
}"
:aria-label="sidebar.isLocked ? t('action:sidebar-unlock') : t('action:sidebar-lock')"
accent="brand"
size="small"
:icon="sidebar.isLocked ? 'fa:thumb-tack-slash' : 'fa:thumb-tack'"
@@ -36,6 +32,7 @@
</template>
<script lang="ts" setup>
import VtsResizeHandle from '@core/components/resize-handle/VtsResizeHandle.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { type SidebarSide, useLeftSidebarStore, useRightSidebarStore } from '@core/packages/sidebar'
@@ -66,6 +63,10 @@ const sidebar = computed(() => (side === 'right' ? rightSidebar : leftSidebar))
const lockTooltipPosition = computed(() => (side === 'left' ? 'right' : 'left'))
const showResizeHandle = computed(() => !ui.isSmall && sidebar.value.isExpanded)
const resizeHandlePosition = computed(() => (side === 'left' ? 'end' : 'start'))
const className = computed(() => [
{
locked: sidebar.value.isLocked && !ui.isSmall,
@@ -154,34 +155,5 @@ const className = computed(() => [
flex: 1;
overflow: auto;
}
.resize-handle {
position: absolute;
inset-block: 0;
width: 0.8rem;
background-color: transparent;
cursor: col-resize;
transition: background-color 0.4s;
user-select: none;
z-index: 1;
&:hover,
&.active {
background-color: var(--color-neutral-border);
transition: background-color 0.05s;
}
}
&.side--left {
.resize-handle {
inset-inline-end: 0;
}
}
&.side--right {
.resize-handle {
inset-inline-start: 0;
}
}
}
</style>

View File

@@ -1,6 +1,7 @@
<!-- v5 -->
<template>
<div class="vts-side-panel-sticky" :class="{ mobile: uiStore.isSmall }">
<VtsResizeHandle v-if="showResizeHandle" position="start" :resizable="panelStore" />
<VtsPanel
:closable="hasSelection"
:close-icon="uiStore.isSmall ? 'fa:angle-left' : 'action:close-cancel-clear'"
@@ -42,12 +43,14 @@
<script setup lang="ts">
import VtsPanel from '@core/components/panel/VtsPanel.vue'
import VtsResizeHandle from '@core/components/resize-handle/VtsResizeHandle.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { usePanelStore } from '@core/stores/panel.store.ts'
import { PANEL_TRANSITION_DURATION, usePanelStore } from '@core/stores/panel.store.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { watch } from 'vue'
import { IK_INFO_SIZE } from '@core/utils/injection-keys.util.ts'
import { computed, provide, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { hasSelection } = defineProps<{
@@ -70,6 +73,12 @@ const uiStore = useUiStore()
const { t } = useI18n()
const showResizeHandle = computed(() => !uiStore.isSmall && panelStore.isExpanded)
const transformTransition = `transform ${PANEL_TRANSITION_DURATION}ms`
provide(IK_INFO_SIZE, 'small')
watch(
[() => hasSelection, () => panelStore.isLocked, () => uiStore.isSmall],
([hasSelection]) => {
@@ -99,8 +108,8 @@ function handleClose() {
}
.vts-side-panel {
width: var(--side-panel-width, 40rem);
transition: transform 0.25s;
width: v-bind('panelStore.cssWidth');
transition: v-bind(transformTransition);
&:not(.mobile) {
--panel-viewport-height: calc(100dvh - 5.6rem);

View File

@@ -0,0 +1,76 @@
<template>
<div
:class="className"
class="vts-resize-handle"
role="separator"
aria-orientation="vertical"
tabindex="0"
:aria-label="t('action:resize')"
:aria-valuenow="ariaValueNow"
:aria-valuemin="resizable.minWidth"
:aria-valuemax="ariaValueMax"
@mousedown="resizable.startResize"
@dblclick="resizable.resetWidth()"
@keydown.left.prevent="handleArrowKey('left')"
@keydown.right.prevent="handleArrowKey('right')"
@keydown.home.prevent="resizable.setWidth(resizable.minWidth)"
@keydown.end.prevent="resizable.setWidth(resizable.maxWidth)"
@keydown.enter.prevent="resizable.resetWidth()"
/>
</template>
<script setup lang="ts">
import type { ResizableWidth } from '@core/composables/resizable-width.composable.ts'
import { toVariants } from '@core/utils/to-variants.util.ts'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { position, resizable } = defineProps<{
position: 'start' | 'end'
resizable: ResizableWidth
}>()
const RESIZE_STEP = 10
const { t } = useI18n()
const className = computed(() => toVariants({ position, active: resizable.isResizing }))
const ariaValueNow = computed(() => Math.round(resizable.width))
const ariaValueMax = computed(() => Math.round(resizable.maxWidth))
function handleArrowKey(direction: 'left' | 'right') {
const isGrowing = (direction === 'right') === (position === 'end')
resizable.setWidth(resizable.width + (isGrowing ? RESIZE_STEP : -RESIZE_STEP))
}
</script>
<style scoped lang="postcss">
.vts-resize-handle {
position: absolute;
inset-block: 0;
width: 0.8rem;
background-color: transparent;
cursor: col-resize;
transition: background-color 0.4s;
user-select: none;
z-index: 1;
&:hover,
&:focus-visible,
&.active {
background-color: var(--color-neutral-border);
transition: background-color 0.05s;
}
&.position--start {
inset-inline-start: 0;
}
&.position--end {
inset-inline-end: 0;
}
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<UiTableCell class="vts-button-icon-list-cell" align="center">
<div class="buttons">
<UiButtonIcon
v-for="(button, index) of buttons"
:key="index"
v-tooltip="button.label"
:aria-label="button.label"
:icon="button.icon"
:disabled="button.disabled"
:target-scale="1.5"
accent="brand"
size="small"
@click="button.onClick()"
/>
</div>
</UiTableCell>
</template>
<script lang="ts" setup>
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import UiTableCell from '@core/components/ui/table-cell/UiTableCell.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import type { IconName } from '@core/icons'
export type ButtonIconItem = {
icon: IconName
label: string
onClick: () => void
disabled?: boolean
}
defineProps<{
buttons: ButtonIconItem[]
}>()
</script>
<style lang="postcss" scoped>
.vts-button-icon-list-cell {
width: 8rem;
padding: 0.8rem 1.6rem 0.8rem 0.8rem;
.buttons {
display: flex;
justify-content: center;
gap: 0.8rem;
}
}
</style>

View File

@@ -1,13 +1,14 @@
<template>
<UiTableCell>
<div class="vts-double-link-cell">
<UiLink size="medium" :icon :to :href :target :disabled class="link">
<UiLink size="medium" :icon :icon-tooltip :to :href :target :disabled class="link">
<slot />
</UiLink>
<UiLink
v-if="suffix"
size="medium"
:icon="suffix.icon"
:icon-tooltip="suffix.iconTooltip"
:to="suffix.to"
:href="suffix.href"
:target="suffix.target"
@@ -27,9 +28,11 @@ import type { IconName } from '@core/icons'
export type VtsDoubleLinkCellProps = LinkOptions & {
icon?: IconName
iconTooltip?: string
suffix?: LinkOptions & {
label: string
icon?: IconName
iconTooltip?: string
}
}

View File

@@ -1,7 +1,7 @@
<template>
<UiTableCell>
<div class="vts-link-cell">
<UiLink size="medium" :icon :busy :busy-tooltip :to :href :target class="link">
<UiLink size="medium" :icon :icon-tooltip :busy :busy-tooltip :to :href :target class="link">
<slot />
<VtsIcon v-if="rightIcon" v-tooltip="rightIcon.tooltip ?? false" :name="rightIcon.icon" size="medium" />
</UiLink>
@@ -25,6 +25,7 @@ import { useI18n } from 'vue-i18n'
export type VtsLinkCellProps = LinkOptions & {
icon?: IconName
iconTooltip?: string
busy?: boolean
busyTooltip?: string
rightIcon?: {

View File

@@ -0,0 +1,56 @@
<template>
<UiTableCell class="vts-rank-cell">
<div class="content">
<span>{{ rank }}</span>
<span v-if="shiftIcon" class="shift">
<VtsIcon :name="shiftIcon" size="small" />
{{ shiftCount }}
</span>
</div>
</UiTableCell>
</template>
<script lang="ts" setup>
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import UiTableCell from '@core/components/ui/table-cell/UiTableCell.vue'
import type { IconName } from '@core/icons'
import { computed } from 'vue'
const { shift = 0 } = defineProps<{
rank: number
shift?: number
}>()
const shiftIcon = computed<IconName | undefined>(() => {
if (shift > 0) {
return 'action:move-up'
}
if (shift < 0) {
return 'action:move-down'
}
return undefined
})
const shiftCount = computed(() => Math.abs(shift))
</script>
<style lang="postcss" scoped>
.vts-rank-cell {
width: 7rem;
.content {
display: flex;
justify-content: space-between;
gap: 0.8rem;
}
.shift {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--color-neutral-txt-secondary);
}
}
</style>

View File

@@ -1,19 +1,25 @@
<template>
<div class="vts-tree-line">
<div :class="className" class="vts-tree-line">
<div class="tree-line-vertical" />
<div :class="{ right }" class="tree-line-horizontal" />
</div>
</template>
<script lang="ts" setup>
defineProps<{
import type { TreeItemLabelSize } from '@core/components/ui/tree-item-label/UiTreeItemLabel.vue'
import { toVariants } from '@core/utils/to-variants.util.ts'
import { computed } from 'vue'
const { size = 'medium' } = defineProps<{
right?: boolean
size?: TreeItemLabelSize
}>()
const className = computed(() => toVariants({ size }))
</script>
<style lang="postcss" scoped>
.vts-tree-line {
flex: 0 1 1.5em;
align-self: stretch;
display: flex;
align-items: center;
@@ -22,7 +28,6 @@ defineProps<{
.tree-line-vertical {
width: 0.1rem;
background: var(--color-brand-txt-base);
height: calc(100% + 0.2rem);
}
.tree-line-horizontal {
@@ -34,5 +39,21 @@ defineProps<{
background: var(--color-brand-txt-base);
}
}
&.size--small {
flex: 0 1 1.6rem;
.tree-line-vertical {
height: 100%;
}
}
&.size--medium {
flex: 0 1 2.4rem;
.tree-line-vertical {
height: calc(100% + 0.2rem);
}
}
}
</style>

View File

@@ -1,9 +1,9 @@
<template>
<li class="vts-tree-loading-item">
<li :class="className" class="vts-tree-loading-item">
<div class="label-placeholder">
<div class="link-placeholder">
<template v-if="depth > 1">
<VtsTreeLine v-for="i in depth - 1" :key="i" :right="i === depth - 1" />
<VtsTreeLine v-for="i in depth - 1" :key="i" :right="i === depth - 1" :size="resolvedSize" />
</template>
<VtsIcon :name="icon" size="medium" />
<div class="loader">&nbsp;</div>
@@ -15,22 +15,33 @@
<script lang="ts" setup>
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsTreeLine from '@core/components/tree/VtsTreeLine.vue'
import type { TreeItemLabelSize } from '@core/components/ui/tree-item-label/UiTreeItemLabel.vue'
import type { IconName } from '@core/icons'
import { IK_TREE_LIST_DEPTH } from '@core/utils/injection-keys.util.ts'
import { inject, ref } from 'vue'
import { TREE_ITEM_SMALL_HEIGHT } from '@core/packages/tree/types.ts'
import { IK_TREE_ITEM_LABEL_SIZE, IK_TREE_LIST_DEPTH } from '@core/utils/injection-keys.util.ts'
import { toVariants } from '@core/utils/to-variants.util.ts'
import { computed, inject, ref } from 'vue'
defineProps<{
const { size } = defineProps<{
icon: IconName
size?: TreeItemLabelSize
}>()
const depth = inject(IK_TREE_LIST_DEPTH, ref(0))
const contextSize = inject(IK_TREE_ITEM_LABEL_SIZE, undefined)
const resolvedSize = computed(() => size ?? contextSize ?? 'medium')
const className = computed(() => toVariants({ size: resolvedSize.value }))
const smallHeight = `${TREE_ITEM_SMALL_HEIGHT}px`
</script>
<style lang="postcss" scoped>
.vts-tree-loading-item {
.label-placeholder {
display: flex;
height: 4rem;
background-color: var(--color-neutral-background-primary);
.link-placeholder {
@@ -48,6 +59,14 @@ const depth = inject(IK_TREE_LIST_DEPTH, ref(0))
}
}
&.size--small .label-placeholder {
height: v-bind(smallHeight);
}
&.size--medium .label-placeholder {
height: 4rem;
}
@keyframes pulse {
0% {
opacity: 0.5;

View File

@@ -1,14 +1,16 @@
<!-- v7 -->
<template>
<button :class="classNames" :disabled="busy || isDisabled || lockIcon" class="ui-button" type="button">
<VtsIcon :busy :name="leftIcon" class="icon" size="current" />
<VtsIcon :name="leftIcon" class="icon" size="current" />
<slot />
<UiLoader v-if="busy" class="icon" />
<VtsIcon v-if="lockIcon" name="fa:lock" size="medium" class="icon" />
</button>
</template>
<script lang="ts" setup>
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import UiLoader from '@core/components/ui/loader/UiLoader.vue'
import { useDisabled } from '@core/composables/disabled.composable.ts'
import type { IconName } from '@core/icons'
import { toVariants } from '@core/utils/to-variants.util.ts'

View File

@@ -1,7 +1,7 @@
<!-- v6 -->
<template>
<div class="ui-info">
<VtsIcon class="icon" :name="icon" :size />
<VtsIcon class="icon" :name="icon" size="medium" />
<p v-tooltip="!wrap" class="label" :class="[textSize, { 'text-ellipsis': !wrap }]">
<slot />
</p>
@@ -13,14 +13,16 @@ import VtsIcon from '@core/components/icon/VtsIcon.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import type { IconName } from '@core/icons'
import { useMapper } from '@core/packages/mapper'
import { IK_INFO_SIZE } from '@core/utils/injection-keys.util.ts'
import { inject } from 'vue'
export type InfoAccent = 'info' | 'success' | 'warning' | 'danger' | 'muted'
type Size = 'small' | 'medium'
export type InfoSize = 'small' | 'medium'
const { accent, size = 'medium' } = defineProps<{
const { accent, size } = defineProps<{
accent: InfoAccent
wrap?: boolean
size?: Size
size?: InfoSize
}>()
defineSlots<{
@@ -39,8 +41,10 @@ const icon = useMapper<InfoAccent, IconName>(
'muted'
)
const textSize = useMapper<Size, any>(
() => size,
const contextSize = inject(IK_INFO_SIZE, undefined)
const textSize = useMapper<InfoSize, any>(
() => size ?? contextSize ?? 'medium',
{
small: 'typo-body-regular-small',
medium: 'typo-body-regular',

View File

@@ -1,7 +1,7 @@
<!-- v6 -->
<template>
<component :is="component" :class="classes" class="ui-link" v-bind="attributes">
<VtsIcon :name="icon" :busy :busy-tooltip size="medium" />
<VtsIcon :name="icon" :tooltip="iconTooltip" :busy :busy-tooltip size="medium" />
<slot />
<VtsIcon v-if="attributes.target === '_blank'" name="action:open-in-new-tab" size="medium" class="external-icon" />
<VtsIcon v-if="isPrimary" v-tooltip="primaryTooltip" name="status:primary-circle" size="medium" />
@@ -19,6 +19,7 @@ const props = defineProps<
LinkOptions & {
size: 'small' | 'medium'
icon?: IconName
iconTooltip?: string
busy?: boolean
busyTooltip?: string
isPrimary?: boolean

View File

@@ -3,11 +3,11 @@
<div class="ui-panel-card-title">
<div class="header">
<div class="title" :class="typoClasses[size]">
<UiLink v-if="isLink" :size :icon :to :href :target :disabled>
<UiLink v-if="isLink" :size :icon :icon-tooltip :to :href :target :disabled>
<slot name="label">{{ label }}</slot>
</UiLink>
<template v-else>
<VtsIcon v-if="icon !== undefined" :name="icon" size="medium" class="icon" />
<VtsIcon v-if="icon !== undefined" :name="icon" :tooltip="iconTooltip" size="medium" class="icon" />
<slot name="label">{{ label }}</slot>
</template>
<UiCounter
@@ -40,11 +40,12 @@ import { computed } from 'vue'
type PanelCardTitleSize = 'small' | 'medium'
const { label, size, icon, id, counter, to, href, target, disabled } = defineProps<
const { label, size, icon, iconTooltip, id, counter, to, href, target, disabled } = defineProps<
LinkOptions & {
size: PanelCardTitleSize
label?: string
icon?: IconName
iconTooltip?: string
id?: string
counter?: CounterValue | { value: CounterValue; accent: CounterAccent }
}
@@ -61,7 +62,7 @@ const typoClasses = {
medium: 'typo-body-bold',
}
const isLink = computed(() => to !== undefined || href !== undefined)
const isLink = computed(() => to !== undefined || href !== undefined || disabled)
const counterProps = computed(() => {
if (counter === undefined) {

View File

@@ -162,7 +162,6 @@ const progress = computed(() => {
--line-width: 0.1rem;
display: flex;
align-items: center;
padding-left: 1.6rem;
gap: 0.4rem;
.tree-lines {
@@ -195,7 +194,7 @@ const progress = computed(() => {
flex: 1;
min-width: 0;
gap: 1.6rem;
padding: 0.4rem 1.6rem;
padding: 0.4rem 1.6rem 0.4rem 0.8rem;
justify-content: space-between;
align-items: center;
}

View File

@@ -2,17 +2,18 @@
<template>
<RouterLink v-slot="{ isExactActive, isActive, href, navigate }" :to="route" custom>
<div
:class="isExactActive ? 'exact-active' : active || isActive ? 'active' : undefined"
:class="[className, isExactActive ? 'exact-active' : active || isActive ? 'active' : undefined]"
class="ui-tree-item-label"
v-bind="attrs"
>
<template v-if="depth > 1">
<VtsTreeLine v-for="i in depth - 1" :key="i" />
<VtsTreeLine v-for="i in depth - 1" :key="i" :size="resolvedSize" />
</template>
<div class="toggle-wrapper">
<UiButtonIcon
v-if="hasToggle"
v-tooltip="isExpanded ? t('action:close') : t('action:open')"
:aria-label="isExpanded ? t('action:close') : t('action:open')"
class="toggle"
accent="brand"
:icon="isExpanded ? 'fa:angle-down' : 'fa:angle-right'"
@@ -43,23 +44,29 @@ import VtsTreeLine from '@core/components/tree/VtsTreeLine.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import type { IconName } from '@core/icons'
import { TREE_ITEM_SMALL_HEIGHT } from '@core/packages/tree/types.ts'
import {
IK_TREE_ITEM_EXPANDED,
IK_TREE_ITEM_HAS_CHILDREN,
IK_TREE_ITEM_LABEL_SIZE,
IK_TREE_LIST_DEPTH,
} from '@core/utils/injection-keys.util.ts'
import { inject, ref, useAttrs } from 'vue'
import { toVariants } from '@core/utils/to-variants.util.ts'
import { computed, inject, ref, useAttrs } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'
export type TreeItemLabelSize = 'small' | 'medium'
defineOptions({
inheritAttrs: false,
})
defineProps<{
const { size } = defineProps<{
route: RouteLocationRaw
icon?: IconName
active?: boolean
size?: TreeItemLabelSize
}>()
const emit = defineEmits<{
@@ -81,6 +88,14 @@ const hasToggle = inject(IK_TREE_ITEM_HAS_CHILDREN, ref(false))
const isExpanded = inject(IK_TREE_ITEM_EXPANDED, ref(true))
const depth = inject(IK_TREE_LIST_DEPTH, ref(0))
const contextSize = inject(IK_TREE_ITEM_LABEL_SIZE, undefined)
const resolvedSize = computed(() => size ?? contextSize ?? 'medium')
const className = computed(() => toVariants({ size: resolvedSize.value }))
const smallHeight = `${TREE_ITEM_SMALL_HEIGHT}px`
</script>
<style lang="postcss" scoped>
@@ -89,13 +104,10 @@ const depth = inject(IK_TREE_LIST_DEPTH, ref(0))
align-items: center;
color: var(--color-neutral-txt-primary);
background-color: var(--color-neutral-background-primary);
border-radius: 0.8rem;
gap: 0.4rem;
padding: 0 0.8rem;
margin-bottom: 0.2rem;
.toggle-wrapper {
width: 2.4rem;
flex-shrink: 0;
display: flex;
align-items: center;
@@ -105,21 +117,16 @@ const depth = inject(IK_TREE_LIST_DEPTH, ref(0))
display: flex;
align-items: center;
flex: 1;
align-self: stretch;
min-width: 0;
padding: 0.8rem 0;
text-decoration: none;
color: inherit;
gap: 1.2rem;
&:hover {
color: var(--color-neutral-txt-primary);
}
}
.text {
padding-inline-end: 0.4rem;
}
.icon {
font-size: 1.6rem;
}
@@ -138,17 +145,49 @@ const depth = inject(IK_TREE_LIST_DEPTH, ref(0))
width: 100%;
}
/* SIZE VARIANTS */
&.size--small {
height: v-bind(smallHeight);
.toggle-wrapper {
width: 1.6rem;
}
.link {
gap: 0.8rem;
}
}
&.size--medium {
border-radius: 0.8rem;
margin-bottom: 0.2rem;
.toggle-wrapper {
width: 2.4rem;
}
.link {
padding: 0.8rem 0;
gap: 1.2rem;
}
.text {
padding-inline-end: 0.4rem;
}
}
/* INTERACTION VARIANTS */
&:is(.exact-active, .active) {
background-color: var(--color-brand-background-selected);
}
&:hover {
&:hover:not(:has(.addons-wrapper :is(button, a):hover)) {
background-color: var(--color-brand-background-hover);
}
&:active {
&:active:not(:has(.addons-wrapper :is(button, a):active)) {
background-color: var(--color-brand-background-active);
}
}

View File

@@ -0,0 +1,93 @@
import { ifElse } from '@core/utils/if-else.utils.ts'
import { useLocalStorage, useRafFn, useStyleTag, useWindowSize } from '@vueuse/core'
import { computed, ref, type UnwrapRef } from 'vue'
const MIN_WIDTH = 280
const MAX_WIDTH_FLOOR = 400
const MAX_WIDTH_RATIO = 1 / 3
const DEFAULT_WIDTH_RATIO = 1 / 5
export type ResizableSide = 'left' | 'right'
export type ResizableWidth = UnwrapRef<ReturnType<typeof useResizableWidth>>
export function useResizableWidth(side: ResizableSide, storageKey: string) {
const customWidth = useLocalStorage<number | null>(storageKey, null, {
serializer: {
read: value => {
const width = Number.parseFloat(value)
return Number.isFinite(width) ? width : null
},
write: value => String(value),
},
})
const isResizing = ref(false)
const { width: windowWidth } = useWindowSize()
const maxWidth = computed(() => Math.max(MAX_WIDTH_FLOOR, windowWidth.value * MAX_WIDTH_RATIO))
const defaultWidth = computed(() => Math.max(MIN_WIDTH, windowWidth.value * DEFAULT_WIDTH_RATIO))
const width = computed(() => Math.min(maxWidth.value, customWidth.value ?? defaultWidth.value))
let initialX: number
let initialWidth: number
let clientX: number
function startResize(event: MouseEvent) {
clientX = event.clientX
initialX = clientX
initialWidth = width.value
isResizing.value = true
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
function setWidth(value: number) {
customWidth.value = Math.min(maxWidth.value, Math.max(MIN_WIDTH, value))
}
function resetWidth() {
customWidth.value = null
}
function handleMouseMove(event: MouseEvent) {
clientX = event.clientX
}
function handleMouseUp() {
isResizing.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
const { load, unload } = useStyleTag('body { cursor: col-resize !important; }', { immediate: false })
const resizeDelta = () => (side === 'left' ? initialWidth + clientX - initialX : initialWidth + initialX - clientX)
const { pause, resume } = useRafFn(
() => {
if (clientX === initialX) {
return
}
setWidth(resizeDelta())
},
{ immediate: false }
)
ifElse(isResizing, [load, resume], [pause, unload])
return {
width,
minWidth: MIN_WIDTH,
maxWidth,
startResize,
setWidth,
resetWidth,
isResizing,
}
}

View File

@@ -103,9 +103,11 @@ export const locales: Locales = {
},
}
export const FALLBACK_LOCALE = 'en'
export default createI18n({
locale: localStorage.getItem('lang') ?? 'en',
fallbackLocale: 'en',
fallbackLocale: FALLBACK_LOCALE,
messages,
missing: (locale, key, vm) => {
if (!import.meta.env.DEV) {

View File

@@ -4,10 +4,12 @@ import { faCopy, faSquare as checkboxEmpty, type IconDefinition } from '@fortawe
import {
faAdd,
faArrowCircleRight,
faArrowDown,
faArrowDownLong,
faArrowRight,
faArrowRotateLeft,
faArrowRotateRight,
faArrowUp,
faArrowUpLong,
faArrowUpRightFromSquare,
faBan,
@@ -153,6 +155,8 @@ export const actionIcons = defineIconPack({
color: 'var(--color-neutral-txt-primary)',
rotate: 90,
},
'move-down': constructIcon(faArrowDown),
'move-up': constructIcon(faArrowUp),
'import-export': [
{
icon: faArrowUpLong,

View File

@@ -10,6 +10,7 @@ import {
faCamera,
faCircle,
faCircleNodes,
faCircleNotch,
faCity,
faClock,
faDatabase,
@@ -71,6 +72,22 @@ const runningNeutral = defineIcon([
},
])
const busy = defineIcon([
{
icon: faCircle,
color: 'var(--color-neutral-background-primary)',
translate: [7, 5.5],
size: 13,
},
{
icon: faCircleNotch,
color: 'var(--color-brand-item-base)',
translate: [7, 5.5],
size: 10,
spin: 1,
},
])
const arrowLeft = defineIcon([
{
icon: faCircle,
@@ -150,6 +167,7 @@ export const objectIcons = defineIconPack({
'host:disabled': [constructIcon(faServer), ...constructCircleStatus('host-disabled-circle')],
'host:warning': [constructIcon(faServer), ...constructCircleStatus('warning-circle')],
'host:halted': [constructIcon(faServer), ...constructCircleStatus('halted-circle')],
'host:busy': [constructIcon(faServer), { icon: busy }],
vm: constructIcon(faDesktop),
'vm:unknown': [
{
@@ -166,6 +184,7 @@ export const objectIcons = defineIconPack({
'vm:suspended': [constructIcon(faDesktop), ...constructCircleStatus('suspended-circle')],
'vm:warning': [constructIcon(faDesktop), ...constructCircleStatus('warning-circle')],
'vm:halted': [constructIcon(faDesktop), ...constructCircleStatus('halted-circle')],
'vm:busy': [constructIcon(faDesktop), { icon: busy }],
'vm-snapshot': [
constructIcon(faDesktop),
{
@@ -187,6 +206,7 @@ export const objectIcons = defineIconPack({
'sr:disabled': [constructIcon(faDatabase), ...constructCircleStatus('disabled')],
'sr:partially-connected': [constructIcon(faDatabase), ...constructCircleStatus('warning-circle')],
'sr:disconnected': [constructIcon(faDatabase), ...constructCircleStatus('danger-circle')],
'sr:busy': [constructIcon(faDatabase), { icon: busy }],
vdi: constructIcon(faHdd),
'vdi:unknown': [
{
@@ -202,6 +222,7 @@ export const objectIcons = defineIconPack({
'vdi:disabled': [constructIcon(faHdd), ...constructCircleStatus('disabled')],
'vdi:warning': [constructIcon(faHdd), ...constructCircleStatus('warning-circle')],
'vdi:detached': [constructIcon(faHdd), ...constructCircleStatus('danger-circle')],
'vdi:busy': [constructIcon(faHdd), { icon: busy }],
'vdi-snapshot': [
constructIcon(faHdd),
{
@@ -240,6 +261,7 @@ export const objectIcons = defineIconPack({
'network:connected': [constructIcon(faNetworkWired), ...constructCircleStatus('success-circle')],
'network:partially-connected': [constructIcon(faNetworkWired), ...constructCircleStatus('warning-circle')],
'network:disconnected': [constructIcon(faNetworkWired), ...constructCircleStatus('danger-circle')],
'network:busy': [constructIcon(faNetworkWired), { icon: busy }],
br: constructIcon(faBoxesStacked),
'br:unknown': [
{
@@ -255,6 +277,7 @@ export const objectIcons = defineIconPack({
'br:disabled': [constructIcon(faBoxesStacked), ...constructCircleStatus('disabled')],
'br:warning': [constructIcon(faBoxesStacked), ...constructCircleStatus('warning-circle')],
'br:disconnected': [constructIcon(faBoxesStacked), ...constructCircleStatus('danger-circle')],
'br:busy': [constructIcon(faBoxesStacked), { icon: busy }],
'backup-archive': constructIcon(faArchive),
'backup-job': [
constructIcon(faArchive),

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "وثائق وحدة تحكم SDN",
"traffic-rules:format-warning": "خطأ في التكوين: قواعد حركة المرور ليست بالتنسيق المتوقع. يرجى {check-doc} حول كيفية ترحيلها.",
"traffic-rules:format-warning:check-doc": "مراجعة أحدث الوثائق",
"traffic-rules:info-message": "ترتيب قواعد حركة المرور قيد التنفيذ. قد يكون السلوك غير مضمون.",
"traffic-rules:no-traffic-rules-detected": "لم يتم اكتشاف أي قواعد حركة مرور",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "يتطلب وضع إضافة XAPI",

View File

@@ -1204,7 +1204,6 @@
"traffic-rules:error-plugin:check-doc": "dokumentace k řadiči SDN sítě",
"traffic-rules:format-warning": "Chyba nastavení: Pravidla pro provoz nejsou v očekávaném formátu. {check-doc} ohledně toho, jak je převést.",
"traffic-rules:format-warning:check-doc": "Podívejte se do neaktuálnější dokumentace",
"traffic-rules:info-message": "Implementování pořadí pravidel pro provoz teprve probíhá. Chování může být nebezpečné.",
"traffic-rules:no-traffic-rules-detected": "Nezjištěna žádná pravidla pro provoz",
"traffic-rules:xapi-plugin-required": "Vyžaduje režim XAPI zásuvného modulu",
"translation-tool": "Nástroj pro překládání",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "dokumentationen til SDN-controlleren",
"traffic-rules:format-warning": "Konfigurationsfejl: Trafikreglerne er ikke i det forventede format. Du bedes {check-doc} for, hvordan de migreres.",
"traffic-rules:format-warning:check-doc": "tjekke den seneste dokumentation",
"traffic-rules:info-message": "Rækkefølge for trafikregler er under implementering. Adfærden kan være uforudsigelig.",
"traffic-rules:no-traffic-rules-detected": "Ingen trafikregler registreret",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Kræver XAPI-plugin-tilstand",

View File

@@ -887,7 +887,6 @@
"total-used:": "Gesamt genutzt:",
"traffic-rule": "Traffic Regel",
"traffic-rules": "Traffic Regeln",
"traffic-rules:info-message": "Reihenfolge der Traffic Regeln wird implementiert. Es kann zu unvorgesehenen Verhalten führen.",
"traffic-rules:no-traffic-rules-detected": "Keine Traffic Regeln erkannt",
"translation-tool": "Übersetzungstool",
"trigger": "Trigger",

View File

@@ -85,6 +85,7 @@
"action:edit-br": "@:action:edit BR",
"action:edit-config": "Edit config",
"action:edit-n-brs": "@:action:edit {n} BRs",
"action:edit-rules-order": "@:action:edit rules order within network",
"action:edit-traffic-rule": "@:action:edit @.lower:traffic-rule",
"action:emergency-shutdown": "Emergency shutdown",
"action:emergency-shutdown-host": "@:action:emergency-shutdown host",
@@ -123,7 +124,9 @@
"action:migrate": "Migrate",
"action:migrate-n-vms": "Migrate 1 VM | Migrate {n} VMs",
"action:migrate-vdi-on-sr": "Migrate on SR",
"action:move-down": "Move down",
"action:move-filters-to-parent-group": "Move filters to parent group",
"action:move-up": "Move up",
"action:open": "Open",
"action:open-app": "@:action:open {name}",
"action:open-console-in-new-tab": "Open console in new tab",
@@ -138,6 +141,7 @@
"action:reset-all-networks": "Reset all networks",
"action:reset-all-srs": "Reset all SRs",
"action:reset-to-default": "Reset to default",
"action:resize": "Resize",
"action:restart-toolstack": "Restart toolstack",
"action:resume": "Resume",
"action:revert-to-snapshot": "Revert to snapshot",
@@ -283,6 +287,7 @@
"bond-mode": "Bond mode",
"bond-mode-required": "Bond mode is required.",
"bond-status": "Bond status",
"bonded-network": "Bonded network",
"boot-firmware": "Boot firmware",
"bootable": "Bootable",
"br-details": "Backup repository details",
@@ -537,6 +542,7 @@
"interface": "Interface",
"interface-required": "Interface is required.",
"interfaces": "Interfaces",
"internal-network": "Internal network",
"interrupted": "Interrupted",
"invalid-field": "Invalid field",
"invalid-ip-addresses": "One or more invalid IP addresses",
@@ -571,6 +577,7 @@
"job:arg:missing-payload": "Payload is missing",
"job:arg:name-required": "@:name-required",
"job:arg:nbd-and-insecure-nbd-exclusive": "NBD and insecure NBD cannot be enabled together",
"job:arg:network-required": "@:network-required",
"job:arg:password-required": "Password is required",
"job:arg:pif-id-required": "PIF ID is required",
"job:arg:pif-ids-required": "At least one PIF ID is required",
@@ -687,6 +694,8 @@
"job:sr-delete:missing-sr": "No SRs to delete",
"job:sr-disconnect:missing-sr": "No SRs to disconnect",
"job:traffic-rule-delete:missing-rule": "No traffic rule selected",
"job:traffic-rule-edit-order:no-free-priority": "No free priority left on this network to apply this order",
"job:traffic-rule-edit-order:not-enough-rules": "Requires at least {n} rules",
"job:vbd-connect:missing-vbd": "No VBDs to connect",
"job:vbd-connect:vbd-already-connected": "VBD is already connected",
"job:vbd-delete:missing-vbd": "No VBDs to delete",
@@ -1026,6 +1035,7 @@
"ok": "OK",
"on": "On",
"on-object": "on {object}",
"operation-in-progress": "{operation}…",
"operation:clean-reboot": "Rebooting",
"operation:clean-shutdown": "Shutting down",
"operation:destroy": "Deleting",
@@ -1076,6 +1086,7 @@
"pbd-details": "Physical Block Device (PBD) details",
"pending": "Pending",
"physical-interface-status": "Physical interface status",
"physical-network": "Physical network",
"pif": "PIF",
"pif-status": "PIF status",
"pifs": "PIFs",
@@ -1127,6 +1138,8 @@
"power-on-mode": "Power on mode",
"power-on-vm-for-console": "Power on your VM to access its console",
"power-state": "Power state",
"priority": "Priority",
"priority-within": "Priority within {name}",
"pro-support": "{name} pro support",
"professional-support": "Professional support",
"progress": "Progress",
@@ -1364,6 +1377,7 @@
"theme-solarized-description": "Precision colors for reduced eye strain",
"third-parties": "Third party apps",
"this-host": "This host",
"this-rule": "This rule",
"this-vm-cant-be-migrated": "This VM can't be migrated",
"timeout": "Timeout",
"timezone": "Timezone",
@@ -1387,14 +1401,18 @@
"traffic-rule": "Traffic rule",
"traffic-rule-delete-warning": "This action is irreversible. The selected traffic rule will be permanently deleted.",
"traffic-rules": "Traffic rules",
"traffic-rules:edit-order-info": "Traffic rules are evaluated by priority, starting with rule 1, then rule 2, and so on. The first matching rule is applied, and subsequent rules are skipped. Priority only matters within the same network.",
"traffic-rules:edit-order-save-info": "Changes will only take effect when you save.",
"traffic-rules:error-plugin": "You are using the direct OpenFlow protocol (legacy mode), which does not support network traffic rules. To create them, switch to the XAPI plugin mode (beta) — see {check-doc}.",
"traffic-rules:error-plugin:check-doc": "the SDN controller documentation",
"traffic-rules:format-warning": "Configuration error: Traffic rules are not in the expected format. Please {check-doc} on how to migrate them.",
"traffic-rules:format-warning:check-doc": "check the latest documentation",
"traffic-rules:info-message": "Traffic rules order is being implemented. Behaviour may be hazardous.",
"traffic-rules:no-traffic-rules-detected": "No traffic rules detected",
"traffic-rules:position-on-network": "{position} rule on this network.",
"traffic-rules:rule-ordinal-suffixes": "one:st, two:nd, few:rd, other:th",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Requires XAPI plugin mode",
"traffic-rules:xapi-plugin-required": "Requires @:xapi-plugin mode",
"traffic-rules:xapi-plugin-required-with-link": "Requires {xapi-plugin} mode",
"translation-tool": "Translation tool",
"treeview": "Treeview",
"trigger": "Trigger",
@@ -1546,6 +1564,7 @@
"with-memory": "With memory",
"write": "Write",
"writing-speed": "Writing speed",
"xapi-plugin": "XAPI plugin",
"xcp-ng": "XCP-ng",
"xen-orchestra": "Xen Orchestra",
"xo": "XO",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "مستندات کنترلر SDN",
"traffic-rules:format-warning": "خطای پیکربندی: قوانین ترافیک در قالب مورد انتظار نیستند. لطفاً برای نحوه مهاجرت آن‌ها {check-doc}.",
"traffic-rules:format-warning:check-doc": "آخرین مستندات را بررسی کنید",
"traffic-rules:info-message": "ترتیب قوانین ترافیک در حال پیاده‌سازی است. رفتار ممکن است مخاطره‌آمیز باشد.",
"traffic-rules:no-traffic-rules-detected": "هیچ قانون ترافیکی شناسایی نشد",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "نیازمند حالت افزونه XAPI",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "SDN-ohjaimen dokumentaatio",
"traffic-rules:format-warning": "Määritysvirhe: liikennesäännöt eivät ole odotetussa muodossa. {check-doc}, miten ne siirretään.",
"traffic-rules:format-warning:check-doc": "Tarkista uusimmasta dokumentaatiosta",
"traffic-rules:info-message": "Liikennesääntöjen järjestystä ollaan toteuttamassa. Toiminta voi olla arvaamatonta.",
"traffic-rules:no-traffic-rules-detected": "Liikennesääntöjä ei havaittu",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Vaatii XAPI-lisäosatilan",

View File

@@ -85,6 +85,7 @@
"action:edit-br": "@:action:edit le BR",
"action:edit-config": "Modifier config",
"action:edit-n-brs": "@:action:edit {n} BRs",
"action:edit-rules-order": "@:action:edit l'ordre des règles dans le réseau",
"action:edit-traffic-rule": "@:action:edit la @.lower:traffic-rule",
"action:emergency-shutdown": "Arrêt d'urgence",
"action:emergency-shutdown-host": "@:action:emergency-shutdown de l'hôte",
@@ -123,7 +124,9 @@
"action:migrate": "Migrer",
"action:migrate-n-vms": "Migrer 1 VM | Migrer {n} VMs",
"action:migrate-vdi-on-sr": "Migrer sur le SR",
"action:move-down": "Descendre",
"action:move-filters-to-parent-group": "Déplacer les filtres vers le groupe parent",
"action:move-up": "Monter",
"action:open": "Ouvrir",
"action:open-app": "@:action:open {name}",
"action:open-console-in-new-tab": "Ouvrir la console dans un nouvel onglet",
@@ -138,6 +141,7 @@
"action:reset-all-networks": "Réinitialiser tous les réseaux",
"action:reset-all-srs": "Réinitialiser tous les SRs",
"action:reset-to-default": "Rétablir la valeur par défaut",
"action:resize": "Redimensionner",
"action:restart-toolstack": "Redémarrer le toolstack",
"action:resume": "Reprendre",
"action:revert-to-snapshot": "Revenir à l'instantané",
@@ -283,6 +287,7 @@
"bond-mode": "Mode de liaison",
"bond-mode-required": "Le mode de liaison est requis.",
"bond-status": "Statut du bond",
"bonded-network": "Réseau lié",
"boot-firmware": "Firmware de démarrage",
"bootable": "Amorçable",
"br-details": "Détails du BR",
@@ -537,6 +542,7 @@
"interface": "Interface",
"interface-required": "L'interface est requise.",
"interfaces": "Interfaces",
"internal-network": "Réseau interne",
"interrupted": "Interrompu",
"invalid-field": "Champ invalide",
"invalid-ip-addresses": "Une ou plusieurs adresses IPs invalides",
@@ -571,6 +577,7 @@
"job:arg:missing-payload": "Aucune donnée fournie pour l'execution du job",
"job:arg:name-required": "@:name-required",
"job:arg:nbd-and-insecure-nbd-exclusive": "NBD et NBD non sécurisé ne peuvent pas être activés ensemble",
"job:arg:network-required": "@:network-required",
"job:arg:password-required": "Le mot de passe est obligatoire",
"job:arg:pif-id-required": "L'ID de PIF est obligatoire",
"job:arg:pif-ids-required": "Au moins un ID de PIF est obligatoire",
@@ -687,6 +694,8 @@
"job:sr-delete:missing-sr": "Aucun SR à supprimer",
"job:sr-disconnect:missing-sr": "Aucun SR à déconnecter",
"job:traffic-rule-delete:missing-rule": "Aucune règle de trafic sélectionnée",
"job:traffic-rule-edit-order:no-free-priority": "Plus aucune priorité libre sur ce réseau pour appliquer cet ordre",
"job:traffic-rule-edit-order:not-enough-rules": "Nécessite au moins {n} règles",
"job:vbd-connect:missing-vbd": "Aucun VBD à connecter",
"job:vbd-connect:vbd-already-connected": "Le VBD est déjà connecté",
"job:vbd-delete:missing-vbd": "Aucun VBD à supprimer",
@@ -1026,6 +1035,7 @@
"ok": "OK",
"on": "Allumé",
"on-object": "sur {object}",
"operation-in-progress": "{operation}…",
"operation:clean-reboot": "Redémarrage",
"operation:clean-shutdown": "Arrêt",
"operation:destroy": "Suppression",
@@ -1076,6 +1086,7 @@
"pbd-details": "Détails du périphérique de bloc physique (PBD)",
"pending": "En cours",
"physical-interface-status": "Statut de l'interface physique",
"physical-network": "Réseau physique",
"pif": "PIF",
"pif-status": "Statut du PIF",
"pifs": "PIFs",
@@ -1127,6 +1138,8 @@
"power-on-mode": "Mode d'alimentation",
"power-on-vm-for-console": "Allumez votre VM pour accéder à sa console",
"power-state": "État d'alimentation",
"priority": "Priorité",
"priority-within": "Priorité dans {name}",
"pro-support": "Support pro {name}",
"professional-support": "Support professionnel",
"progress": "Progression",
@@ -1364,6 +1377,7 @@
"theme-solarized-description": "Couleurs précises pour réduire la fatigue oculaire",
"third-parties": "Apps tierces",
"this-host": "Cet hôte",
"this-rule": "Cette règle",
"this-vm-cant-be-migrated": "Cette VM ne peut pas être migrée",
"timeout": "Délai d'attente",
"timezone": "Fuseau horaire",
@@ -1387,14 +1401,18 @@
"traffic-rule": "Règle de trafic",
"traffic-rule-delete-warning": "Cette action est irréversible. La règle de trafic sélectionnée sera définitivement supprimée.",
"traffic-rules": "Règles de trafic",
"traffic-rules:edit-order-info": "Les règles de trafic sont évaluées par priorité, en commençant par la règle 1, puis la règle 2, et ainsi de suite. La première règle correspondante est appliquée et les suivantes sont ignorées. La priorité ne compte qu'au sein d'un même réseau.",
"traffic-rules:edit-order-save-info": "Les modifications ne prendront effet qu'à l'enregistrement.",
"traffic-rules:error-plugin": "Vous utilisez le protocole OpenFlow direct (mode historique), qui ne prend pas en charge les règles de trafic réseau. Pour en créer, passez en mode plugin XAPI (bêta) — voir {check-doc}.",
"traffic-rules:error-plugin:check-doc": "la documentation du contrôleur SDN",
"traffic-rules:format-warning": "Erreur de configuration : les règles de trafic ne sont pas au format attendu. Veuillez {check-doc} la plus récente pour savoir comment les migrer.",
"traffic-rules:format-warning:check-doc": "consulter la documentation",
"traffic-rules:info-message": "Les règles de trafic sont en cours d'application. Certains comportements peuvent être hasardeux.",
"traffic-rules:no-traffic-rules-detected": "Aucune règle de trafic détectée",
"traffic-rules:position-on-network": "{position} règle sur ce réseau.",
"traffic-rules:rule-ordinal-suffixes": "one:re, other:e",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Nécessite le mode plugin XAPI",
"traffic-rules:xapi-plugin-required": "Nécessite le mode @:xapi-plugin",
"traffic-rules:xapi-plugin-required-with-link": "Nécessite le mode {xapi-plugin}",
"translation-tool": "Outil de traduction",
"treeview": "Arborescence",
"trigger": "Déclencheur",
@@ -1546,6 +1564,7 @@
"with-memory": "Avec mémoire",
"write": "Écriture",
"writing-speed": "Vitesse d'écriture",
"xapi-plugin": "plugin XAPI",
"xcp-ng": "XCP-ng",
"xen-orchestra": "Xen Orchestra",
"xo": "XO",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "la documentazione del controller SDN",
"traffic-rules:format-warning": "Errore di configurazione: le regole di traffico non sono nel formato previsto. {check-doc} su come migrarle.",
"traffic-rules:format-warning:check-doc": "Consulta la documentazione più recente",
"traffic-rules:info-message": "L'ordine delle regole di traffico è in fase di implementazione. Il comportamento potrebbe essere imprevedibile.",
"traffic-rules:no-traffic-rules-detected": "Nessuna regola di traffico rilevata",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Richiede la modalità plugin XAPI",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "SDN コントローラーのドキュメント",
"traffic-rules:format-warning": "設定エラー: トラフィックルールが想定された形式ではありません。移行方法については{check-doc}。",
"traffic-rules:format-warning:check-doc": "最新のドキュメントを確認してください",
"traffic-rules:info-message": "トラフィックルールの順序は実装中です。動作が不安定な場合があります。",
"traffic-rules:no-traffic-rules-detected": "トラフィックルールは検出されませんでした",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "XAPI プラグインモードが必要です",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "SDN 컨트롤러 문서",
"traffic-rules:format-warning": "구성 오류: 트래픽 규칙이 예상된 형식이 아닙니다. 마이그레이션 방법은 {check-doc}.",
"traffic-rules:format-warning:check-doc": "최신 문서를 확인하세요",
"traffic-rules:info-message": "트래픽 규칙 순서 기능을 구현 중입니다. 동작이 불안정할 수 있습니다.",
"traffic-rules:no-traffic-rules-detected": "감지된 트래픽 규칙 없음",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "XAPI 플러그인 모드 필요",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "dokumentasjonen for SDN-kontrolleren",
"traffic-rules:format-warning": "Konfigurasjonsfeil: Trafikkreglene har ikke forventet format. Vennligst {check-doc} om hvordan du migrerer dem.",
"traffic-rules:format-warning:check-doc": "se den nyeste dokumentasjonen",
"traffic-rules:info-message": "Rekkefølge for trafikkregler er under implementering. Oppførselen kan være uforutsigbar.",
"traffic-rules:no-traffic-rules-detected": "Ingen trafikkregler oppdaget",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Krever XAPI-plugin-modus",

View File

@@ -1171,7 +1171,6 @@
"traffic-rules:error-plugin:check-doc": "de documentatie van de SDN-controller",
"traffic-rules:format-warning": "Configuratiefout: De verkeersregels hebben niet de verwachte indeling. Raadpleeg {check-doc} voor informatie over hoe je ze kunt migreren.",
"traffic-rules:format-warning:check-doc": "raadpleeg de meest recente documentatie",
"traffic-rules:info-message": "De verkeersregels worden gehandhaafd. Het gedrag kan gevaarlijk zijn.",
"traffic-rules:no-traffic-rules-detected": "Er zijn geen verkeersregels gevonden",
"traffic-rules:xapi-plugin-required": "Vereist de XAPI-plug-in modus",
"translation-tool": "Vertaalprogramma",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "dokumentację kontrolera SDN",
"traffic-rules:format-warning": "Błąd konfiguracji: reguły ruchu nie są w oczekiwanym formacie. {check-doc}, jak je zmigrować.",
"traffic-rules:format-warning:check-doc": "Sprawdź w najnowszej dokumentacji",
"traffic-rules:info-message": "Kolejność reguł ruchu jest w trakcie implementacji. Działanie może być nieprzewidywalne.",
"traffic-rules:no-traffic-rules-detected": "Nie wykryto reguł ruchu",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Wymaga trybu wtyczki XAPI",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "a documentação do controlador SDN",
"traffic-rules:format-warning": "Erro de configuração: as regras de tráfego não estão no formato esperado. Por favor, {check-doc} sobre como migrá-las.",
"traffic-rules:format-warning:check-doc": "consulte a documentação mais recente",
"traffic-rules:info-message": "A ordem das regras de tráfego está sendo implementada. O comportamento pode ser imprevisível.",
"traffic-rules:no-traffic-rules-detected": "Nenhuma regra de tráfego detectada",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Requer o modo de plugin XAPI",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "документацию SDN-контроллера",
"traffic-rules:format-warning": "Ошибка конфигурации: правила для трафика имеют неожиданный формат. Пожалуйста, {check-doc} о том, как их мигрировать.",
"traffic-rules:format-warning:check-doc": "ознакомьтесь с последней документацией",
"traffic-rules:info-message": "Порядок правил для трафика находится в разработке. Поведение может быть непредсказуемым.",
"traffic-rules:no-traffic-rules-detected": "Правила для трафика не обнаружены",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Требуется режим плагина XAPI",

View File

@@ -1171,7 +1171,6 @@
"traffic-rules:error-plugin:check-doc": "dokumentácia k radiču SDN siete",
"traffic-rules:format-warning": "Chyba nastavenia: Pravidlá pre prevádzku nie sú v očakávanom formáte. {check-doc} ohľadom toho, ako ich previesť.",
"traffic-rules:format-warning:check-doc": "Pozrite sa do najaktuálnejšej dokumentácie",
"traffic-rules:info-message": "Implementácia poradia pravidiel pre prevádzku ešte prebieha. Chovanie môže byť nebezpečné.",
"traffic-rules:no-traffic-rules-detected": "Nezjištěná žiadna pravidla pre prevádzku",
"traffic-rules:xapi-plugin-required": "Vyžaduje režim XAPI zásuvného modulu",
"translation-tool": "Prekladový nástroj",

View File

@@ -1090,7 +1090,6 @@
"total-used:": "Totalt använt:",
"traffic-rule": "Trafikregel",
"traffic-rules": "Trafikregler",
"traffic-rules:info-message": "Trafikreglernas ordning införs. Beteendet kan vara farligt.",
"traffic-rules:no-traffic-rules-detected": "Inga trafikregler upptäckta",
"translation-tool": "Översättningsverktyg",
"trigger": "Utlösare",

View File

@@ -1161,7 +1161,6 @@
"traffic-rules:error-plugin:check-doc": "SDN denetleyicisi belgelerine",
"traffic-rules:format-warning": "Yapılandırma hatası: Trafik kuralları beklenen biçimde değil. Bunları nasıl taşıyacağınızı öğrenmek için lütfen {check-doc}.",
"traffic-rules:format-warning:check-doc": "en son belgelere bakın",
"traffic-rules:info-message": "Trafik kurallarının sırası henüz uygulanıyor. Davranış riskli olabilir.",
"traffic-rules:no-traffic-rules-detected": "Trafik kuralı bulunamadı",
"traffic-rules:xapi-plugin-required": "XAPI eklenti modu gerekir",
"translation-tool": "Çeviri aracı",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "документацію контролера SDN",
"traffic-rules:format-warning": "Помилка конфігурації: правила трафіку мають неочікуваний формат. Будь ласка, {check-doc}, щоб дізнатися, як їх мігрувати.",
"traffic-rules:format-warning:check-doc": "перегляньте найновішу документацію",
"traffic-rules:info-message": "Порядок правил трафіку ще впроваджується. Поведінка може бути непередбачуваною.",
"traffic-rules:no-traffic-rules-detected": "Правил трафіку не виявлено",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Потрібен режим плагіна XAPI",

View File

@@ -1171,7 +1171,6 @@
"traffic-rules:error-plugin:check-doc": "SDN控制器文档",
"traffic-rules:format-warning": "配置错误:流量规则格式异常,请参考{check-doc}完成迁移。",
"traffic-rules:format-warning:check-doc": "最新文档",
"traffic-rules:info-message": "流量规则顺序正在生效,行为可能存在异常。",
"traffic-rules:no-traffic-rules-detected": "未检测到流量规则",
"traffic-rules:xapi-plugin-required": "需要XAPI插件模式",
"translation-tool": "翻译工具",

View File

@@ -1,5 +1,5 @@
import { useResizableWidth } from '@core/composables/resizable-width.composable.ts'
import { SIDEBAR_DEFAULTS, type SidebarSide } from '@core/packages/sidebar/types.ts'
import { useSidebarResize } from '@core/packages/sidebar/use-sidebar-resize.ts'
import { useSidebarResponsiveExpand } from '@core/packages/sidebar/use-sidebar-responsive-expand.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { useLocalStorage, useToggle } from '@vueuse/core'
@@ -13,15 +13,14 @@ function createSidebar(side: SidebarSide) {
const isExpanded = useLocalStorage(`${prefix}.expanded`, defaults.isExpanded)
const isLocked = useLocalStorage(`${prefix}.locked`, defaults.isLocked)
const width = useLocalStorage(`${prefix}.width`, defaults.width)
const toggleExpand = useToggle(isExpanded)
const toggleLock = useToggle(isLocked)
const { startResize, isResizing } = useSidebarResize(side, width)
const resizableWidth = useResizableWidth(side, `${prefix}.custom-width`)
useSidebarResponsiveExpand(isExpanded)
const cssWidth = computed(() => (uiStore.isSmall ? '100%' : `${width.value}px`))
const cssWidth = computed(() => (uiStore.isSmall ? '100%' : `${resizableWidth.width.value}px`))
const cssLockedOffset = computed(() => (isExpanded.value ? '0' : `-${cssWidth.value}`))
@@ -36,14 +35,12 @@ function createSidebar(side: SidebarSide) {
return {
isExpanded,
isLocked,
width,
toggleExpand,
toggleLock,
startResize,
isResizing,
cssWidth,
cssLockedOffset,
cssUnlockedOffset,
...resizableWidth,
}
}

View File

@@ -1,21 +1,20 @@
import type { ResizableWidth } from '@core/composables/resizable-width.composable.ts'
export const SIDES = ['left', 'right'] as const
export type SidebarSide = (typeof SIDES)[number]
export type SidebarState = {
export type SidebarState = ResizableWidth & {
isExpanded: boolean
isLocked: boolean
width: number
toggleExpand: (value?: boolean) => boolean
toggleLock: (value?: boolean) => boolean
startResize: (event: MouseEvent) => void
isResizing: boolean
cssWidth: string
cssLockedOffset: string | number
cssUnlockedOffset: string | number
}
export const SIDEBAR_DEFAULTS = {
left: { isExpanded: true, isLocked: true, width: 350 },
right: { isExpanded: false, isLocked: false, width: 350 },
} satisfies Record<SidebarSide, Pick<SidebarState, 'isExpanded' | 'isLocked' | 'width'>>
left: { isExpanded: true, isLocked: true },
right: { isExpanded: false, isLocked: false },
} satisfies Record<SidebarSide, Pick<SidebarState, 'isExpanded' | 'isLocked'>>

View File

@@ -1,47 +0,0 @@
import type { SidebarSide } from '@core/packages/sidebar/types.ts'
import { ifElse } from '@core/utils/if-else.utils.ts'
import { useRafFn, useStyleTag } from '@vueuse/core'
import { ref, type Ref } from 'vue'
export function useSidebarResize(side: SidebarSide, width: Ref<number>) {
const isResizing = ref(false)
let initialX: number
let initialWidth: number
let clientX: number
function startResize(event: MouseEvent) {
clientX = event.clientX
initialX = clientX
initialWidth = width.value
isResizing.value = true
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
function handleMouseMove(event: MouseEvent) {
clientX = event.clientX
}
function handleMouseUp() {
isResizing.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
const { load, unload } = useStyleTag('body { cursor: col-resize !important; }', { immediate: false })
const resizeDelta = () => (side === 'left' ? initialWidth + clientX - initialX : initialWidth + initialX - clientX)
const { pause, resume } = useRafFn(
() => (width.value = Math.min(500, window.innerWidth * 0.3, Math.max(200, resizeDelta()))),
{ immediate: false }
)
ifElse(isResizing, [load, resume], [pause, unload])
return {
startResize,
isResizing,
}
}

View File

@@ -12,6 +12,8 @@ export type Identifiable = { id: TreeNodeId }
export type Labeled = { label: string }
export const TREE_ITEM_SMALL_HEIGHT = 32
type AcceptableKeys<TData, TAccepted> = {
[K in keyof TData]: TData[K] extends TAccepted ? K : never
}[keyof TData]

View File

@@ -1,13 +1,20 @@
import { useResizableWidth } from '@core/composables/resizable-width.composable.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, watch } from 'vue'
export const PANEL_TRANSITION_DURATION = 250
export const usePanelStore = defineStore('panel', () => {
const uiStore = useUiStore()
const isExpanded = useLocalStorage('panel.expanded', true)
const isLocked = useLocalStorage('panel.locked', true)
const resizableWidth = useResizableWidth('right', 'panel.custom-width')
const cssWidth = computed(() => `${resizableWidth.width.value}px`)
const cssHorizontalOffset = computed(() => (isExpanded.value ? 0 : '100%'))
/* Panel closes automatically when it has no content (true on small UI or when not locked) */
const syncsOpenStateWithSelection = computed(() => uiStore.isSmall || !isLocked.value)
@@ -59,5 +66,7 @@ export const usePanelStore = defineStore('panel', () => {
toggleLock,
syncWithSelection,
cssHorizontalOffset,
cssWidth,
...resizableWidth,
}
})

View File

@@ -0,0 +1,12 @@
import VtsButtonIconListCell, { type ButtonIconItem } from '@core/components/table/cells/VtsButtonIconListCell.vue'
import { defineColumn } from '@core/packages/table/define-column.ts'
import { renderHeadCell } from '@core/tables/helpers/render-head-cell.ts'
import type { HeaderConfig } from '@core/tables/types.ts'
import { h } from 'vue'
export type { ButtonIconItem }
export const useButtonIconListColumn = defineColumn((config?: HeaderConfig) => ({
renderHead: () => renderHeadCell(config?.headerLabel),
renderBody: (buttons: ButtonIconItem[]) => h(VtsButtonIconListCell, { buttons }),
}))

View File

@@ -0,0 +1,10 @@
import VtsRankCell from '@core/components/table/cells/VtsRankCell.vue'
import { defineColumn } from '@core/packages/table/define-column.ts'
import { renderHeadCell } from '@core/tables/helpers/render-head-cell.ts'
import type { HeaderConfig } from '@core/tables/types.ts'
import { h } from 'vue'
export const useRankColumn = defineColumn((config?: HeaderConfig) => ({
renderHead: () => renderHeadCell(config?.headerLabel),
renderBody: (rank: number, shift?: number) => h(VtsRankCell, { rank, shift }),
}))

Some files were not shown because too many files have changed in this diff Show More