mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
16 Commits
c145a7a579
...
xo6/traffi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abdcbc1088 | ||
|
|
3c4ae9c70f | ||
|
|
8e81250ca3 | ||
|
|
c8c11ae63c | ||
|
|
8df236a512 | ||
|
|
8cc3384850 | ||
|
|
e4cc8df323 | ||
|
|
43a7e3d3ac | ||
|
|
2e586f0369 | ||
|
|
93dc079f71 | ||
|
|
9c6a59bb89 | ||
|
|
12aa9576b9 | ||
|
|
85541ce5d7 | ||
|
|
56a33c1639 | ||
|
|
7f6ea616ca | ||
|
|
5601a64703 |
@@ -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"
|
||||
|
||||
38
@vates/types/src/backup-expression-context.mts
Normal file
38
@vates/types/src/backup-expression-context.mts
Normal 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]
|
||||
@@ -772,6 +772,7 @@ export type RawTrafficRule = {
|
||||
ipRange: string
|
||||
direction: TrafficRuleDirection
|
||||
port?: string
|
||||
priority?: number
|
||||
}
|
||||
|
||||
type BaseTrafficRule = RawTrafficRule & {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
111
@xen-orchestra/backups/_buildContext.mjs
Normal file
111
@xen-orchestra/backups/_buildContext.mjs
Normal 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,
|
||||
}
|
||||
}
|
||||
147
@xen-orchestra/backups/_buildContext.test.mjs
Normal file
147
@xen-orchestra/backups/_buildContext.test.mjs
Normal 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()
|
||||
)
|
||||
})
|
||||
})
|
||||
24
@xen-orchestra/backups/_expressionPredicate.mjs
Normal file
24
@xen-orchestra/backups/_expressionPredicate.mjs
Normal 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))
|
||||
}
|
||||
95
@xen-orchestra/backups/_expressionPredicate.test.mjs
Normal file
95
@xen-orchestra/backups/_expressionPredicate.test.mjs
Normal 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 '))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '-';
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
13
@xen-orchestra/lite/src/route-map.d.ts
vendored
13
@xen-orchestra/lite/src/route-map.d.ts
vendored
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
]"
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'][] = []
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"> </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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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í",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 プラグインモードが必要です",
|
||||
|
||||
@@ -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 플러그인 모드 필요",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "翻译工具",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'>>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 }),
|
||||
}))
|
||||
@@ -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
Reference in New Issue
Block a user