Compare commits
145 Commits
feat_more_
...
lite/ui-ic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d0dccf5ba | ||
|
|
185509a0cf | ||
|
|
08298d3284 | ||
|
|
7a4cec5093 | ||
|
|
f44f5199c6 | ||
|
|
81abc091de | ||
|
|
7e4f4c445d | ||
|
|
5a673c1833 | ||
|
|
266231ae0f | ||
|
|
9e87a887cb | ||
|
|
12e98bfd31 | ||
|
|
249f124ba6 | ||
|
|
131643a91b | ||
|
|
df3df18690 | ||
|
|
5401d17610 | ||
|
|
90ea2284c6 | ||
|
|
a4c5792f9e | ||
|
|
5723598923 | ||
|
|
aa0b2ff93a | ||
|
|
be6233f12b | ||
|
|
17df749790 | ||
|
|
97f852f8e8 | ||
|
|
dc3446d61a | ||
|
|
c830a0b208 | ||
|
|
ff0307b68f | ||
|
|
1c3cad9235 | ||
|
|
ccafc15b66 | ||
|
|
a40d6b32e3 | ||
|
|
de1ee92fe7 | ||
|
|
c7227d2f50 | ||
|
|
b2cebbfaf4 | ||
|
|
30fbbc92ca | ||
|
|
d1b210cf16 | ||
|
|
9963568368 | ||
|
|
ffc3249b33 | ||
|
|
29826db81b | ||
|
|
5367a76db5 | ||
|
|
2512a00205 | ||
|
|
72a3a9f04f | ||
|
|
b566e0fd46 | ||
|
|
4621fb4e9b | ||
|
|
7f3d25964f | ||
|
|
4b3728e8d8 | ||
|
|
5218d6df1a | ||
|
|
94b2b8ec70 | ||
|
|
6d1086539e | ||
|
|
7f758bbb73 | ||
|
|
62b88200c3 | ||
|
|
ce42883268 | ||
|
|
6b60cfce4d | ||
|
|
aebb47ad38 | ||
|
|
41f5634b7a | ||
|
|
87ddb01122 | ||
|
|
6898eea45e | ||
|
|
ba2679d3d7 | ||
|
|
971cdaa44f | ||
|
|
005d3b5976 | ||
|
|
663403cb14 | ||
|
|
b341e38623 | ||
|
|
8246db30cb | ||
|
|
9c9c656620 | ||
|
|
f36be0d5e0 | ||
|
|
72090ea8ff | ||
|
|
8d64a0a232 | ||
|
|
35974a0a33 | ||
|
|
3023439028 | ||
|
|
77f4a09d74 | ||
|
|
0fc797f7d0 | ||
|
|
0b02c84e33 | ||
|
|
e03ff0a9be | ||
|
|
d91f1841c0 | ||
|
|
0effc9cfc1 | ||
|
|
f08cbb458d | ||
|
|
b8c9770d43 | ||
|
|
44ff5d0e4d | ||
|
|
ecb580a629 | ||
|
|
0623d837c1 | ||
|
|
f92d1ce4ac | ||
|
|
88f84069d6 | ||
|
|
b9b7081184 | ||
|
|
ce3e0817db | ||
|
|
55b65a8bf6 | ||
|
|
6767141661 | ||
|
|
2eb3b15930 | ||
|
|
b63c4a0d4f | ||
|
|
1269ddfeae | ||
|
|
afd47f5522 | ||
|
|
7ede6bdbce | ||
|
|
03b505e40e | ||
|
|
ed7ff1fad4 | ||
|
|
2dda1aecce | ||
|
|
720e363577 | ||
|
|
545a65521a | ||
|
|
0cf6f94677 | ||
|
|
14e205ab69 | ||
|
|
c3da87a40c | ||
|
|
5d93b05088 | ||
|
|
2cdd33cb7a | ||
|
|
dc909fdfb0 | ||
|
|
a43199b754 | ||
|
|
876211879f | ||
|
|
fe323b8fe5 | ||
|
|
b60f5d593b | ||
|
|
2d4317b681 | ||
|
|
caf0eb3762 | ||
|
|
c1aa7b9d8a | ||
|
|
6c6efd9cfb | ||
|
|
551670a8b9 | ||
|
|
ac75225e7d | ||
|
|
20dbbeb38e | ||
|
|
37dea9980e | ||
|
|
5cec2d4cb0 | ||
|
|
ed76fa5141 | ||
|
|
389a765825 | ||
|
|
3bad40095a | ||
|
|
1a51c66028 | ||
|
|
05161bd4df | ||
|
|
db1102750f | ||
|
|
42a974476f | ||
|
|
0dd91c1efe | ||
|
|
756d2fe4e7 | ||
|
|
61c64b49c7 | ||
|
|
c2eb68a31a | ||
|
|
f1a1b922c7 | ||
|
|
a2dcceb470 | ||
|
|
1d78fdd673 | ||
|
|
4a53749ca0 | ||
|
|
7f73ec52d6 | ||
|
|
4abb172976 | ||
|
|
c52e0a5531 | ||
|
|
0197758780 | ||
|
|
e2521b6688 | ||
|
|
13f19de1a0 | ||
|
|
5e589019d0 | ||
|
|
feaad13ac3 | ||
|
|
ab9428a9c4 | ||
|
|
c964a1471a | ||
|
|
424322f7b7 | ||
|
|
956a4f8b2a | ||
|
|
d87210e903 | ||
|
|
3d3b63a596 | ||
|
|
4f9636b4c3 | ||
|
|
74c8d56046 | ||
|
|
2d6b827fd2 | ||
|
|
f82eb8aeb4 |
@@ -28,8 +28,10 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.spec.{,c,m}js'],
|
||||
files: ['*.{spec,test}.{,c,m}js'],
|
||||
rules: {
|
||||
'n/no-unpublished-require': 'off',
|
||||
'n/no-unpublished-import': 'off',
|
||||
'n/no-unsupported-features/node-builtins': [
|
||||
'error',
|
||||
{
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/bug_report.md
vendored
8
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -6,7 +6,10 @@ labels: 'status: triaging :triangular_flag_on_post:, type: bug :bug:'
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**XOA or XO from the sources?**
|
||||
1. ⚠️ **If you don't follow this template, the issue will be closed**.
|
||||
2. ⚠️ **If your issue can't be easily reproduced, please report it [on the forum first](https://xcp-ng.org/forum/category/12/xen-orchestra)**.
|
||||
|
||||
Are you using XOA or XO from the sources?
|
||||
|
||||
If XOA:
|
||||
|
||||
@@ -15,6 +18,7 @@ If XOA:
|
||||
|
||||
If XO from the sources:
|
||||
|
||||
- Provide **your commit number**. If it's older than a week, we won't investigate
|
||||
- Don't forget to [read this first](https://xen-orchestra.com/docs/community.html)
|
||||
- As well as follow [this guide](https://xen-orchestra.com/docs/community.html#report-a-bug)
|
||||
|
||||
@@ -38,8 +42,6 @@ If applicable, add screenshots to help explain your problem.
|
||||
**Environment (please provide the following information):**
|
||||
|
||||
- Node: [e.g. 16.12.1]
|
||||
- xo-server: [e.g. 5.82.3]
|
||||
- xo-web: [e.g. 5.87.0]
|
||||
- hypervisor: [e.g. XCP-ng 8.2.0]
|
||||
|
||||
**Additional context**
|
||||
|
||||
15
.github/workflows/push.yml
vendored
15
.github/workflows/push.yml
vendored
@@ -1,13 +1,12 @@
|
||||
name: CI
|
||||
on: [push]
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: satackey/action-docker-layer-caching@v0.0.11
|
||||
# Ignore the failure of a step and avoid terminating the job.
|
||||
continue-on-error: true
|
||||
- run: docker-compose -f docker/docker-compose.dev.yml build
|
||||
- run: docker-compose -f docker/docker-compose.dev.yml up
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Build docker image
|
||||
run: docker-compose -f docker/docker-compose.dev.yml build
|
||||
- name: Create the container and start the tests
|
||||
run: docker-compose -f docker/docker-compose.dev.yml up --exit-code-from xo
|
||||
@@ -1,6 +1,8 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
const { describe, it, beforeEach } = require('test')
|
||||
const assert = require('assert').strict
|
||||
const { spy } = require('sinon')
|
||||
|
||||
const { asyncEach } = require('./')
|
||||
|
||||
@@ -34,12 +36,18 @@ describe('asyncEach', () => {
|
||||
})
|
||||
|
||||
it('works', async () => {
|
||||
const iteratee = jest.fn(async () => {})
|
||||
const iteratee = spy(async () => {})
|
||||
|
||||
await asyncEach.call(thisArg, iterable, iteratee, { concurrency: 1 })
|
||||
|
||||
expect(iteratee.mock.instances).toEqual(Array.from(values, () => thisArg))
|
||||
expect(iteratee.mock.calls).toEqual(Array.from(values, (value, index) => [value, index, iterable]))
|
||||
assert.deepStrictEqual(
|
||||
iteratee.thisValues,
|
||||
Array.from(values, () => thisArg)
|
||||
)
|
||||
assert.deepStrictEqual(
|
||||
iteratee.args,
|
||||
Array.from(values, (value, index) => [value, index, iterable])
|
||||
)
|
||||
})
|
||||
;[1, 2, 4].forEach(concurrency => {
|
||||
it('respects a concurrency of ' + concurrency, async () => {
|
||||
@@ -49,7 +57,7 @@ describe('asyncEach', () => {
|
||||
values,
|
||||
async () => {
|
||||
++running
|
||||
expect(running).toBeLessThanOrEqual(concurrency)
|
||||
assert.deepStrictEqual(running <= concurrency, true)
|
||||
await randomDelay()
|
||||
--running
|
||||
},
|
||||
@@ -59,42 +67,52 @@ describe('asyncEach', () => {
|
||||
})
|
||||
|
||||
it('stops on first error when stopOnError is true', async () => {
|
||||
const tracker = new assert.CallTracker()
|
||||
|
||||
const error = new Error()
|
||||
const iteratee = jest.fn((_, i) => {
|
||||
const iteratee = tracker.calls((_, i) => {
|
||||
if (i === 1) {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}, 2)
|
||||
assert.deepStrictEqual(
|
||||
await rejectionOf(asyncEach(iterable, iteratee, { concurrency: 1, stopOnError: true })),
|
||||
error
|
||||
)
|
||||
|
||||
expect(await rejectionOf(asyncEach(iterable, iteratee, { concurrency: 1, stopOnError: true }))).toBe(error)
|
||||
expect(iteratee).toHaveBeenCalledTimes(2)
|
||||
tracker.verify()
|
||||
})
|
||||
|
||||
it('rejects AggregateError when stopOnError is false', async () => {
|
||||
const errors = []
|
||||
const iteratee = jest.fn(() => {
|
||||
const iteratee = spy(() => {
|
||||
const error = new Error()
|
||||
errors.push(error)
|
||||
throw error
|
||||
})
|
||||
|
||||
const error = await rejectionOf(asyncEach(iterable, iteratee, { stopOnError: false }))
|
||||
expect(error.errors).toEqual(errors)
|
||||
expect(iteratee.mock.calls).toEqual(Array.from(values, (value, index) => [value, index, iterable]))
|
||||
assert.deepStrictEqual(error.errors, errors)
|
||||
assert.deepStrictEqual(
|
||||
iteratee.args,
|
||||
Array.from(values, (value, index) => [value, index, iterable])
|
||||
)
|
||||
})
|
||||
|
||||
it('can be interrupted with an AbortSignal', async () => {
|
||||
const tracker = new assert.CallTracker()
|
||||
|
||||
const ac = new AbortController()
|
||||
const iteratee = jest.fn((_, i) => {
|
||||
const iteratee = tracker.calls((_, i) => {
|
||||
if (i === 1) {
|
||||
ac.abort()
|
||||
}
|
||||
}, 2)
|
||||
await assert.rejects(asyncEach(iterable, iteratee, { concurrency: 1, signal: ac.signal }), {
|
||||
message: 'asyncEach aborted',
|
||||
})
|
||||
|
||||
await expect(asyncEach(iterable, iteratee, { concurrency: 1, signal: ac.signal })).rejects.toThrow(
|
||||
'asyncEach aborted'
|
||||
)
|
||||
expect(iteratee).toHaveBeenCalledTimes(2)
|
||||
tracker.verify()
|
||||
})
|
||||
})
|
||||
)
|
||||
@@ -29,6 +29,12 @@
|
||||
"node": ">=8.10"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^14.0.1",
|
||||
"tap": "^16.3.0",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
const { describe, it } = require('test')
|
||||
const assert = require('assert')
|
||||
|
||||
const { coalesceCalls } = require('./')
|
||||
|
||||
@@ -23,13 +24,13 @@ describe('coalesceCalls', () => {
|
||||
const promise2 = fn(defer2.promise)
|
||||
|
||||
defer1.resolve('foo')
|
||||
expect(await promise1).toBe('foo')
|
||||
expect(await promise2).toBe('foo')
|
||||
assert.strictEqual(await promise1, 'foo')
|
||||
assert.strictEqual(await promise2, 'foo')
|
||||
|
||||
const defer3 = pDefer()
|
||||
const promise3 = fn(defer3.promise)
|
||||
|
||||
defer3.resolve('bar')
|
||||
expect(await promise3).toBe('bar')
|
||||
assert.strictEqual(await promise3, 'bar')
|
||||
})
|
||||
})
|
||||
@@ -30,6 +30,10 @@
|
||||
"node": ">=8.10"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
const { describe, it } = require('test')
|
||||
const assert = require('node:assert').strict
|
||||
|
||||
const { compose } = require('./')
|
||||
|
||||
@@ -9,43 +10,42 @@ const mul3 = x => x * 3
|
||||
|
||||
describe('compose()', () => {
|
||||
it('throws when no functions is passed', () => {
|
||||
expect(() => compose()).toThrow(TypeError)
|
||||
expect(() => compose([])).toThrow(TypeError)
|
||||
assert.throws(() => compose(), TypeError)
|
||||
assert.throws(() => compose([]), TypeError)
|
||||
})
|
||||
|
||||
it('applies from left to right', () => {
|
||||
expect(compose(add2, mul3)(5)).toBe(21)
|
||||
assert.strictEqual(compose(add2, mul3)(5), 21)
|
||||
})
|
||||
|
||||
it('accepts functions in an array', () => {
|
||||
expect(compose([add2, mul3])(5)).toBe(21)
|
||||
assert.strictEqual(compose([add2, mul3])(5), 21)
|
||||
})
|
||||
|
||||
it('can apply from right to left', () => {
|
||||
expect(compose({ right: true }, add2, mul3)(5)).toBe(17)
|
||||
assert.strictEqual(compose({ right: true }, add2, mul3)(5), 17)
|
||||
})
|
||||
|
||||
it('accepts options with functions in an array', () => {
|
||||
expect(compose({ right: true }, [add2, mul3])(5)).toBe(17)
|
||||
assert.strictEqual(compose({ right: true }, [add2, mul3])(5), 17)
|
||||
})
|
||||
|
||||
it('can compose async functions', async () => {
|
||||
expect(
|
||||
assert.strictEqual(
|
||||
await compose(
|
||||
{ async: true },
|
||||
async x => x + 2,
|
||||
async x => x * 3
|
||||
)(5)
|
||||
).toBe(21)
|
||||
)(5),
|
||||
21
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards all args to first function', () => {
|
||||
expect.assertions(1)
|
||||
|
||||
const expectedArgs = [Math.random(), Math.random()]
|
||||
compose(
|
||||
(...args) => {
|
||||
expect(args).toEqual(expectedArgs)
|
||||
assert.deepEqual(args, expectedArgs)
|
||||
},
|
||||
// add a second function to avoid the one function special case
|
||||
Function.prototype
|
||||
@@ -53,15 +53,13 @@ describe('compose()', () => {
|
||||
})
|
||||
|
||||
it('forwards context to all functions', () => {
|
||||
expect.assertions(2)
|
||||
|
||||
const expectedThis = {}
|
||||
compose(
|
||||
function () {
|
||||
expect(this).toBe(expectedThis)
|
||||
assert.strictEqual(this, expectedThis)
|
||||
},
|
||||
function () {
|
||||
expect(this).toBe(expectedThis)
|
||||
assert.strictEqual(this, expectedThis)
|
||||
}
|
||||
).call(expectedThis)
|
||||
})
|
||||
@@ -19,6 +19,10 @@
|
||||
"node": ">=7.6"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
const { describe, it } = require('tap').mocha
|
||||
const { describe, it } = require('test')
|
||||
|
||||
const { decorateClass, decorateWith, decorateMethodsWith, perInstance } = require('./')
|
||||
|
||||
@@ -26,9 +26,9 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "tap"
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tap": "^16.0.1"
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
const { describe, it } = require('test')
|
||||
const { useFakeTimers, spy, assert } = require('sinon')
|
||||
|
||||
const { createDebounceResource } = require('./debounceResource')
|
||||
|
||||
jest.useFakeTimers()
|
||||
const clock = useFakeTimers()
|
||||
|
||||
describe('debounceResource()', () => {
|
||||
it('calls the resource disposer after 10 seconds', async () => {
|
||||
const debounceResource = createDebounceResource()
|
||||
const delay = 10e3
|
||||
const dispose = jest.fn()
|
||||
const dispose = spy()
|
||||
|
||||
const resource = await debounceResource(
|
||||
Promise.resolve({
|
||||
@@ -22,10 +23,10 @@ describe('debounceResource()', () => {
|
||||
|
||||
resource.dispose()
|
||||
|
||||
expect(dispose).not.toBeCalled()
|
||||
assert.notCalled(dispose)
|
||||
|
||||
jest.advanceTimersByTime(delay)
|
||||
clock.tick(delay)
|
||||
|
||||
expect(dispose).toBeCalled()
|
||||
assert.called(dispose)
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,14 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
const { describe, it } = require('test')
|
||||
const { spy, assert } = require('sinon')
|
||||
|
||||
const { deduped } = require('./deduped')
|
||||
|
||||
describe('deduped()', () => {
|
||||
it('calls the resource function only once', async () => {
|
||||
const value = {}
|
||||
const getResource = jest.fn(async () => ({
|
||||
const getResource = spy(async () => ({
|
||||
value,
|
||||
dispose: Function.prototype,
|
||||
}))
|
||||
@@ -17,13 +18,13 @@ describe('deduped()', () => {
|
||||
const { value: v1 } = await dedupedGetResource()
|
||||
const { value: v2 } = await dedupedGetResource()
|
||||
|
||||
expect(getResource).toHaveBeenCalledTimes(1)
|
||||
expect(v1).toBe(value)
|
||||
expect(v2).toBe(value)
|
||||
assert.calledOnce(getResource)
|
||||
assert.match(v1, value)
|
||||
assert.match(v2, value)
|
||||
})
|
||||
|
||||
it('only disposes the source disposable when its all copies dispose', async () => {
|
||||
const dispose = jest.fn()
|
||||
const dispose = spy()
|
||||
const getResource = async () => ({
|
||||
value: '',
|
||||
dispose,
|
||||
@@ -36,35 +37,35 @@ describe('deduped()', () => {
|
||||
|
||||
d1()
|
||||
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
assert.notCalled(dispose)
|
||||
|
||||
d2()
|
||||
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
assert.calledOnce(dispose)
|
||||
})
|
||||
|
||||
it('works with sync factory', () => {
|
||||
const value = {}
|
||||
const dispose = jest.fn()
|
||||
const dispose = spy()
|
||||
const dedupedGetResource = deduped(() => ({ value, dispose }))
|
||||
|
||||
const d1 = dedupedGetResource()
|
||||
expect(d1.value).toBe(value)
|
||||
assert.match(d1.value, value)
|
||||
|
||||
const d2 = dedupedGetResource()
|
||||
expect(d2.value).toBe(value)
|
||||
assert.match(d2.value, value)
|
||||
|
||||
d1.dispose()
|
||||
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
assert.notCalled(dispose)
|
||||
|
||||
d2.dispose()
|
||||
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
assert.calledOnce(dispose)
|
||||
})
|
||||
|
||||
it('no race condition on dispose before async acquisition', async () => {
|
||||
const dispose = jest.fn()
|
||||
const dispose = spy()
|
||||
const dedupedGetResource = deduped(async () => ({ value: 42, dispose }))
|
||||
|
||||
const d1 = await dedupedGetResource()
|
||||
@@ -73,6 +74,6 @@ describe('deduped()', () => {
|
||||
|
||||
d1.dispose()
|
||||
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
assert.notCalled(dispose)
|
||||
})
|
||||
})
|
||||
@@ -14,17 +14,22 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/multi-key-map": "^0.1.0",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"ensure-array": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^14.0.1",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,6 @@ const LRU = require('lru-cache')
|
||||
const Fuse = require('fuse-native')
|
||||
const { VhdSynthetic } = require('vhd-lib')
|
||||
const { Disposable, fromCallback } = require('promise-toolbox')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
|
||||
const { warn } = createLogger('vates:fuse-vhd')
|
||||
|
||||
// build a s stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
|
||||
const stat = st => ({
|
||||
@@ -57,9 +54,7 @@ exports.mount = Disposable.factory(async function* mount(handler, diskPath, moun
|
||||
},
|
||||
read(path, fd, buf, len, pos, cb) {
|
||||
if (path === '/vhd0') {
|
||||
return vhd
|
||||
.readRawData(pos, len, cache, buf)
|
||||
.then(cb)
|
||||
return vhd.readRawData(pos, len, cache, buf).then(cb)
|
||||
}
|
||||
throw new Error(`read file ${path} not exists`)
|
||||
},
|
||||
@@ -67,5 +62,5 @@ exports.mount = Disposable.factory(async function* mount(handler, diskPath, moun
|
||||
return new Disposable(
|
||||
() => fromCallback(() => fuse.unmount()),
|
||||
fromCallback(() => fuse.mount())
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vates/fuse-vhd",
|
||||
"version": "0.0.1",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"private": false,
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/fuse-vhd",
|
||||
@@ -18,11 +18,10 @@
|
||||
"node": ">=10.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"fuse-native": "^2.2.6",
|
||||
"lru-cache": "^7.14.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"vhd-lib": "^4.0.1"
|
||||
"vhd-lib": "^4.1.1"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
const { describe, it } = require('test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { MultiKeyMap } = require('./')
|
||||
|
||||
@@ -28,9 +29,9 @@ describe('MultiKeyMap', () => {
|
||||
|
||||
keys.forEach((key, i) => {
|
||||
// copy the key to make sure the array itself is not the key
|
||||
expect(map.get(key.slice())).toBe(values[i])
|
||||
assert.strictEqual(map.get(key.slice()), values[i])
|
||||
map.delete(key.slice())
|
||||
expect(map.get(key.slice())).toBe(undefined)
|
||||
assert.strictEqual(map.get(key.slice()), undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,10 @@
|
||||
"node": ">=8.10"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
16
@vates/nbd-client/.USAGE.md
Normal file
16
@vates/nbd-client/.USAGE.md
Normal file
@@ -0,0 +1,16 @@
|
||||
### `new NdbClient({address, exportname, secure = true, port = 10809})`
|
||||
|
||||
create a new nbd client
|
||||
|
||||
```js
|
||||
import NbdClient from '@vates/nbd-client'
|
||||
const client = new NbdClient({
|
||||
address: 'MY_NBD_HOST',
|
||||
exportname: 'MY_SECRET_EXPORT',
|
||||
cert: 'Server certificate', // optional, will use encrypted link if provided
|
||||
})
|
||||
|
||||
await client.connect()
|
||||
const block = await client.readBlock(blockIndex, BlockSize)
|
||||
await client.disconnect()
|
||||
```
|
||||
1
@vates/nbd-client/.npmignore
Symbolic link
1
@vates/nbd-client/.npmignore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/npmignore
|
||||
47
@vates/nbd-client/README.md
Normal file
47
@vates/nbd-client/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
|
||||
|
||||
# @vates/nbd-client
|
||||
|
||||
[](https://npmjs.org/package/@vates/nbd-client)  [](https://bundlephobia.com/result?p=@vates/nbd-client) [](https://npmjs.org/package/@vates/nbd-client)
|
||||
|
||||
## Install
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/nbd-client):
|
||||
|
||||
```
|
||||
> npm install --save @vates/nbd-client
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### `new NdbClient({address, exportname, secure = true, port = 10809})`
|
||||
|
||||
create a new nbd client
|
||||
|
||||
```js
|
||||
import NbdClient from '@vates/nbd-client'
|
||||
const client = new NbdClient({
|
||||
address: 'MY_NBD_HOST',
|
||||
exportname: 'MY_SECRET_EXPORT',
|
||||
cert: 'Server certificate', // optional, will use encrypted link if provided
|
||||
})
|
||||
|
||||
await client.connect()
|
||||
const block = await client.readBlock(blockIndex, BlockSize)
|
||||
await client.disconnect()
|
||||
```
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are _very_ welcomed, either on the documentation or on
|
||||
the code.
|
||||
|
||||
You may:
|
||||
|
||||
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
|
||||
you've encountered;
|
||||
- fork and create a pull request.
|
||||
|
||||
## License
|
||||
|
||||
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)
|
||||
42
@vates/nbd-client/constants.js
Normal file
42
@vates/nbd-client/constants.js
Normal file
@@ -0,0 +1,42 @@
|
||||
'use strict'
|
||||
exports.INIT_PASSWD = Buffer.from('NBDMAGIC') // "NBDMAGIC" ensure we're connected to a nbd server
|
||||
exports.OPTS_MAGIC = Buffer.from('IHAVEOPT') // "IHAVEOPT" start an option block
|
||||
exports.NBD_OPT_REPLY_MAGIC = 1100100111001001n // magic received during negociation
|
||||
exports.NBD_OPT_EXPORT_NAME = 1
|
||||
exports.NBD_OPT_ABORT = 2
|
||||
exports.NBD_OPT_LIST = 3
|
||||
exports.NBD_OPT_STARTTLS = 5
|
||||
exports.NBD_OPT_INFO = 6
|
||||
exports.NBD_OPT_GO = 7
|
||||
|
||||
exports.NBD_FLAG_HAS_FLAGS = 1 << 0
|
||||
exports.NBD_FLAG_READ_ONLY = 1 << 1
|
||||
exports.NBD_FLAG_SEND_FLUSH = 1 << 2
|
||||
exports.NBD_FLAG_SEND_FUA = 1 << 3
|
||||
exports.NBD_FLAG_ROTATIONAL = 1 << 4
|
||||
exports.NBD_FLAG_SEND_TRIM = 1 << 5
|
||||
|
||||
exports.NBD_FLAG_FIXED_NEWSTYLE = 1 << 0
|
||||
|
||||
exports.NBD_CMD_FLAG_FUA = 1 << 0
|
||||
exports.NBD_CMD_FLAG_NO_HOLE = 1 << 1
|
||||
exports.NBD_CMD_FLAG_DF = 1 << 2
|
||||
exports.NBD_CMD_FLAG_REQ_ONE = 1 << 3
|
||||
exports.NBD_CMD_FLAG_FAST_ZERO = 1 << 4
|
||||
|
||||
exports.NBD_CMD_READ = 0
|
||||
exports.NBD_CMD_WRITE = 1
|
||||
exports.NBD_CMD_DISC = 2
|
||||
exports.NBD_CMD_FLUSH = 3
|
||||
exports.NBD_CMD_TRIM = 4
|
||||
exports.NBD_CMD_CACHE = 5
|
||||
exports.NBD_CMD_WRITE_ZEROES = 6
|
||||
exports.NBD_CMD_BLOCK_STATUS = 7
|
||||
exports.NBD_CMD_RESIZE = 8
|
||||
|
||||
exports.NBD_REQUEST_MAGIC = 0x25609513 // magic number to create a new NBD request to send to the server
|
||||
exports.NBD_REPLY_MAGIC = 0x67446698 // magic number received from the server when reading response to a nbd request
|
||||
exports.NBD_REPLY_ACK = 1
|
||||
|
||||
exports.NBD_DEFAULT_PORT = 10809
|
||||
exports.NBD_DEFAULT_BLOCK_SIZE = 64 * 1024
|
||||
243
@vates/nbd-client/index.js
Normal file
243
@vates/nbd-client/index.js
Normal file
@@ -0,0 +1,243 @@
|
||||
'use strict'
|
||||
const assert = require('node:assert')
|
||||
const { Socket } = require('node:net')
|
||||
const { connect } = require('node:tls')
|
||||
const {
|
||||
INIT_PASSWD,
|
||||
NBD_CMD_READ,
|
||||
NBD_DEFAULT_BLOCK_SIZE,
|
||||
NBD_DEFAULT_PORT,
|
||||
NBD_FLAG_FIXED_NEWSTYLE,
|
||||
NBD_FLAG_HAS_FLAGS,
|
||||
NBD_OPT_EXPORT_NAME,
|
||||
NBD_OPT_REPLY_MAGIC,
|
||||
NBD_OPT_STARTTLS,
|
||||
NBD_REPLY_ACK,
|
||||
NBD_REPLY_MAGIC,
|
||||
NBD_REQUEST_MAGIC,
|
||||
OPTS_MAGIC,
|
||||
} = require('./constants.js')
|
||||
const { fromCallback } = require('promise-toolbox')
|
||||
const { readChunkStrict } = require('@vates/read-chunk')
|
||||
|
||||
// documentation is here : https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
|
||||
|
||||
module.exports = class NbdClient {
|
||||
#serverAddress
|
||||
#serverCert
|
||||
#serverPort
|
||||
#serverSocket
|
||||
|
||||
#exportName
|
||||
#exportSize
|
||||
|
||||
// AFAIK, there is no guaranty the server answers in the same order as the queries
|
||||
// so we handle a backlog of command waiting for response and handle concurrency manually
|
||||
|
||||
#waitingForResponse // there is already a listenner waiting for a response
|
||||
#nextCommandQueryId = BigInt(0)
|
||||
#commandQueryBacklog // map of command waiting for an response queryId => { size/*in byte*/, resolve, reject}
|
||||
|
||||
constructor({ address, port = NBD_DEFAULT_PORT, exportname, cert }) {
|
||||
this.#serverAddress = address
|
||||
this.#serverPort = port
|
||||
this.#exportName = exportname
|
||||
this.#serverCert = cert
|
||||
}
|
||||
|
||||
get exportSize() {
|
||||
return this.#exportSize
|
||||
}
|
||||
|
||||
async #tlsConnect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#serverSocket = connect({
|
||||
socket: this.#serverSocket,
|
||||
rejectUnauthorized: false,
|
||||
cert: this.#serverCert,
|
||||
})
|
||||
this.#serverSocket.once('error', reject)
|
||||
this.#serverSocket.once('secureConnect', () => {
|
||||
this.#serverSocket.removeListener('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// mandatory , at least to start the handshake
|
||||
async #unsecureConnect() {
|
||||
this.#serverSocket = new Socket()
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#serverSocket.connect(this.#serverPort, this.#serverAddress)
|
||||
this.#serverSocket.once('error', reject)
|
||||
this.#serverSocket.once('connect', () => {
|
||||
this.#serverSocket.removeListener('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async connect() {
|
||||
// first we connect to the serve without tls, and then we upgrade the connection
|
||||
// to tls during the handshake
|
||||
await this.#unsecureConnect()
|
||||
await this.#handshake()
|
||||
|
||||
// reset internal state if we reconnected a nbd client
|
||||
this.#commandQueryBacklog = new Map()
|
||||
this.#waitingForResponse = false
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
await this.#serverSocket.destroy()
|
||||
}
|
||||
|
||||
// we can use individual read/write from the socket here since there is no concurrency
|
||||
async #sendOption(option, buffer = Buffer.alloc(0)) {
|
||||
await this.#write(OPTS_MAGIC)
|
||||
await this.#writeInt32(option)
|
||||
await this.#writeInt32(buffer.length)
|
||||
await this.#write(buffer)
|
||||
assert.strictEqual(await this.#readInt64(), NBD_OPT_REPLY_MAGIC) // magic number everywhere
|
||||
assert.strictEqual(await this.#readInt32(), option) // the option passed
|
||||
assert.strictEqual(await this.#readInt32(), NBD_REPLY_ACK) // ACK
|
||||
const length = await this.#readInt32()
|
||||
assert.strictEqual(length, 0) // length
|
||||
}
|
||||
|
||||
// we can use individual read/write from the socket here since there is only one handshake at once, no concurrency
|
||||
async #handshake() {
|
||||
assert((await this.#read(8)).equals(INIT_PASSWD))
|
||||
assert((await this.#read(8)).equals(OPTS_MAGIC))
|
||||
const flagsBuffer = await this.#read(2)
|
||||
const flags = flagsBuffer.readInt16BE(0)
|
||||
assert.strictEqual(flags & NBD_FLAG_FIXED_NEWSTYLE, NBD_FLAG_FIXED_NEWSTYLE) // only FIXED_NEWSTYLE one is supported from the server options
|
||||
await this.#writeInt32(NBD_FLAG_FIXED_NEWSTYLE) // client also support NBD_FLAG_C_FIXED_NEWSTYLE
|
||||
|
||||
if (this.#serverCert !== undefined) {
|
||||
// upgrade socket to TLS if needed
|
||||
await this.#sendOption(NBD_OPT_STARTTLS)
|
||||
await this.#tlsConnect()
|
||||
}
|
||||
|
||||
// send export name we want to access.
|
||||
// it's implictly closing the negociation phase.
|
||||
await this.#write(OPTS_MAGIC)
|
||||
await this.#writeInt32(NBD_OPT_EXPORT_NAME)
|
||||
const exportNameBuffer = Buffer.from(this.#exportName)
|
||||
await this.#writeInt32(exportNameBuffer.length)
|
||||
await this.#write(exportNameBuffer)
|
||||
|
||||
// 8 (export size ) + 2 (flags) + 124 zero = 134
|
||||
// must read all to ensure nothing stays in the buffer
|
||||
const answer = await this.#read(134)
|
||||
this.#exportSize = answer.readBigUInt64BE(0)
|
||||
const transmissionFlags = answer.readInt16BE(8)
|
||||
assert.strictEqual(transmissionFlags & NBD_FLAG_HAS_FLAGS, NBD_FLAG_HAS_FLAGS, 'NBD_FLAG_HAS_FLAGS') // must always be 1 by the norm
|
||||
|
||||
// note : xapi server always send NBD_FLAG_READ_ONLY (3) as a flag
|
||||
}
|
||||
|
||||
#read(length) {
|
||||
return readChunkStrict(this.#serverSocket, length)
|
||||
}
|
||||
|
||||
#write(buffer) {
|
||||
return fromCallback.call(this.#serverSocket, 'write', buffer)
|
||||
}
|
||||
|
||||
async #readInt32() {
|
||||
const buffer = await this.#read(4)
|
||||
return buffer.readInt32BE(0)
|
||||
}
|
||||
|
||||
async #readInt64() {
|
||||
const buffer = await this.#read(8)
|
||||
return buffer.readBigUInt64BE(0)
|
||||
}
|
||||
|
||||
#writeInt32(int) {
|
||||
const buffer = Buffer.alloc(4)
|
||||
buffer.writeInt32BE(int)
|
||||
return this.#write(buffer)
|
||||
}
|
||||
|
||||
// when one read fail ,stop everything
|
||||
async #rejectAll(error) {
|
||||
this.#commandQueryBacklog.forEach(({ reject }) => {
|
||||
reject(error)
|
||||
})
|
||||
await this.disconnect()
|
||||
}
|
||||
|
||||
async #readBlockResponse() {
|
||||
// ensure at most one read occur in parallel
|
||||
if (this.#waitingForResponse) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.#waitingForResponse = true
|
||||
const magic = await this.#readInt32()
|
||||
|
||||
if (magic !== NBD_REPLY_MAGIC) {
|
||||
throw new Error(`magic number for block answer is wrong : ${magic} ${NBD_REPLY_MAGIC}`)
|
||||
}
|
||||
|
||||
const error = await this.#readInt32()
|
||||
if (error !== 0) {
|
||||
// @todo use error code from constants.mjs
|
||||
throw new Error(`GOT ERROR CODE : ${error}`)
|
||||
}
|
||||
|
||||
const blockQueryId = await this.#readInt64()
|
||||
const query = this.#commandQueryBacklog.get(blockQueryId)
|
||||
if (!query) {
|
||||
throw new Error(` no query associated with id ${blockQueryId}`)
|
||||
}
|
||||
this.#commandQueryBacklog.delete(blockQueryId)
|
||||
const data = await this.#read(query.size)
|
||||
query.resolve(data)
|
||||
this.#waitingForResponse = false
|
||||
if (this.#commandQueryBacklog.size > 0) {
|
||||
await this.#readBlockResponse()
|
||||
}
|
||||
} catch (error) {
|
||||
// reject all the promises
|
||||
// we don't need to call readBlockResponse on failure
|
||||
// since we will empty the backlog
|
||||
await this.#rejectAll(error)
|
||||
}
|
||||
}
|
||||
|
||||
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE) {
|
||||
const queryId = this.#nextCommandQueryId
|
||||
this.#nextCommandQueryId++
|
||||
|
||||
// create and send command at once to ensure there is no concurrency issue
|
||||
const buffer = Buffer.alloc(28)
|
||||
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
|
||||
buffer.writeInt16BE(0, 4) // no command flags for a simple block read
|
||||
buffer.writeInt16BE(NBD_CMD_READ, 6) // we want to read a data block
|
||||
buffer.writeBigUInt64BE(queryId, 8)
|
||||
// byte offset in the raw disk
|
||||
buffer.writeBigUInt64BE(BigInt(index) * BigInt(size), 16)
|
||||
buffer.writeInt32BE(size, 24)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// this will handle one block response, but it can be another block
|
||||
// since server does not guaranty to handle query in order
|
||||
this.#commandQueryBacklog.set(queryId, {
|
||||
size,
|
||||
resolve,
|
||||
reject,
|
||||
})
|
||||
// really send the command to the server
|
||||
this.#write(buffer).catch(reject)
|
||||
|
||||
// #readBlockResponse never throws directly
|
||||
// but if it fails it will reject all the promises in the backlog
|
||||
this.#readBlockResponse()
|
||||
})
|
||||
}
|
||||
}
|
||||
76
@vates/nbd-client/nbdclient.spec.js
Normal file
76
@vates/nbd-client/nbdclient.spec.js
Normal file
@@ -0,0 +1,76 @@
|
||||
'use strict'
|
||||
const NbdClient = require('./index.js')
|
||||
const { spawn } = require('node:child_process')
|
||||
const fs = require('node:fs/promises')
|
||||
const { test } = require('tap')
|
||||
const tmp = require('tmp')
|
||||
const { pFromCallback } = require('promise-toolbox')
|
||||
const { asyncEach } = require('@vates/async-each')
|
||||
|
||||
const FILE_SIZE = 2 * 1024 * 1024
|
||||
|
||||
async function createTempFile(size) {
|
||||
const tmpPath = await pFromCallback(cb => tmp.file(cb))
|
||||
const data = Buffer.alloc(size, 0)
|
||||
for (let i = 0; i < size; i += 4) {
|
||||
data.writeUInt32BE(i, i)
|
||||
}
|
||||
await fs.writeFile(tmpPath, data)
|
||||
|
||||
return tmpPath
|
||||
}
|
||||
|
||||
test('it works with unsecured network', async tap => {
|
||||
const path = await createTempFile(FILE_SIZE)
|
||||
|
||||
const nbdServer = spawn(
|
||||
'nbdkit',
|
||||
[
|
||||
'file',
|
||||
path,
|
||||
'--newstyle', //
|
||||
'--exit-with-parent',
|
||||
'--read-only',
|
||||
'--export-name=MY_SECRET_EXPORT',
|
||||
],
|
||||
{
|
||||
stdio: ['inherit', 'inherit', 'inherit'],
|
||||
}
|
||||
)
|
||||
|
||||
const client = new NbdClient({
|
||||
address: 'localhost',
|
||||
exportname: 'MY_SECRET_EXPORT',
|
||||
secure: false,
|
||||
})
|
||||
|
||||
await client.connect()
|
||||
tap.equal(client.exportSize, BigInt(FILE_SIZE))
|
||||
const CHUNK_SIZE = 128 * 1024 // non default size
|
||||
const indexes = []
|
||||
for (let i = 0; i < FILE_SIZE / CHUNK_SIZE; i++) {
|
||||
indexes.push(i)
|
||||
}
|
||||
// read mutiple blocks in parallel
|
||||
await asyncEach(
|
||||
indexes,
|
||||
async i => {
|
||||
const block = await client.readBlock(i, CHUNK_SIZE)
|
||||
let blockOk = true
|
||||
let firstFail
|
||||
for (let j = 0; j < CHUNK_SIZE; j += 4) {
|
||||
const wanted = i * CHUNK_SIZE + j
|
||||
const found = block.readUInt32BE(j)
|
||||
blockOk = blockOk && found === wanted
|
||||
if (!blockOk && firstFail === undefined) {
|
||||
firstFail = j
|
||||
}
|
||||
}
|
||||
tap.ok(blockOk, `check block ${i} content`)
|
||||
},
|
||||
{ concurrency: 8 }
|
||||
)
|
||||
await client.disconnect()
|
||||
nbdServer.kill()
|
||||
await fs.unlink(path)
|
||||
})
|
||||
35
@vates/nbd-client/package.json
Normal file
35
@vates/nbd-client/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@vates/nbd-client",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/nbd-client",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@vates/nbd-client",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "1.0.0",
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.0",
|
||||
"@vates/read-chunk": "^1.0.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"xen-api": "^1.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tap": "^16.3.0",
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test-integration": "tap *.spec.js"
|
||||
}
|
||||
}
|
||||
130
@vates/otp/.USAGE.md
Normal file
130
@vates/otp/.USAGE.md
Normal file
@@ -0,0 +1,130 @@
|
||||
### Usual workflow
|
||||
|
||||
> This section presents how this library should be used to implement a classic two factor authentification.
|
||||
|
||||
#### Setup
|
||||
|
||||
```js
|
||||
import { generateSecret, generateTotp } from '@vates/otp'
|
||||
import QrCode from 'qrcode'
|
||||
|
||||
// Generates a secret that will be shared by both the service and the user:
|
||||
const secret = generateSecret()
|
||||
|
||||
// Stores the secret in the service:
|
||||
await currentUser.saveOtpSecret(secret)
|
||||
|
||||
// Generates an URI to present to the user
|
||||
const uri = generateTotpUri({ secret })
|
||||
|
||||
// Generates the QR code from the URI to make it easily importable in Authy or Google Authenticator
|
||||
const qr = await QrCode.toDataURL(uri)
|
||||
```
|
||||
|
||||
#### Authentication
|
||||
|
||||
```js
|
||||
import { verifyTotp } from '@vates/otp'
|
||||
|
||||
// Verifies a `token` entered by the user against a `secret` generated during setup.
|
||||
if (await verifyTotp(token, { secret })) {
|
||||
console.log('authenticated!')
|
||||
}
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
#### Secret
|
||||
|
||||
```js
|
||||
import { generateSecret } from '@vates/otp'
|
||||
|
||||
const secret = generateSecret()
|
||||
// 'OJOKA65RY5FQQ2RYWVKD5Y3YG5CSHGYH'
|
||||
```
|
||||
|
||||
#### HOTP
|
||||
|
||||
> This is likely not what you want to use, see TOTP below instead.
|
||||
|
||||
```js
|
||||
import { generateHotp, generateHotpUri, verifyHotp } from '@vates/otp'
|
||||
|
||||
// a sequence number, see HOTP specification
|
||||
const counter = 0
|
||||
|
||||
// generate a token
|
||||
//
|
||||
// optional params:
|
||||
// - digits
|
||||
const token = await generateHotp({ counter, secret })
|
||||
// '239988'
|
||||
|
||||
// verify a token
|
||||
//
|
||||
// optional params:
|
||||
// - digits
|
||||
const isValid = await verifyHotp(token, { counter, secret })
|
||||
// true
|
||||
|
||||
// generate a URI than can be displayed as a QR code to be used with Authy or Google Authenticator
|
||||
//
|
||||
// optional params:
|
||||
// - digits
|
||||
const uri = generateHotpUri({ counter, label: 'account name', issuer: 'my app', secret })
|
||||
// 'otpauth://hotp/my%20app:account%20name?counter=0&issuer=my%20app&secret=OJOKA65RY5FQQ2RYWVKD5Y3YG5CSHGYH'
|
||||
```
|
||||
|
||||
Optional params and their default values:
|
||||
|
||||
- `digits = 6`: length of the token, avoid using it because not compatible with Google Authenticator
|
||||
|
||||
#### TOTP
|
||||
|
||||
```js
|
||||
import { generateTotp, generateTotpUri, verifyTotp } from '@vates/otp'
|
||||
|
||||
// generate a token
|
||||
//
|
||||
// optional params:
|
||||
// - digits
|
||||
// - period
|
||||
// - timestamp
|
||||
const token = await generateTotp({ secret })
|
||||
// '632869'
|
||||
|
||||
// verify a token
|
||||
//
|
||||
// optional params:
|
||||
// - digits
|
||||
// - period
|
||||
// - timestamp
|
||||
// - window
|
||||
const isValid = await verifyTotp(token, { secret })
|
||||
// true
|
||||
|
||||
// generate a URI than can be displayed as a QR code to be used with Authy or Google Authenticator
|
||||
//
|
||||
// optional params:
|
||||
// - digits
|
||||
// - period
|
||||
const uri = generateTotpUri({ label: 'account name', issuer: 'my app', secret })
|
||||
// 'otpauth://totp/my%20app:account%20name?issuer=my%20app&secret=OJOKA65RY5FQQ2RYWVKD5Y3YG5CSHGYH'
|
||||
```
|
||||
|
||||
Optional params and their default values:
|
||||
|
||||
- `digits = 6`: length of the token, avoid using it because not compatible with Google Authenticator
|
||||
- `period = 30`: number of seconds a token is valid
|
||||
- `timestamp = Date.now() / 1e3`: Unix timestamp, in seconds, when this token will be valid, default to now
|
||||
- `window = 1`: number of periods before and after `timestamp` for which the token is considered valid
|
||||
|
||||
#### Verification from URI
|
||||
|
||||
```js
|
||||
import { verifyFromUri } from '@vates/otp'
|
||||
|
||||
// Verify the token using all the information contained in the URI
|
||||
const isValid = await verifyFromUri(token, uri)
|
||||
// true
|
||||
```
|
||||
1
@vates/otp/.npmignore
Symbolic link
1
@vates/otp/.npmignore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/npmignore
|
||||
163
@vates/otp/README.md
Normal file
163
@vates/otp/README.md
Normal file
@@ -0,0 +1,163 @@
|
||||
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
|
||||
|
||||
# @vates/otp
|
||||
|
||||
[](https://npmjs.org/package/@vates/otp)  [](https://bundlephobia.com/result?p=@vates/otp) [](https://npmjs.org/package/@vates/otp)
|
||||
|
||||
> Minimal HTOP/TOTP implementation
|
||||
|
||||
## Install
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/otp):
|
||||
|
||||
```
|
||||
> npm install --save @vates/otp
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Usual workflow
|
||||
|
||||
> This section presents how this library should be used to implement a classic two factor authentification.
|
||||
|
||||
#### Setup
|
||||
|
||||
```js
|
||||
import { generateSecret, generateTotp } from '@vates/otp'
|
||||
import QrCode from 'qrcode'
|
||||
|
||||
// Generates a secret that will be shared by both the service and the user:
|
||||
const secret = generateSecret()
|
||||
|
||||
// Stores the secret in the service:
|
||||
await currentUser.saveOtpSecret(secret)
|
||||
|
||||
// Generates an URI to present to the user
|
||||
const uri = generateTotpUri({ secret })
|
||||
|
||||
// Generates the QR code from the URI to make it easily importable in Authy or Google Authenticator
|
||||
const qr = await QrCode.toDataURL(uri)
|
||||
```
|
||||
|
||||
#### Authentication
|
||||
|
||||
```js
|
||||
import { verifyTotp } from '@vates/otp'
|
||||
|
||||
// Verifies a `token` entered by the user against a `secret` generated during setup.
|
||||
if (await verifyTotp(token, { secret })) {
|
||||
console.log('authenticated!')
|
||||
}
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
#### Secret
|
||||
|
||||
```js
|
||||
import { generateSecret } from '@vates/otp'
|
||||
|
||||
const secret = generateSecret()
|
||||
// 'OJOKA65RY5FQQ2RYWVKD5Y3YG5CSHGYH'
|
||||
```
|
||||
|
||||
#### HOTP
|
||||
|
||||
> This is likely not what you want to use, see TOTP below instead.
|
||||
|
||||
```js
|
||||
import { generateHotp, generateHotpUri, verifyHotp } from '@vates/otp'
|
||||
|
||||
// a sequence number, see HOTP specification
|
||||
const counter = 0
|
||||
|
||||
// generate a token
|
||||
//
|
||||
// optional params:
|
||||
// - digits
|
||||
const token = await generateHotp({ counter, secret })
|
||||
// '239988'
|
||||
|
||||
// verify a token
|
||||
//
|
||||
// optional params:
|
||||
// - digits
|
||||
const isValid = await verifyHotp(token, { counter, secret })
|
||||
// true
|
||||
|
||||
// generate a URI than can be displayed as a QR code to be used with Authy or Google Authenticator
|
||||
//
|
||||
// optional params:
|
||||
// - digits
|
||||
const uri = generateHotpUri({ counter, label: 'account name', issuer: 'my app', secret })
|
||||
// 'otpauth://hotp/my%20app:account%20name?counter=0&issuer=my%20app&secret=OJOKA65RY5FQQ2RYWVKD5Y3YG5CSHGYH'
|
||||
```
|
||||
|
||||
Optional params and their default values:
|
||||
|
||||
- `digits = 6`: length of the token, avoid using it because not compatible with Google Authenticator
|
||||
|
||||
#### TOTP
|
||||
|
||||
```js
|
||||
import { generateTotp, generateTotpUri, verifyTotp } from '@vates/otp'
|
||||
|
||||
// generate a token
|
||||
//
|
||||
// optional params:
|
||||
// - digits
|
||||
// - period
|
||||
// - timestamp
|
||||
const token = await generateTotp({ secret })
|
||||
// '632869'
|
||||
|
||||
// verify a token
|
||||
//
|
||||
// optional params:
|
||||
// - digits
|
||||
// - period
|
||||
// - timestamp
|
||||
// - window
|
||||
const isValid = await verifyTotp(token, { secret })
|
||||
// true
|
||||
|
||||
// generate a URI than can be displayed as a QR code to be used with Authy or Google Authenticator
|
||||
//
|
||||
// optional params:
|
||||
// - digits
|
||||
// - period
|
||||
const uri = generateTotpUri({ label: 'account name', issuer: 'my app', secret })
|
||||
// 'otpauth://totp/my%20app:account%20name?issuer=my%20app&secret=OJOKA65RY5FQQ2RYWVKD5Y3YG5CSHGYH'
|
||||
```
|
||||
|
||||
Optional params and their default values:
|
||||
|
||||
- `digits = 6`: length of the token, avoid using it because not compatible with Google Authenticator
|
||||
- `period = 30`: number of seconds a token is valid
|
||||
- `timestamp = Date.now() / 1e3`: Unix timestamp, in seconds, when this token will be valid, default to now
|
||||
- `window = 1`: number of periods before and after `timestamp` for which the token is considered valid
|
||||
|
||||
#### Verification from URI
|
||||
|
||||
```js
|
||||
import { verifyFromUri } from '@vates/otp'
|
||||
|
||||
// Verify the token using all the information contained in the URI
|
||||
const isValid = await verifyFromUri(token, uri)
|
||||
// true
|
||||
```
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are _very_ welcomed, either on the documentation or on
|
||||
the code.
|
||||
|
||||
You may:
|
||||
|
||||
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
|
||||
you've encountered;
|
||||
- fork and create a pull request.
|
||||
|
||||
## License
|
||||
|
||||
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)
|
||||
111
@vates/otp/index.mjs
Normal file
111
@vates/otp/index.mjs
Normal file
@@ -0,0 +1,111 @@
|
||||
import { base32 } from 'rfc4648'
|
||||
import { webcrypto } from 'node:crypto'
|
||||
|
||||
const { subtle } = webcrypto
|
||||
|
||||
function assert(name, value) {
|
||||
if (!value) {
|
||||
throw new TypeError('invalid value for param ' + name)
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
||||
function generateUri(protocol, label, params) {
|
||||
assert('label', typeof label === 'string')
|
||||
assert('secret', typeof params.secret === 'string')
|
||||
|
||||
let path = encodeURIComponent(label)
|
||||
|
||||
const { issuer } = params
|
||||
if (issuer !== undefined) {
|
||||
path = encodeURIComponent(issuer) + ':' + path
|
||||
}
|
||||
|
||||
const query = Object.entries(params)
|
||||
.filter(_ => _[1] !== undefined)
|
||||
.map(([key, value]) => key + '=' + encodeURIComponent(value))
|
||||
.join('&')
|
||||
|
||||
return `otpauth://${protocol}/${path}?${query}`
|
||||
}
|
||||
|
||||
export function generateSecret() {
|
||||
// https://www.rfc-editor.org/rfc/rfc4226 recommends 160 bits (i.e. 20 bytes)
|
||||
const data = new Uint8Array(20)
|
||||
webcrypto.getRandomValues(data)
|
||||
return base32.stringify(data, { pad: false })
|
||||
}
|
||||
|
||||
const DIGITS = 6
|
||||
|
||||
// https://www.rfc-editor.org/rfc/rfc4226
|
||||
export async function generateHotp({ counter, digits = DIGITS, secret }) {
|
||||
const data = new Uint8Array(8)
|
||||
new DataView(data.buffer).setBigInt64(0, BigInt(counter), false)
|
||||
|
||||
const key = await subtle.importKey(
|
||||
'raw',
|
||||
base32.parse(secret, { loose: true }),
|
||||
{ name: 'HMAC', hash: 'SHA-1' },
|
||||
false,
|
||||
['sign', 'verify']
|
||||
)
|
||||
const digest = new DataView(await subtle.sign('HMAC', key, data))
|
||||
|
||||
const offset = digest.getUint8(digest.byteLength - 1) & 0xf
|
||||
const p = digest.getUint32(offset) & 0x7f_ff_ff_ff
|
||||
|
||||
return String(p % Math.pow(10, digits)).padStart(digits, '0')
|
||||
}
|
||||
|
||||
export function generateHotpUri({ counter, digits, issuer, label, secret }) {
|
||||
assert('counter', typeof counter === 'number')
|
||||
return generateUri('hotp', label, { counter, digits, issuer, secret })
|
||||
}
|
||||
|
||||
export async function verifyHotp(token, opts) {
|
||||
return token === (await generateHotp(opts))
|
||||
}
|
||||
|
||||
function totpCounter(period = 30, timestamp = Math.floor(Date.now() / 1e3)) {
|
||||
return Math.floor(timestamp / period)
|
||||
}
|
||||
|
||||
// https://www.rfc-editor.org/rfc/rfc6238.html
|
||||
export async function generateTotp({ period, timestamp, ...opts }) {
|
||||
opts.counter = totpCounter(period, timestamp)
|
||||
return await generateHotp(opts)
|
||||
}
|
||||
|
||||
export function generateTotpUri({ digits, issuer, label, period, secret }) {
|
||||
return generateUri('totp', label, { digits, issuer, period, secret })
|
||||
}
|
||||
|
||||
export async function verifyTotp(token, { period, timestamp, window = 1, ...opts }) {
|
||||
const counter = totpCounter(period, timestamp)
|
||||
const end = counter + window
|
||||
opts.counter = counter - window
|
||||
while (opts.counter <= end) {
|
||||
if (token === (await generateHotp(opts))) {
|
||||
return true
|
||||
}
|
||||
opts.counter += 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function verifyFromUri(token, uri) {
|
||||
const url = new URL(uri)
|
||||
assert('protocol', url.protocol === 'otpauth:')
|
||||
|
||||
const { host } = url
|
||||
const opts = Object.fromEntries(url.searchParams.entries())
|
||||
if (host === 'hotp') {
|
||||
return await verifyHotp(token, opts)
|
||||
}
|
||||
if (host === 'totp') {
|
||||
return await verifyTotp(token, opts)
|
||||
}
|
||||
|
||||
assert('host', false)
|
||||
}
|
||||
112
@vates/otp/index.spec.mjs
Normal file
112
@vates/otp/index.spec.mjs
Normal file
@@ -0,0 +1,112 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { describe, it } from 'tap/mocha'
|
||||
|
||||
import {
|
||||
generateHotp,
|
||||
generateHotpUri,
|
||||
generateSecret,
|
||||
generateTotp,
|
||||
generateTotpUri,
|
||||
verifyHotp,
|
||||
verifyTotp,
|
||||
} from './index.mjs'
|
||||
|
||||
describe('generateSecret', function () {
|
||||
it('generates a string of 32 chars', async function () {
|
||||
const secret = generateSecret()
|
||||
assert.equal(typeof secret, 'string')
|
||||
assert.equal(secret.length, 32)
|
||||
})
|
||||
|
||||
it('generates a different secret at each call', async function () {
|
||||
assert.notEqual(generateSecret(), generateSecret())
|
||||
})
|
||||
})
|
||||
|
||||
describe('HOTP', function () {
|
||||
it('generate and verify valid tokens', async function () {
|
||||
for (const [token, opts] of Object.entries({
|
||||
382752: {
|
||||
counter: -3088,
|
||||
secret: 'PJYFSZ3JNVXVQMZXOB2EQYJSKB2HE6TB',
|
||||
},
|
||||
163376: {
|
||||
counter: 30598,
|
||||
secret: 'GBUDQZ3UKZZGIMRLNVYXA33GMFMEGQKN',
|
||||
},
|
||||
})) {
|
||||
assert.equal(await generateHotp(opts), token)
|
||||
assert(await verifyHotp(token, opts))
|
||||
}
|
||||
})
|
||||
|
||||
describe('generateHotpUri', function () {
|
||||
const opts = {
|
||||
counter: 59732,
|
||||
label: 'the label',
|
||||
secret: 'OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX',
|
||||
}
|
||||
|
||||
Object.entries({
|
||||
'without optional params': [
|
||||
opts,
|
||||
'otpauth://hotp/the%20label?counter=59732&secret=OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX',
|
||||
],
|
||||
'with issuer': [
|
||||
{ ...opts, issuer: 'the issuer' },
|
||||
'otpauth://hotp/the%20issuer:the%20label?counter=59732&issuer=the%20issuer&secret=OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX',
|
||||
],
|
||||
'with digits': [
|
||||
{ ...opts, digits: 7 },
|
||||
'otpauth://hotp/the%20label?counter=59732&digits=7&secret=OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX',
|
||||
],
|
||||
}).forEach(([title, [opts, uri]]) => {
|
||||
it(title, async function () {
|
||||
assert.strictEqual(generateHotpUri(opts), uri)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('TOTP', function () {
|
||||
Object.entries({
|
||||
'033702': {
|
||||
secret: 'PJYFSZ3JNVXVQMZXOB2EQYJSKB2HE6TB',
|
||||
timestamp: 1665416296,
|
||||
period: 30,
|
||||
},
|
||||
107250: {
|
||||
secret: 'GBUDQZ3UKZZGIMRLNVYXA33GMFMEGQKN',
|
||||
timestamp: 1665416674,
|
||||
period: 60,
|
||||
},
|
||||
}).forEach(([token, opts]) => {
|
||||
it('works', async function () {
|
||||
assert.equal(await generateTotp(opts), token)
|
||||
assert(await verifyTotp(token, opts))
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateHotpUri', function () {
|
||||
const opts = {
|
||||
label: 'the label',
|
||||
secret: 'OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX',
|
||||
}
|
||||
|
||||
Object.entries({
|
||||
'without optional params': [opts, 'otpauth://totp/the%20label?secret=OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX'],
|
||||
'with issuer': [
|
||||
{ ...opts, issuer: 'the issuer' },
|
||||
'otpauth://totp/the%20issuer:the%20label?issuer=the%20issuer&secret=OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX',
|
||||
],
|
||||
'with digits': [
|
||||
{ ...opts, digits: 7 },
|
||||
'otpauth://totp/the%20label?digits=7&secret=OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX',
|
||||
],
|
||||
}).forEach(([title, [opts, uri]]) => {
|
||||
it(title, async function () {
|
||||
assert.strictEqual(generateTotpUri(opts), uri)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
39
@vates/otp/package.json
Normal file
39
@vates/otp/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@vates/otp",
|
||||
"description": "Minimal HTOP/TOTP implementation",
|
||||
"keywords": [
|
||||
"2fa",
|
||||
"authenticator",
|
||||
"hotp",
|
||||
"otp",
|
||||
"totp"
|
||||
],
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/otp",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"main": "index.mjs",
|
||||
"repository": {
|
||||
"directory": "@vates/otp",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "1.0.0",
|
||||
"engines": {
|
||||
"node": ">=15"
|
||||
},
|
||||
"dependencies": {
|
||||
"rfc4648": "^1.5.2"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "tap"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tap": "^16.3.0"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
`undefined` predicates are ignored and `undefined` is returned if all predicates are `undefined`, this permits the most efficient composition:
|
||||
|
||||
```js
|
||||
const compositePredicate = every(undefined, some(predicate2, undefined))
|
||||
const compositePredicate = not(every(undefined, some(not(predicate2), undefined)))
|
||||
|
||||
// ends up as
|
||||
|
||||
@@ -36,6 +36,21 @@ isBetween3And10(10)
|
||||
// → false
|
||||
```
|
||||
|
||||
### `not(predicate)`
|
||||
|
||||
> Returns a predicate that returns the negation of the predicate.
|
||||
|
||||
```js
|
||||
const isEven = n => n % 2 === 0
|
||||
const isOdd = not(isEven)
|
||||
|
||||
isOdd(1)
|
||||
// true
|
||||
|
||||
isOdd(2)
|
||||
// false
|
||||
```
|
||||
|
||||
### `some(predicates)`
|
||||
|
||||
> Returns a predicate that returns `true` iff some predicate returns `true`.
|
||||
|
||||
@@ -19,7 +19,7 @@ Installation of the [npm package](https://npmjs.org/package/@vates/predicates):
|
||||
`undefined` predicates are ignored and `undefined` is returned if all predicates are `undefined`, this permits the most efficient composition:
|
||||
|
||||
```js
|
||||
const compositePredicate = every(undefined, some(predicate2, undefined))
|
||||
const compositePredicate = not(every(undefined, some(not(predicate2), undefined)))
|
||||
|
||||
// ends up as
|
||||
|
||||
@@ -54,6 +54,21 @@ isBetween3And10(10)
|
||||
// → false
|
||||
```
|
||||
|
||||
### `not(predicate)`
|
||||
|
||||
> Returns a predicate that returns the negation of the predicate.
|
||||
|
||||
```js
|
||||
const isEven = n => n % 2 === 0
|
||||
const isOdd = not(isEven)
|
||||
|
||||
isOdd(1)
|
||||
// true
|
||||
|
||||
isOdd(2)
|
||||
// false
|
||||
```
|
||||
|
||||
### `some(predicates)`
|
||||
|
||||
> Returns a predicate that returns `true` iff some predicate returns `true`.
|
||||
|
||||
@@ -51,6 +51,22 @@ exports.every = function every() {
|
||||
}
|
||||
}
|
||||
|
||||
const notPredicateTag = {}
|
||||
exports.not = function not(predicate) {
|
||||
if (isDefinedPredicate(predicate)) {
|
||||
if (predicate.tag === notPredicateTag) {
|
||||
return predicate.predicate
|
||||
}
|
||||
|
||||
function notPredicate() {
|
||||
return !predicate.apply(this, arguments)
|
||||
}
|
||||
notPredicate.predicate = predicate
|
||||
notPredicate.tag = notPredicateTag
|
||||
return notPredicate
|
||||
}
|
||||
}
|
||||
|
||||
exports.some = function some() {
|
||||
const predicates = handleArgs.apply(this, arguments)
|
||||
const n = predicates.length
|
||||
|
||||
@@ -3,20 +3,14 @@
|
||||
const assert = require('assert/strict')
|
||||
const { describe, it } = require('tap').mocha
|
||||
|
||||
const { every, some } = require('./')
|
||||
const { every, not, some } = require('./')
|
||||
|
||||
const T = () => true
|
||||
const F = () => false
|
||||
|
||||
const testArgsHandling = fn => {
|
||||
it('returns undefined if all predicates are undefined', () => {
|
||||
const testArgHandling = fn => {
|
||||
it('returns undefined if predicate is undefined', () => {
|
||||
assert.equal(fn(undefined), undefined)
|
||||
assert.equal(fn([undefined]), undefined)
|
||||
})
|
||||
|
||||
it('returns the predicate if only a single one is passed', () => {
|
||||
assert.equal(fn(undefined, T), T)
|
||||
assert.equal(fn([undefined, T]), T)
|
||||
})
|
||||
|
||||
it('throws if it receives a non-predicate', () => {
|
||||
@@ -24,6 +18,15 @@ const testArgsHandling = fn => {
|
||||
error.value = 3
|
||||
assert.throws(() => fn(3), error)
|
||||
})
|
||||
}
|
||||
|
||||
const testArgsHandling = fn => {
|
||||
testArgHandling(fn)
|
||||
|
||||
it('returns the predicate if only a single one is passed', () => {
|
||||
assert.equal(fn(undefined, T), T)
|
||||
assert.equal(fn([undefined, T]), T)
|
||||
})
|
||||
|
||||
it('forwards this and arguments to predicates', () => {
|
||||
const thisArg = 'qux'
|
||||
@@ -36,17 +39,21 @@ const testArgsHandling = fn => {
|
||||
})
|
||||
}
|
||||
|
||||
const runTests = (fn, truthTable) =>
|
||||
const runTests = (fn, acceptMultiple, truthTable) =>
|
||||
it('works', () => {
|
||||
truthTable.forEach(([result, ...predicates]) => {
|
||||
if (acceptMultiple) {
|
||||
assert.equal(fn(predicates)(), result)
|
||||
} else {
|
||||
assert.equal(predicates.length, 1)
|
||||
}
|
||||
assert.equal(fn(...predicates)(), result)
|
||||
assert.equal(fn(predicates)(), result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('every', () => {
|
||||
testArgsHandling(every)
|
||||
runTests(every, [
|
||||
runTests(every, true, [
|
||||
[true, T, T],
|
||||
[false, T, F],
|
||||
[false, F, T],
|
||||
@@ -54,9 +61,22 @@ describe('every', () => {
|
||||
])
|
||||
})
|
||||
|
||||
describe('not', () => {
|
||||
testArgHandling(not)
|
||||
|
||||
it('returns the original predicate if negated twice', () => {
|
||||
assert.equal(not(not(T)), T)
|
||||
})
|
||||
|
||||
runTests(not, false, [
|
||||
[true, F],
|
||||
[false, T],
|
||||
])
|
||||
})
|
||||
|
||||
describe('some', () => {
|
||||
testArgsHandling(some)
|
||||
runTests(some, [
|
||||
runTests(some, true, [
|
||||
[true, T, T],
|
||||
[true, T, F],
|
||||
[true, F, T],
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use strict'
|
||||
|
||||
const readChunk = (stream, size) =>
|
||||
size === 0
|
||||
stream.closed || stream.readableEnded
|
||||
? Promise.resolve(null)
|
||||
: size === 0
|
||||
? Promise.resolve(Buffer.alloc(0))
|
||||
: new Promise((resolve, reject) => {
|
||||
function onEnd() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
const { describe, it } = require('test')
|
||||
const assert = require('node:assert').strict
|
||||
|
||||
const { Readable } = require('stream')
|
||||
|
||||
@@ -11,35 +12,42 @@ makeStream.obj = Readable.from
|
||||
|
||||
describe('readChunk', () => {
|
||||
it('returns null if stream is empty', async () => {
|
||||
expect(await readChunk(makeStream([]))).toBe(null)
|
||||
assert.strictEqual(await readChunk(makeStream([])), null)
|
||||
})
|
||||
|
||||
it('returns null if the stream is already ended', async () => {
|
||||
const stream = await makeStream([])
|
||||
await readChunk(stream)
|
||||
|
||||
assert.strictEqual(await readChunk(stream), null)
|
||||
})
|
||||
|
||||
describe('with binary stream', () => {
|
||||
it('returns the first chunk of data', async () => {
|
||||
expect(await readChunk(makeStream(['foo', 'bar']))).toEqual(Buffer.from('foo'))
|
||||
assert.deepEqual(await readChunk(makeStream(['foo', 'bar'])), Buffer.from('foo'))
|
||||
})
|
||||
|
||||
it('returns a chunk of the specified size (smaller than first)', async () => {
|
||||
expect(await readChunk(makeStream(['foo', 'bar']), 2)).toEqual(Buffer.from('fo'))
|
||||
assert.deepEqual(await readChunk(makeStream(['foo', 'bar']), 2), Buffer.from('fo'))
|
||||
})
|
||||
|
||||
it('returns a chunk of the specified size (larger than first)', async () => {
|
||||
expect(await readChunk(makeStream(['foo', 'bar']), 4)).toEqual(Buffer.from('foob'))
|
||||
assert.deepEqual(await readChunk(makeStream(['foo', 'bar']), 4), Buffer.from('foob'))
|
||||
})
|
||||
|
||||
it('returns less data if stream ends', async () => {
|
||||
expect(await readChunk(makeStream(['foo', 'bar']), 10)).toEqual(Buffer.from('foobar'))
|
||||
assert.deepEqual(await readChunk(makeStream(['foo', 'bar']), 10), Buffer.from('foobar'))
|
||||
})
|
||||
|
||||
it('returns an empty buffer if the specified size is 0', async () => {
|
||||
expect(await readChunk(makeStream(['foo', 'bar']), 0)).toEqual(Buffer.alloc(0))
|
||||
assert.deepEqual(await readChunk(makeStream(['foo', 'bar']), 0), Buffer.alloc(0))
|
||||
})
|
||||
})
|
||||
|
||||
describe('with object stream', () => {
|
||||
it('returns the first chunk of data verbatim', async () => {
|
||||
const chunks = [{}, {}]
|
||||
expect(await readChunk(makeStream.obj(chunks))).toBe(chunks[0])
|
||||
assert.strictEqual(await readChunk(makeStream.obj(chunks)), chunks[0])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -55,15 +63,15 @@ const rejectionOf = promise =>
|
||||
describe('readChunkStrict', function () {
|
||||
it('throws if stream is empty', async () => {
|
||||
const error = await rejectionOf(readChunkStrict(makeStream([])))
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.message).toBe('stream has ended without data')
|
||||
expect(error.chunk).toEqual(undefined)
|
||||
assert(error instanceof Error)
|
||||
assert.strictEqual(error.message, 'stream has ended without data')
|
||||
assert.strictEqual(error.chunk, undefined)
|
||||
})
|
||||
|
||||
it('throws if stream ends with not enough data', async () => {
|
||||
const error = await rejectionOf(readChunkStrict(makeStream(['foo', 'bar']), 10))
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.message).toBe('stream has ended with not enough data')
|
||||
expect(error.chunk).toEqual(Buffer.from('foobar'))
|
||||
assert(error instanceof Error)
|
||||
assert.strictEqual(error.message, 'stream has ended with not enough data')
|
||||
assert.deepEqual(error.chunk, Buffer.from('foobar'))
|
||||
})
|
||||
})
|
||||
@@ -19,15 +19,19 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
},
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
const { describe, it } = require('test')
|
||||
const assert = require('assert').strict
|
||||
const sinon = require('sinon')
|
||||
|
||||
const { asyncMapSettled } = require('./')
|
||||
|
||||
@@ -9,26 +11,29 @@ const noop = Function.prototype
|
||||
describe('asyncMapSettled', () => {
|
||||
it('works', async () => {
|
||||
const values = [Math.random(), Math.random()]
|
||||
const spy = jest.fn(async v => v * 2)
|
||||
const spy = sinon.spy(async v => v * 2)
|
||||
const iterable = new Set(values)
|
||||
|
||||
// returns an array containing the result of each calls
|
||||
expect(await asyncMapSettled(iterable, spy)).toEqual(values.map(value => value * 2))
|
||||
assert.deepStrictEqual(
|
||||
await asyncMapSettled(iterable, spy),
|
||||
values.map(value => value * 2)
|
||||
)
|
||||
|
||||
for (let i = 0, n = values.length; i < n; ++i) {
|
||||
// each call receive the current item as sole argument
|
||||
expect(spy.mock.calls[i]).toEqual([values[i]])
|
||||
assert.deepStrictEqual(spy.args[i], [values[i]])
|
||||
|
||||
// each call as this bind to the iterable
|
||||
expect(spy.mock.instances[i]).toBe(iterable)
|
||||
assert.deepStrictEqual(spy.thisValues[i], iterable)
|
||||
}
|
||||
})
|
||||
|
||||
it('can use a specified thisArg', () => {
|
||||
const thisArg = {}
|
||||
const spy = jest.fn()
|
||||
const spy = sinon.spy()
|
||||
asyncMapSettled(['foo'], spy, thisArg)
|
||||
expect(spy.mock.instances[0]).toBe(thisArg)
|
||||
assert.deepStrictEqual(spy.thisValues[0], thisArg)
|
||||
})
|
||||
|
||||
it('rejects only when all calls as resolved', async () => {
|
||||
@@ -55,19 +60,22 @@ describe('asyncMapSettled', () => {
|
||||
// wait for all microtasks to settle
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
expect(hasSettled).toBe(false)
|
||||
assert.strictEqual(hasSettled, false)
|
||||
|
||||
defers[1].resolve()
|
||||
|
||||
// wait for all microtasks to settle
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
expect(hasSettled).toBe(true)
|
||||
await expect(promise).rejects.toBe(error)
|
||||
assert.strictEqual(hasSettled, true)
|
||||
await assert.rejects(promise, error)
|
||||
})
|
||||
|
||||
it('issues when latest promise rejects', async () => {
|
||||
const error = new Error()
|
||||
await expect(asyncMapSettled([1], () => Promise.reject(error))).rejects.toBe(error)
|
||||
await assert.rejects(
|
||||
asyncMapSettled([1], () => Promise.reject(error)),
|
||||
error
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -31,6 +31,11 @@
|
||||
"lodash": "^4.17.4"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish"
|
||||
"postversion": "npm publish",
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^14.0.1",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"golike-defer": "^0.5.1",
|
||||
"object-hash": "^2.0.1"
|
||||
},
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.27.4",
|
||||
"@xen-orchestra/fs": "^3.1.0",
|
||||
"@xen-orchestra/backups": "^0.29.0",
|
||||
"@xen-orchestra/fs": "^3.2.0",
|
||||
"filenamify": "^4.1.0",
|
||||
"getopts": "^2.2.5",
|
||||
"lodash": "^4.17.15",
|
||||
@@ -27,7 +27,7 @@
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
},
|
||||
"version": "0.7.7",
|
||||
"version": "0.7.8",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
|
||||
@@ -43,6 +43,7 @@ const DEFAULT_VM_SETTINGS = {
|
||||
offlineSnapshot: false,
|
||||
snapshotRetention: 0,
|
||||
timeout: 0,
|
||||
useNbd: false,
|
||||
unconditionalSnapshot: false,
|
||||
vmTimeout: 0,
|
||||
}
|
||||
|
||||
@@ -76,14 +76,16 @@ const debounceResourceFactory = factory =>
|
||||
}
|
||||
|
||||
class RemoteAdapter {
|
||||
constructor(handler, { debounceResource = res => res, dirMode, vhdDirectoryCompression, useGetDiskLegacy=false } = {}) {
|
||||
constructor(
|
||||
handler,
|
||||
{ debounceResource = res => res, dirMode, vhdDirectoryCompression, useGetDiskLegacy = false } = {}
|
||||
) {
|
||||
this._debounceResource = debounceResource
|
||||
this._dirMode = dirMode
|
||||
this._handler = handler
|
||||
this._vhdDirectoryCompression = vhdDirectoryCompression
|
||||
this._readCacheListVmBackups = synchronized.withKey()(this._readCacheListVmBackups)
|
||||
this._useGetDiskLegacy = useGetDiskLegacy
|
||||
|
||||
}
|
||||
|
||||
get handler() {
|
||||
@@ -324,9 +326,7 @@ class RemoteAdapter {
|
||||
return this.#useVhdDirectory()
|
||||
}
|
||||
|
||||
|
||||
async *#getDiskLegacy(diskId) {
|
||||
|
||||
const RE_VHDI = /^vhdi(\d+)$/
|
||||
const handler = this._handler
|
||||
|
||||
@@ -358,8 +358,8 @@ class RemoteAdapter {
|
||||
}
|
||||
|
||||
async *getDisk(diskId) {
|
||||
if(this._useGetDiskLegacy){
|
||||
yield * this.#getDiskLegacy(diskId)
|
||||
if (this._useGetDiskLegacy) {
|
||||
yield* this.#getDiskLegacy(diskId)
|
||||
return
|
||||
}
|
||||
const handler = this._handler
|
||||
@@ -659,9 +659,8 @@ class RemoteAdapter {
|
||||
return path
|
||||
}
|
||||
|
||||
async writeVhd(path, input, { checksum = true, validator = noop, writeBlockConcurrency } = {}) {
|
||||
async writeVhd(path, input, { checksum = true, validator = noop, writeBlockConcurrency, nbdClient } = {}) {
|
||||
const handler = this._handler
|
||||
|
||||
if (this.#useVhdDirectory()) {
|
||||
const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
|
||||
await createVhdDirectoryFromStream(handler, dataPath, input, {
|
||||
@@ -671,6 +670,7 @@ class RemoteAdapter {
|
||||
await input.task
|
||||
return validator.apply(this, arguments)
|
||||
},
|
||||
nbdClient,
|
||||
})
|
||||
await VhdAbstract.createAlias(handler, path, dataPath)
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
const { beforeEach, afterEach, test, describe } = require('test')
|
||||
const assert = require('assert').strict
|
||||
|
||||
const rimraf = require('rimraf')
|
||||
const tmp = require('tmp')
|
||||
@@ -14,9 +15,8 @@ const { VhdFile, Constants, VhdDirectory, VhdAbstract } = require('vhd-lib')
|
||||
const { checkAliases } = require('./_cleanVm')
|
||||
const { dirname, basename } = require('path')
|
||||
|
||||
let tempDir, adapter, handler, jobId, vdiId, basePath
|
||||
|
||||
jest.setTimeout(60000)
|
||||
let tempDir, adapter, handler, jobId, vdiId, basePath, relativePath
|
||||
const rootPath = 'xo-vm-backups/VMUUID/'
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await pFromCallback(cb => tmp.dir(cb))
|
||||
@@ -25,7 +25,8 @@ beforeEach(async () => {
|
||||
adapter = new RemoteAdapter(handler)
|
||||
jobId = uniqueId()
|
||||
vdiId = uniqueId()
|
||||
basePath = `vdis/${jobId}/${vdiId}`
|
||||
relativePath = `vdis/${jobId}/${vdiId}`
|
||||
basePath = `${rootPath}/${relativePath}`
|
||||
await fs.mkdirp(`${tempDir}/${basePath}`)
|
||||
})
|
||||
|
||||
@@ -76,18 +77,18 @@ test('It remove broken vhd', async () => {
|
||||
// todo also tests a directory and an alias
|
||||
|
||||
await handler.writeFile(`${basePath}/notReallyAVhd.vhd`, 'I AM NOT A VHD')
|
||||
expect((await handler.list(basePath)).length).toEqual(1)
|
||||
assert.equal((await handler.list(basePath)).length, 1)
|
||||
let loggued = ''
|
||||
const logInfo = message => {
|
||||
loggued += message
|
||||
}
|
||||
await adapter.cleanVm('/', { remove: false, logInfo, logWarn: logInfo, lock: false })
|
||||
expect(loggued).toEqual(`VHD check error`)
|
||||
await adapter.cleanVm(rootPath, { remove: false, logInfo, logWarn: logInfo, lock: false })
|
||||
assert.equal(loggued, `VHD check error`)
|
||||
// not removed
|
||||
expect((await handler.list(basePath)).length).toEqual(1)
|
||||
assert.deepEqual(await handler.list(basePath), ['notReallyAVhd.vhd'])
|
||||
// really remove it
|
||||
await adapter.cleanVm('/', { remove: true, logInfo, logWarn: () => {}, lock: false })
|
||||
expect((await handler.list(basePath)).length).toEqual(0)
|
||||
await adapter.cleanVm(rootPath, { remove: true, logInfo, logWarn: () => {}, lock: false })
|
||||
assert.deepEqual(await handler.list(basePath), [])
|
||||
})
|
||||
|
||||
test('it remove vhd with missing or multiple ancestors', async () => {
|
||||
@@ -121,10 +122,10 @@ test('it remove vhd with missing or multiple ancestors', async () => {
|
||||
const logInfo = message => {
|
||||
loggued += message + '\n'
|
||||
}
|
||||
await adapter.cleanVm('/', { remove: true, logInfo, logWarn: logInfo, lock: false })
|
||||
await adapter.cleanVm(rootPath, { remove: true, logInfo, logWarn: logInfo, lock: false })
|
||||
|
||||
const deletedOrphanVhd = loggued.match(/deleting orphan VHD/g) || []
|
||||
expect(deletedOrphanVhd.length).toEqual(1) // only one vhd should have been deleted
|
||||
assert.equal(deletedOrphanVhd.length, 1) // only one vhd should have been deleted
|
||||
|
||||
// we don't test the filew on disk, since they will all be marker as unused and deleted without a metadata.json file
|
||||
})
|
||||
@@ -132,12 +133,12 @@ test('it remove vhd with missing or multiple ancestors', async () => {
|
||||
test('it remove backup meta data referencing a missing vhd in delta backup', async () => {
|
||||
// create a metadata file marking child and orphan as ok
|
||||
await handler.writeFile(
|
||||
`metadata.json`,
|
||||
`${rootPath}/metadata.json`,
|
||||
JSON.stringify({
|
||||
mode: 'delta',
|
||||
vhds: [
|
||||
`${basePath}/orphan.vhd`,
|
||||
`${basePath}/child.vhd`,
|
||||
`${relativePath}/orphan.vhd`,
|
||||
`${relativePath}/child.vhd`,
|
||||
// abandonned.json is not here
|
||||
],
|
||||
})
|
||||
@@ -160,39 +161,39 @@ test('it remove backup meta data referencing a missing vhd in delta backup', asy
|
||||
const logInfo = message => {
|
||||
loggued += message + '\n'
|
||||
}
|
||||
await adapter.cleanVm('/', { remove: true, logInfo, logWarn: logInfo, lock: false })
|
||||
await adapter.cleanVm(rootPath, { remove: true, logInfo, logWarn: logInfo, lock: false })
|
||||
let matched = loggued.match(/deleting unused VHD/g) || []
|
||||
expect(matched.length).toEqual(1) // only one vhd should have been deleted
|
||||
assert.equal(matched.length, 1) // only one vhd should have been deleted
|
||||
|
||||
// a missing vhd cause clean to remove all vhds
|
||||
await handler.writeFile(
|
||||
`metadata.json`,
|
||||
`${rootPath}/metadata.json`,
|
||||
JSON.stringify({
|
||||
mode: 'delta',
|
||||
vhds: [
|
||||
`${basePath}/deleted.vhd`, // in metadata but not in vhds
|
||||
`${basePath}/orphan.vhd`,
|
||||
`${basePath}/child.vhd`,
|
||||
`deleted.vhd`, // in metadata but not in vhds
|
||||
`orphan.vhd`,
|
||||
`child.vhd`,
|
||||
// abandonned.vhd is not here anymore
|
||||
],
|
||||
}),
|
||||
{ flags: 'w' }
|
||||
)
|
||||
loggued = ''
|
||||
await adapter.cleanVm('/', { remove: true, logInfo, logWarn: () => {}, lock: false })
|
||||
await adapter.cleanVm(rootPath, { remove: true, logInfo, logWarn: () => {}, lock: false })
|
||||
matched = loggued.match(/deleting unused VHD/g) || []
|
||||
expect(matched.length).toEqual(2) // all vhds (orphan and child ) should have been deleted
|
||||
assert.equal(matched.length, 2) // all vhds (orphan and child ) should have been deleted
|
||||
})
|
||||
|
||||
test('it merges delta of non destroyed chain', async () => {
|
||||
await handler.writeFile(
|
||||
`metadata.json`,
|
||||
`${rootPath}/metadata.json`,
|
||||
JSON.stringify({
|
||||
mode: 'delta',
|
||||
size: 12000, // a size too small
|
||||
vhds: [
|
||||
`${basePath}/grandchild.vhd`, // grand child should not be merged
|
||||
`${basePath}/child.vhd`,
|
||||
`${relativePath}/grandchild.vhd`, // grand child should not be merged
|
||||
`${relativePath}/child.vhd`,
|
||||
// orphan is not here, he should be merged in child
|
||||
],
|
||||
})
|
||||
@@ -219,33 +220,33 @@ test('it merges delta of non destroyed chain', async () => {
|
||||
const logInfo = message => {
|
||||
loggued.push(message)
|
||||
}
|
||||
await adapter.cleanVm('/', { remove: true, logInfo, logWarn: logInfo, lock: false })
|
||||
expect(loggued[0]).toEqual(`incorrect backup size in metadata`)
|
||||
await adapter.cleanVm(rootPath, { remove: true, logInfo, logWarn: logInfo, lock: false })
|
||||
assert.equal(loggued[0], `incorrect backup size in metadata`)
|
||||
|
||||
loggued = []
|
||||
await adapter.cleanVm('/', { remove: true, merge: true, logInfo, logWarn: () => {}, lock: false })
|
||||
await adapter.cleanVm(rootPath, { remove: true, merge: true, logInfo, logWarn: () => {}, lock: false })
|
||||
const [merging] = loggued
|
||||
expect(merging).toEqual(`merging VHD chain`)
|
||||
assert.equal(merging, `merging VHD chain`)
|
||||
|
||||
const metadata = JSON.parse(await handler.readFile(`metadata.json`))
|
||||
const metadata = JSON.parse(await handler.readFile(`${rootPath}/metadata.json`))
|
||||
// size should be the size of children + grand children after the merge
|
||||
expect(metadata.size).toEqual(209920)
|
||||
assert.equal(metadata.size, 209920)
|
||||
|
||||
// merging is already tested in vhd-lib, don't retest it here (and theses vhd are as empty as my stomach at 12h12)
|
||||
// only check deletion
|
||||
const remainingVhds = await handler.list(basePath)
|
||||
expect(remainingVhds.length).toEqual(2)
|
||||
expect(remainingVhds.includes('child.vhd')).toEqual(true)
|
||||
expect(remainingVhds.includes('grandchild.vhd')).toEqual(true)
|
||||
assert.equal(remainingVhds.length, 2)
|
||||
assert.equal(remainingVhds.includes('child.vhd'), true)
|
||||
assert.equal(remainingVhds.includes('grandchild.vhd'), true)
|
||||
})
|
||||
|
||||
test('it finish unterminated merge ', async () => {
|
||||
await handler.writeFile(
|
||||
`metadata.json`,
|
||||
`${rootPath}/metadata.json`,
|
||||
JSON.stringify({
|
||||
mode: 'delta',
|
||||
size: 209920,
|
||||
vhds: [`${basePath}/orphan.vhd`, `${basePath}/child.vhd`],
|
||||
vhds: [`${relativePath}/orphan.vhd`, `${relativePath}/child.vhd`],
|
||||
})
|
||||
)
|
||||
|
||||
@@ -271,13 +272,13 @@ test('it finish unterminated merge ', async () => {
|
||||
})
|
||||
)
|
||||
|
||||
await adapter.cleanVm('/', { remove: true, merge: true, logWarn: () => {}, lock: false })
|
||||
await adapter.cleanVm(rootPath, { remove: true, merge: true, logWarn: () => {}, lock: false })
|
||||
// merging is already tested in vhd-lib, don't retest it here (and theses vhd are as empty as my stomach at 12h12)
|
||||
|
||||
// only check deletion
|
||||
const remainingVhds = await handler.list(basePath)
|
||||
expect(remainingVhds.length).toEqual(1)
|
||||
expect(remainingVhds.includes('child.vhd')).toEqual(true)
|
||||
assert.equal(remainingVhds.length, 1)
|
||||
assert.equal(remainingVhds.includes('child.vhd'), true)
|
||||
})
|
||||
|
||||
// each of the vhd can be a file, a directory, an alias to a file or an alias to a directory
|
||||
@@ -367,22 +368,22 @@ describe('tests multiple combination ', () => {
|
||||
|
||||
// the metadata file
|
||||
await handler.writeFile(
|
||||
`metadata.json`,
|
||||
`${rootPath}/metadata.json`,
|
||||
JSON.stringify({
|
||||
mode: 'delta',
|
||||
vhds: [
|
||||
`${basePath}/grandchild.vhd` + (useAlias ? '.alias.vhd' : ''), // grand child should not be merged
|
||||
`${basePath}/child.vhd` + (useAlias ? '.alias.vhd' : ''),
|
||||
`${basePath}/clean.vhd` + (useAlias ? '.alias.vhd' : ''),
|
||||
`${relativePath}/grandchild.vhd` + (useAlias ? '.alias.vhd' : ''), // grand child should not be merged
|
||||
`${relativePath}/child.vhd` + (useAlias ? '.alias.vhd' : ''),
|
||||
`${relativePath}/clean.vhd` + (useAlias ? '.alias.vhd' : ''),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
await adapter.cleanVm('/', { remove: true, merge: true, logWarn: () => {}, lock: false })
|
||||
await adapter.cleanVm(rootPath, { remove: true, merge: true, logWarn: () => {}, lock: false })
|
||||
|
||||
const metadata = JSON.parse(await handler.readFile(`metadata.json`))
|
||||
const metadata = JSON.parse(await handler.readFile(`${rootPath}/metadata.json`))
|
||||
// size should be the size of children + grand children + clean after the merge
|
||||
expect(metadata.size).toEqual(vhdMode === 'file' ? 314880 : undefined)
|
||||
assert.deepEqual(metadata.size, vhdMode === 'file' ? 314880 : undefined)
|
||||
|
||||
// broken vhd, non referenced, abandonned should be deleted ( alias and data)
|
||||
// ancestor and child should be merged
|
||||
@@ -392,19 +393,19 @@ describe('tests multiple combination ', () => {
|
||||
if (useAlias) {
|
||||
const dataSurvivors = await handler.list(basePath + '/data')
|
||||
// the goal of the alias : do not move a full folder
|
||||
expect(dataSurvivors).toContain('ancestor.vhd')
|
||||
expect(dataSurvivors).toContain('grandchild.vhd')
|
||||
expect(dataSurvivors).toContain('cleanAncestor.vhd')
|
||||
expect(survivors).toContain('clean.vhd.alias.vhd')
|
||||
expect(survivors).toContain('child.vhd.alias.vhd')
|
||||
expect(survivors).toContain('grandchild.vhd.alias.vhd')
|
||||
expect(survivors.length).toEqual(4) // the 3 ok + data
|
||||
expect(dataSurvivors.length).toEqual(3) // the 3 ok + data
|
||||
assert.equal(dataSurvivors.includes('ancestor.vhd'), true)
|
||||
assert.equal(dataSurvivors.includes('grandchild.vhd'), true)
|
||||
assert.equal(dataSurvivors.includes('cleanAncestor.vhd'), true)
|
||||
assert.equal(survivors.includes('clean.vhd.alias.vhd'), true)
|
||||
assert.equal(survivors.includes('child.vhd.alias.vhd'), true)
|
||||
assert.equal(survivors.includes('grandchild.vhd.alias.vhd'), true)
|
||||
assert.equal(survivors.length, 4) // the 3 ok + data
|
||||
assert.equal(dataSurvivors.length, 3)
|
||||
} else {
|
||||
expect(survivors).toContain('clean.vhd')
|
||||
expect(survivors).toContain('child.vhd')
|
||||
expect(survivors).toContain('grandchild.vhd')
|
||||
expect(survivors.length).toEqual(3)
|
||||
assert.equal(survivors.includes('clean.vhd'), true)
|
||||
assert.equal(survivors.includes('child.vhd'), true)
|
||||
assert.equal(survivors.includes('grandchild.vhd'), true)
|
||||
assert.equal(survivors.length, 3)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -414,9 +415,9 @@ describe('tests multiple combination ', () => {
|
||||
test('it cleans orphan merge states ', async () => {
|
||||
await handler.writeFile(`${basePath}/.orphan.vhd.merge.json`, '')
|
||||
|
||||
await adapter.cleanVm('/', { remove: true, logWarn: () => {}, lock: false })
|
||||
await adapter.cleanVm(rootPath, { remove: true, logWarn: () => {}, lock: false })
|
||||
|
||||
expect(await handler.list(basePath)).toEqual([])
|
||||
assert.deepEqual(await handler.list(basePath), [])
|
||||
})
|
||||
|
||||
test('check Aliases should work alone', async () => {
|
||||
@@ -437,8 +438,8 @@ test('check Aliases should work alone', async () => {
|
||||
|
||||
// only ok have suvived
|
||||
const alias = (await handler.list('vhds')).filter(f => f.endsWith('.vhd'))
|
||||
expect(alias.length).toEqual(1)
|
||||
assert.equal(alias.length, 1)
|
||||
|
||||
const data = await handler.list('vhds/data')
|
||||
expect(data.length).toEqual(1)
|
||||
assert.equal(data.length, 1)
|
||||
})
|
||||
@@ -8,24 +8,26 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.27.4",
|
||||
"version": "0.29.0",
|
||||
"engines": {
|
||||
"node": ">=14.6"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.0",
|
||||
"@vates/cached-dns.lookup": "^1.0.0",
|
||||
"@vates/compose": "^2.1.0",
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/disposable": "^0.1.1",
|
||||
"@vates/fuse-vhd": "^0.0.1",
|
||||
"@vates/disposable": "^0.1.2",
|
||||
"@vates/fuse-vhd": "^1.0.0",
|
||||
"@vates/nbd-client": "*",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/fs": "^3.1.0",
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"@xen-orchestra/fs": "^3.2.0",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/template": "^0.1.0",
|
||||
"compare-versions": "^5.0.1",
|
||||
"d3-time-format": "^3.0.0",
|
||||
@@ -40,15 +42,17 @@
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"uuid": "^9.0.0",
|
||||
"vhd-lib": "^4.0.1",
|
||||
"vhd-lib": "^4.1.1",
|
||||
"yazl": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"rimraf": "^3.0.2",
|
||||
"sinon": "^14.0.1",
|
||||
"test": "^3.2.1",
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xen-orchestra/xapi": "^1.4.2"
|
||||
"@xen-orchestra/xapi": "^1.5.2"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
|
||||
@@ -19,8 +19,9 @@ const { AbstractDeltaWriter } = require('./_AbstractDeltaWriter.js')
|
||||
const { checkVhd } = require('./_checkVhd.js')
|
||||
const { packUuid } = require('./_packUuid.js')
|
||||
const { Disposable } = require('promise-toolbox')
|
||||
const NbdClient = require('@vates/nbd-client')
|
||||
|
||||
const { warn } = createLogger('xo:backups:DeltaBackupWriter')
|
||||
const { debug, warn } = createLogger('xo:backups:DeltaBackupWriter')
|
||||
|
||||
exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
|
||||
async checkBaseVdis(baseUuidToSrcVdi) {
|
||||
@@ -199,12 +200,30 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
await checkVhd(handler, parentPath)
|
||||
}
|
||||
|
||||
const vdiRef = vm.$xapi.getObject(vdi.uuid).$ref
|
||||
|
||||
let nbdClient
|
||||
if (!this._backup.config.useNbd) {
|
||||
// get nbd if possible
|
||||
try {
|
||||
// this will always take the first host in the list
|
||||
const [nbdInfo] = await vm.$xapi.call('VDI.get_nbd_info', vdiRef)
|
||||
nbdClient = new NbdClient(nbdInfo)
|
||||
await nbdClient.connect()
|
||||
debug(`got nbd connection `, { vdi: vdi.uuid })
|
||||
} catch (error) {
|
||||
nbdClient = undefined
|
||||
debug(`can't connect to nbd server or no server available`, { error })
|
||||
}
|
||||
}
|
||||
|
||||
await adapter.writeVhd(path, deltaExport.streams[`${id}.vhd`], {
|
||||
// no checksum for VHDs, because they will be invalidated by
|
||||
// merges and chainings
|
||||
checksum: false,
|
||||
validator: tmpPath => checkVhd(handler, tmpPath),
|
||||
writeBlockConcurrency: this._backup.config.writeBlockConcurrency,
|
||||
nbdClient,
|
||||
})
|
||||
|
||||
if (isDelta) {
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('test')
|
||||
const assert = require('assert').strict
|
||||
const sinon = require('sinon')
|
||||
|
||||
const { createSchedule } = require('./')
|
||||
|
||||
jest.useFakeTimers()
|
||||
const clock = sinon.useFakeTimers()
|
||||
|
||||
const wrap = value => () => value
|
||||
|
||||
describe('issues', () => {
|
||||
test('issues', async t => {
|
||||
let originalDateNow
|
||||
beforeAll(() => {
|
||||
originalDateNow = Date.now
|
||||
})
|
||||
afterAll(() => {
|
||||
Date.now = originalDateNow
|
||||
originalDateNow = undefined
|
||||
})
|
||||
originalDateNow = Date.now
|
||||
|
||||
test('stop during async execution', async () => {
|
||||
await t.test('stop during async execution', async () => {
|
||||
let nCalls = 0
|
||||
let resolve, promise
|
||||
|
||||
@@ -35,20 +31,20 @@ describe('issues', () => {
|
||||
|
||||
job.start()
|
||||
Date.now = wrap(+schedule.next(1)[0])
|
||||
jest.runAllTimers()
|
||||
clock.runAll()
|
||||
|
||||
expect(nCalls).toBe(1)
|
||||
assert.strictEqual(nCalls, 1)
|
||||
|
||||
job.stop()
|
||||
|
||||
resolve()
|
||||
await promise
|
||||
|
||||
jest.runAllTimers()
|
||||
expect(nCalls).toBe(1)
|
||||
clock.runAll()
|
||||
assert.strictEqual(nCalls, 1)
|
||||
})
|
||||
|
||||
test('stop then start during async job execution', async () => {
|
||||
await t.test('stop then start during async job execution', async () => {
|
||||
let nCalls = 0
|
||||
let resolve, promise
|
||||
|
||||
@@ -65,9 +61,9 @@ describe('issues', () => {
|
||||
|
||||
job.start()
|
||||
Date.now = wrap(+schedule.next(1)[0])
|
||||
jest.runAllTimers()
|
||||
clock.runAll()
|
||||
|
||||
expect(nCalls).toBe(1)
|
||||
assert.strictEqual(nCalls, 1)
|
||||
|
||||
job.stop()
|
||||
job.start()
|
||||
@@ -76,7 +72,10 @@ describe('issues', () => {
|
||||
await promise
|
||||
|
||||
Date.now = wrap(+schedule.next(1)[0])
|
||||
jest.runAllTimers()
|
||||
expect(nCalls).toBe(2)
|
||||
clock.runAll()
|
||||
assert.strictEqual(nCalls, 2)
|
||||
})
|
||||
|
||||
Date.now = originalDateNow
|
||||
originalDateNow = undefined
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
'use strict'
|
||||
|
||||
const { describe, it } = require('test')
|
||||
const assert = require('assert').strict
|
||||
|
||||
const mapValues = require('lodash/mapValues')
|
||||
const moment = require('moment-timezone')
|
||||
|
||||
@@ -25,24 +26,24 @@ describe('next()', () => {
|
||||
},
|
||||
([pattern, result], title) =>
|
||||
it(title, () => {
|
||||
expect(N(pattern)).toBe(result)
|
||||
assert.strictEqual(N(pattern), result)
|
||||
})
|
||||
)
|
||||
|
||||
it('select first between month-day and week-day', () => {
|
||||
expect(N('* * 10 * wen')).toBe('2018-04-10T00:00')
|
||||
expect(N('* * 12 * wen')).toBe('2018-04-11T00:00')
|
||||
assert.strictEqual(N('* * 10 * wen'), '2018-04-10T00:00')
|
||||
assert.strictEqual(N('* * 12 * wen'), '2018-04-11T00:00')
|
||||
})
|
||||
|
||||
it('select the last available day of a month', () => {
|
||||
expect(N('* * 29 feb *')).toBe('2020-02-29T00:00')
|
||||
assert.strictEqual(N('* * 29 feb *'), '2020-02-29T00:00')
|
||||
})
|
||||
|
||||
it('fails when no solutions has been found', () => {
|
||||
expect(() => N('0 0 30 feb *')).toThrow('no solutions found for this schedule')
|
||||
assert.throws(() => N('0 0 30 feb *'), { message: 'no solutions found for this schedule' })
|
||||
})
|
||||
|
||||
it('select the first sunday of the month', () => {
|
||||
expect(N('* * * * 0', '2018-03-31T00:00')).toBe('2018-04-01T00:00')
|
||||
assert.strictEqual(N('* * * * 0', '2018-03-31T00:00'), '2018-04-01T00:00')
|
||||
})
|
||||
})
|
||||
@@ -38,6 +38,11 @@
|
||||
"moment-timezone": "^0.5.14"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish"
|
||||
"postversion": "npm publish",
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^14.0.1",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
'use strict'
|
||||
|
||||
const parse = require('./parse')
|
||||
|
||||
describe('parse()', () => {
|
||||
it('works', () => {
|
||||
expect(parse('0 0-10 */10 jan,2,4-11/3 *')).toEqual({
|
||||
minute: [0],
|
||||
hour: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||
dayOfMonth: [1, 11, 21, 31],
|
||||
month: [0, 2, 4, 7, 10],
|
||||
})
|
||||
})
|
||||
|
||||
it('correctly parse months', () => {
|
||||
expect(parse('* * * 0,11 *')).toEqual({
|
||||
month: [0, 11],
|
||||
})
|
||||
expect(parse('* * * jan,dec *')).toEqual({
|
||||
month: [0, 11],
|
||||
})
|
||||
})
|
||||
|
||||
it('correctly parse days', () => {
|
||||
expect(parse('* * * * mon,sun')).toEqual({
|
||||
dayOfWeek: [0, 1],
|
||||
})
|
||||
})
|
||||
|
||||
it('reports missing integer', () => {
|
||||
expect(() => parse('*/a')).toThrow('minute: missing integer at character 2')
|
||||
expect(() => parse('*')).toThrow('hour: missing integer at character 1')
|
||||
})
|
||||
|
||||
it('reports invalid aliases', () => {
|
||||
expect(() => parse('* * * jan-foo *')).toThrow('month: missing alias or integer at character 10')
|
||||
})
|
||||
|
||||
it('dayOfWeek: 0 and 7 bind to sunday', () => {
|
||||
expect(parse('* * * * 0')).toEqual({
|
||||
dayOfWeek: [0],
|
||||
})
|
||||
expect(parse('* * * * 7')).toEqual({
|
||||
dayOfWeek: [0],
|
||||
})
|
||||
})
|
||||
})
|
||||
50
@xen-orchestra/cron/parse.test.js
Normal file
50
@xen-orchestra/cron/parse.test.js
Normal file
@@ -0,0 +1,50 @@
|
||||
'use strict'
|
||||
|
||||
const { describe, it } = require('test')
|
||||
const assert = require('assert').strict
|
||||
|
||||
const parse = require('./parse')
|
||||
|
||||
describe('parse()', () => {
|
||||
it('works', () => {
|
||||
assert.deepStrictEqual(parse('0 0-10 */10 jan,2,4-11/3 *'), {
|
||||
minute: [0],
|
||||
hour: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||
dayOfMonth: [1, 11, 21, 31],
|
||||
month: [0, 2, 4, 7, 10],
|
||||
})
|
||||
})
|
||||
|
||||
it('correctly parse months', () => {
|
||||
assert.deepStrictEqual(parse('* * * 0,11 *'), {
|
||||
month: [0, 11],
|
||||
})
|
||||
assert.deepStrictEqual(parse('* * * jan,dec *'), {
|
||||
month: [0, 11],
|
||||
})
|
||||
})
|
||||
|
||||
it('correctly parse days', () => {
|
||||
assert.deepStrictEqual(parse('* * * * mon,sun'), {
|
||||
dayOfWeek: [0, 1],
|
||||
})
|
||||
})
|
||||
|
||||
it('reports missing integer', () => {
|
||||
assert.throws(() => parse('*/a'), { message: 'minute: missing integer at character 2' })
|
||||
assert.throws(() => parse('*'), { message: 'hour: missing integer at character 1' })
|
||||
})
|
||||
|
||||
it('reports invalid aliases', () => {
|
||||
assert.throws(() => parse('* * * jan-foo *'), { message: 'month: missing alias or integer at character 10' })
|
||||
})
|
||||
|
||||
it('dayOfWeek: 0 and 7 bind to sunday', () => {
|
||||
assert.deepStrictEqual(parse('* * * * 0'), {
|
||||
dayOfWeek: [0],
|
||||
})
|
||||
assert.deepStrictEqual(parse('* * * * 7'), {
|
||||
dayOfWeek: [0],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/fs",
|
||||
"version": "3.1.0",
|
||||
"version": "3.2.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "The File System for Xen Orchestra backups.",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
|
||||
@@ -28,9 +28,9 @@
|
||||
"@vates/async-each": "^1.0.0",
|
||||
"@vates/coalesce-calls": "^0.1.0",
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/read-chunk": "^1.0.0",
|
||||
"@vates/read-chunk": "^1.0.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.3.0",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"bind-property-descriptor": "^2.0.0",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
"execa": "^5.0.0",
|
||||
@@ -40,10 +40,9 @@
|
||||
"lodash": "^4.17.4",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"pumpify": "^2.0.1",
|
||||
"readable-stream": "^4.1.0",
|
||||
"through2": "^4.0.2",
|
||||
"xo-remote-parser": "^0.9.1"
|
||||
"xo-remote-parser": "^0.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.0.0",
|
||||
@@ -54,7 +53,8 @@
|
||||
"babel-plugin-lodash": "^3.3.2",
|
||||
"cross-env": "^7.0.2",
|
||||
"dotenv": "^16.0.0",
|
||||
"rimraf": "^3.0.0"
|
||||
"rimraf": "^3.0.0",
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/",
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
const { pipeline } = require('node:stream')
|
||||
const { readChunk } = require('@vates/read-chunk')
|
||||
const crypto = require('crypto')
|
||||
const pumpify = require('pumpify')
|
||||
|
||||
function getEncryptor(key) {
|
||||
export const DEFAULT_ENCRYPTION_ALGORITHM = 'aes-256-gcm'
|
||||
export const UNENCRYPTED_ALGORITHM = 'none'
|
||||
|
||||
export function isLegacyEncryptionAlgorithm(algorithm) {
|
||||
return algorithm !== UNENCRYPTED_ALGORITHM && algorithm !== DEFAULT_ENCRYPTION_ALGORITHM
|
||||
}
|
||||
|
||||
function getEncryptor(algorithm = DEFAULT_ENCRYPTION_ALGORITHM, key) {
|
||||
if (key === undefined) {
|
||||
return {
|
||||
id: 'NULL_ENCRYPTOR',
|
||||
@@ -15,43 +22,100 @@ function getEncryptor(key) {
|
||||
decryptStream: stream => stream,
|
||||
}
|
||||
}
|
||||
const algorithm = 'aes-256-cbc'
|
||||
const ivLength = 16
|
||||
const info = crypto.getCipherInfo(algorithm, { keyLength: key.length })
|
||||
if (info === undefined) {
|
||||
const error = new Error(
|
||||
`Either the algorithm ${algorithm} is not available, or the key length ${
|
||||
key.length
|
||||
} is incorrect. Supported algorithm are ${crypto.getCiphers()}`
|
||||
)
|
||||
error.code = 'BAD_ALGORITHM'
|
||||
throw error
|
||||
}
|
||||
const { ivLength, mode } = info
|
||||
const authTagLength = ['gcm', 'ccm', 'ocb'].includes(mode) ? 16 : 0
|
||||
|
||||
function encryptStream(input) {
|
||||
const iv = crypto.randomBytes(ivLength)
|
||||
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv)
|
||||
|
||||
const encrypted = pumpify(input, cipher)
|
||||
encrypted.unshift(iv)
|
||||
return encrypted
|
||||
return pipeline(
|
||||
input,
|
||||
async function* (source) {
|
||||
const iv = crypto.randomBytes(ivLength)
|
||||
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv)
|
||||
yield iv
|
||||
for await (const data of source) {
|
||||
yield cipher.update(data)
|
||||
}
|
||||
yield cipher.final()
|
||||
// must write the auth tag at the end of the encryption stream
|
||||
if (authTagLength > 0) {
|
||||
yield cipher.getAuthTag()
|
||||
}
|
||||
},
|
||||
() => {}
|
||||
)
|
||||
}
|
||||
|
||||
async function decryptStream(encryptedStream) {
|
||||
const iv = await readChunk(encryptedStream, ivLength)
|
||||
const cipher = crypto.createDecipheriv(algorithm, Buffer.from(key), iv)
|
||||
/**
|
||||
* WARNING
|
||||
*
|
||||
* the crytped size has an initializtion vector + a padding at the end
|
||||
* whe can't predict the decrypted size from the start of the encrypted size
|
||||
* thus, we can't set decrypted.length reliably
|
||||
*
|
||||
*/
|
||||
return pumpify(encryptedStream, cipher)
|
||||
function decryptStream(encryptedStream) {
|
||||
return pipeline(
|
||||
encryptedStream,
|
||||
async function* (source) {
|
||||
/**
|
||||
* WARNING
|
||||
*
|
||||
* the crypted size has an initializtion vector + eventually an auth tag + a padding at the end
|
||||
* whe can't predict the decrypted size from the start of the encrypted size
|
||||
* thus, we can't set decrypted.length reliably
|
||||
*
|
||||
*/
|
||||
|
||||
const iv = await readChunk(source, ivLength)
|
||||
const cipher = crypto.createDecipheriv(algorithm, Buffer.from(key), iv)
|
||||
let authTag = Buffer.alloc(0)
|
||||
for await (const data of source) {
|
||||
if (data.length >= authTagLength) {
|
||||
// fast path, no buffer concat
|
||||
yield cipher.update(authTag)
|
||||
authTag = data.slice(data.length - authTagLength)
|
||||
yield cipher.update(data.slice(0, data.length - authTagLength))
|
||||
} else {
|
||||
// slower since there is a concat
|
||||
const fullData = Buffer.concat([authTag, data])
|
||||
const fullDataLength = fullData.length
|
||||
if (fullDataLength > authTagLength) {
|
||||
authTag = fullData.slice(fullDataLength - authTagLength)
|
||||
yield cipher.update(fullData.slice(0, fullDataLength - authTagLength))
|
||||
} else {
|
||||
authTag = fullData
|
||||
}
|
||||
}
|
||||
}
|
||||
if (authTagLength > 0) {
|
||||
cipher.setAuthTag(authTag)
|
||||
}
|
||||
yield cipher.final()
|
||||
},
|
||||
() => {}
|
||||
)
|
||||
}
|
||||
|
||||
function encryptData(buffer) {
|
||||
const iv = crypto.randomBytes(ivLength)
|
||||
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv)
|
||||
const encrypted = cipher.update(buffer)
|
||||
return Buffer.concat([iv, encrypted, cipher.final()])
|
||||
return Buffer.concat([iv, encrypted, cipher.final(), authTagLength > 0 ? cipher.getAuthTag() : Buffer.alloc(0)])
|
||||
}
|
||||
|
||||
function decryptData(buffer) {
|
||||
const iv = buffer.slice(0, ivLength)
|
||||
const encrypted = buffer.slice(ivLength)
|
||||
const decipher = crypto.createDecipheriv(algorithm, Buffer.from(key), iv)
|
||||
let encrypted
|
||||
if (authTagLength > 0) {
|
||||
const authTag = buffer.slice(buffer.length - authTagLength)
|
||||
decipher.setAuthTag(authTag)
|
||||
encrypted = buffer.slice(ivLength, buffer.length - authTagLength)
|
||||
} else {
|
||||
encrypted = buffer.slice(ivLength)
|
||||
}
|
||||
const decrypted = decipher.update(encrypted)
|
||||
return Buffer.concat([decrypted, decipher.final()])
|
||||
}
|
||||
|
||||
50
@xen-orchestra/fs/src/_encryptor.spec.js
Normal file
50
@xen-orchestra/fs/src/_encryptor.spec.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/* eslint-env jest */
|
||||
import { Readable } from 'node:stream'
|
||||
import { _getEncryptor } from './_encryptor'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const algorithms = ['none', 'aes-256-cbc', 'aes-256-gcm']
|
||||
|
||||
function streamToBuffer(stream) {
|
||||
return new Promise(resolve => {
|
||||
const bufs = []
|
||||
stream.on('data', function (d) {
|
||||
bufs.push(d)
|
||||
})
|
||||
stream.on('end', function () {
|
||||
resolve(Buffer.concat(bufs))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
algorithms.forEach(algorithm => {
|
||||
describe(`test algorithm ${algorithm}`, () => {
|
||||
const key = algorithm === 'none' ? undefined : '73c1838d7d8a6088ca2317fb5f29cd91'
|
||||
const encryptor = _getEncryptor(algorithm, key)
|
||||
const buffer = crypto.randomBytes(1024 * 1024 + 1)
|
||||
it('handle buffer', () => {
|
||||
const encrypted = encryptor.encryptData(buffer)
|
||||
if (algorithm !== 'none') {
|
||||
expect(encrypted.equals(buffer)).toEqual(false) // encrypted should be different
|
||||
// ivlength, auth tag, padding
|
||||
expect(encrypted.length).not.toEqual(buffer.length)
|
||||
}
|
||||
|
||||
const decrypted = encryptor.decryptData(encrypted)
|
||||
expect(decrypted.equals(buffer)).toEqual(true)
|
||||
})
|
||||
|
||||
it('handle stream', async () => {
|
||||
const stream = Readable.from(buffer)
|
||||
stream.length = buffer.length
|
||||
const encrypted = encryptor.encryptStream(stream)
|
||||
if (algorithm !== 'none') {
|
||||
expect(encrypted.length).toEqual(undefined)
|
||||
}
|
||||
|
||||
const decrypted = encryptor.decryptStream(encrypted)
|
||||
const decryptedBuffer = await streamToBuffer(decrypted)
|
||||
expect(decryptedBuffer.equals(buffer)).toEqual(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,7 @@ import { synchronized } from 'decorator-synchronized'
|
||||
|
||||
import { basename, dirname, normalize as normalizePath } from './path'
|
||||
import { createChecksumStream, validChecksumOfReadStream } from './checksum'
|
||||
import { _getEncryptor } from './_encryptor'
|
||||
import { DEFAULT_ENCRYPTION_ALGORITHM, _getEncryptor } from './_encryptor'
|
||||
|
||||
const { info, warn } = createLogger('@xen-orchestra:fs')
|
||||
|
||||
@@ -68,7 +68,15 @@ class PrefixWrapper {
|
||||
}
|
||||
|
||||
export default class RemoteHandlerAbstract {
|
||||
_encryptor
|
||||
#encryptor
|
||||
|
||||
get _encryptor() {
|
||||
if (this.#encryptor === undefined) {
|
||||
throw new Error(`Can't access to encryptor before remote synchronization`)
|
||||
}
|
||||
return this.#encryptor
|
||||
}
|
||||
|
||||
constructor(remote, options = {}) {
|
||||
if (remote.url === 'test://') {
|
||||
this._remote = remote
|
||||
@@ -79,7 +87,6 @@ export default class RemoteHandlerAbstract {
|
||||
}
|
||||
}
|
||||
;({ highWaterMark: this._highWaterMark, timeout: this._timeout = DEFAULT_TIMEOUT } = options)
|
||||
this._encryptor = _getEncryptor(this._remote.encryptionKey)
|
||||
|
||||
const sharedLimit = limitConcurrency(options.maxParallelOperations ?? DEFAULT_MAX_PARALLEL_OPERATIONS)
|
||||
this.closeFile = sharedLimit(this.closeFile)
|
||||
@@ -330,44 +337,54 @@ export default class RemoteHandlerAbstract {
|
||||
}
|
||||
|
||||
async _createMetadata() {
|
||||
const encryptionAlgorithm = this._remote.encryptionKey === undefined ? 'none' : DEFAULT_ENCRYPTION_ALGORITHM
|
||||
this.#encryptor = _getEncryptor(encryptionAlgorithm, this._remote.encryptionKey)
|
||||
|
||||
await Promise.all([
|
||||
this._writeFile(
|
||||
normalizePath(ENCRYPTION_DESC_FILENAME),
|
||||
JSON.stringify({ algorithm: this._encryptor.algorithm }),
|
||||
{
|
||||
flags: 'w',
|
||||
}
|
||||
), // not encrypted
|
||||
this._writeFile(normalizePath(ENCRYPTION_DESC_FILENAME), JSON.stringify({ algorithm: encryptionAlgorithm }), {
|
||||
flags: 'w',
|
||||
}), // not encrypted
|
||||
this.writeFile(ENCRYPTION_METADATA_FILENAME, `{"random":"${randomUUID()}"}`, { flags: 'w' }), // encrypted
|
||||
])
|
||||
}
|
||||
|
||||
async _checkMetadata() {
|
||||
let encryptionAlgorithm = 'none'
|
||||
let data
|
||||
try {
|
||||
// this file is not encrypted
|
||||
const data = await this._readFile(normalizePath(ENCRYPTION_DESC_FILENAME))
|
||||
JSON.parse(data)
|
||||
data = await this._readFile(normalizePath(ENCRYPTION_DESC_FILENAME), 'utf-8')
|
||||
const json = JSON.parse(data)
|
||||
encryptionAlgorithm = json.algorithm
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
encryptionAlgorithm = this._remote.encryptionKey === undefined ? 'none' : DEFAULT_ENCRYPTION_ALGORITHM
|
||||
}
|
||||
|
||||
try {
|
||||
this.#encryptor = _getEncryptor(encryptionAlgorithm, this._remote.encryptionKey)
|
||||
// this file is encrypted
|
||||
const data = await this.readFile(ENCRYPTION_METADATA_FILENAME)
|
||||
const data = await this.readFile(ENCRYPTION_METADATA_FILENAME, 'utf-8')
|
||||
JSON.parse(data)
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT' || (await this._canWriteMetadata())) {
|
||||
info('will update metadata of this remote')
|
||||
return this._createMetadata()
|
||||
// can be enoent, bad algorithm, or broeken json ( bad key or algorithm)
|
||||
if (encryptionAlgorithm !== 'none') {
|
||||
if (await this._canWriteMetadata()) {
|
||||
// any other error , but on empty remote => update with remote settings
|
||||
|
||||
info('will update metadata of this remote')
|
||||
return this._createMetadata()
|
||||
} else {
|
||||
warn(
|
||||
`The encryptionKey settings of this remote does not match the key used to create it. You won't be able to read any data from this remote`,
|
||||
{ error }
|
||||
)
|
||||
// will probably send a ERR_OSSL_EVP_BAD_DECRYPT if key is incorrect
|
||||
throw error
|
||||
}
|
||||
}
|
||||
warn(
|
||||
`The encryptionKey settings of this remote does not match the key used to create it. You won't be able to read any data from this remote`,
|
||||
{ error }
|
||||
)
|
||||
// will probably send a ERR_OSSL_EVP_BAD_DECRYPT if key is incorrect
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
import { TimeoutError } from 'promise-toolbox'
|
||||
|
||||
import { DEFAULT_ENCRYPTION_ALGORITHM, _getEncryptor } from './_encryptor'
|
||||
import { Disposable, pFromCallback, TimeoutError } from 'promise-toolbox'
|
||||
import { getSyncedHandler } from '.'
|
||||
import AbstractHandler from './abstract'
|
||||
import fs from 'fs-extra'
|
||||
import rimraf from 'rimraf'
|
||||
import tmp from 'tmp'
|
||||
|
||||
const TIMEOUT = 10e3
|
||||
|
||||
class TestHandler extends AbstractHandler {
|
||||
constructor(impl) {
|
||||
super({ url: 'test://' }, { timeout: TIMEOUT })
|
||||
|
||||
Object.defineProperty(this, 'isEncrypted', {
|
||||
get: () => false, // encryption is tested separatly
|
||||
})
|
||||
Object.keys(impl).forEach(method => {
|
||||
this[`_${method}`] = impl[method]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const noop = Function.prototype
|
||||
|
||||
jest.useFakeTimers()
|
||||
|
||||
describe('closeFile()', () => {
|
||||
@@ -101,3 +109,112 @@ describe('rmdir()', () => {
|
||||
await expect(promise).rejects.toThrowError(TimeoutError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('encryption', () => {
|
||||
let dir
|
||||
beforeEach(async () => {
|
||||
dir = await pFromCallback(cb => tmp.dir(cb))
|
||||
})
|
||||
afterAll(async () => {
|
||||
await pFromCallback(cb => rimraf(dir, cb))
|
||||
})
|
||||
|
||||
it('sync should NOT create metadata if missing (not encrypted)', async () => {
|
||||
await Disposable.use(getSyncedHandler({ url: `file://${dir}` }), noop)
|
||||
|
||||
expect(await fs.readdir(dir)).toEqual([])
|
||||
})
|
||||
|
||||
it('sync should create metadata if missing (encrypted)', async () => {
|
||||
await Disposable.use(
|
||||
getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd00"` }),
|
||||
noop
|
||||
)
|
||||
|
||||
expect(await fs.readdir(dir)).toEqual(['encryption.json', 'metadata.json'])
|
||||
|
||||
const encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
|
||||
expect(encryption.algorithm).toEqual(DEFAULT_ENCRYPTION_ALGORITHM)
|
||||
// encrypted , should not be parsable
|
||||
expect(async () => JSON.parse(await fs.readFile(`${dir}/metadata.json`))).rejects.toThrowError()
|
||||
})
|
||||
|
||||
it('sync should not modify existing metadata', async () => {
|
||||
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "none"}`)
|
||||
await fs.writeFile(`${dir}/metadata.json`, `{"random": "NOTSORANDOM"}`)
|
||||
|
||||
await Disposable.use(await getSyncedHandler({ url: `file://${dir}` }), noop)
|
||||
|
||||
const encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
|
||||
expect(encryption.algorithm).toEqual('none')
|
||||
const metadata = JSON.parse(await fs.readFile(`${dir}/metadata.json`, 'utf-8'))
|
||||
expect(metadata.random).toEqual('NOTSORANDOM')
|
||||
})
|
||||
|
||||
it('should modify metadata if empty', async () => {
|
||||
await Disposable.use(getSyncedHandler({ url: `file://${dir}` }), noop)
|
||||
// nothing created without encryption
|
||||
|
||||
await Disposable.use(
|
||||
getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd00"` }),
|
||||
noop
|
||||
)
|
||||
let encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
|
||||
expect(encryption.algorithm).toEqual(DEFAULT_ENCRYPTION_ALGORITHM)
|
||||
|
||||
await Disposable.use(getSyncedHandler({ url: `file://${dir}` }), noop)
|
||||
encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
|
||||
expect(encryption.algorithm).toEqual('none')
|
||||
})
|
||||
|
||||
it(
|
||||
'sync should work with encrypted',
|
||||
Disposable.wrap(async function* () {
|
||||
const encryptor = _getEncryptor(DEFAULT_ENCRYPTION_ALGORITHM, '73c1838d7d8a6088ca2317fb5f29cd91')
|
||||
|
||||
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "${DEFAULT_ENCRYPTION_ALGORITHM}"}`)
|
||||
await fs.writeFile(`${dir}/metadata.json`, encryptor.encryptData(`{"random": "NOTSORANDOM"}`))
|
||||
|
||||
const handler = yield getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd91"` })
|
||||
|
||||
const encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
|
||||
expect(encryption.algorithm).toEqual(DEFAULT_ENCRYPTION_ALGORITHM)
|
||||
const metadata = JSON.parse(await handler.readFile(`./metadata.json`))
|
||||
expect(metadata.random).toEqual('NOTSORANDOM')
|
||||
})
|
||||
)
|
||||
|
||||
it('sync should fail when changing key on non empty remote ', async () => {
|
||||
const encryptor = _getEncryptor(DEFAULT_ENCRYPTION_ALGORITHM, '73c1838d7d8a6088ca2317fb5f29cd91')
|
||||
|
||||
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "${DEFAULT_ENCRYPTION_ALGORITHM}"}`)
|
||||
await fs.writeFile(`${dir}/metadata.json`, encryptor.encryptData(`{"random": "NOTSORANDOM"}`))
|
||||
|
||||
// different key but empty remote => ok
|
||||
await Disposable.use(
|
||||
getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd00"` }),
|
||||
noop
|
||||
)
|
||||
|
||||
// remote is now non empty : can't modify key anymore
|
||||
await fs.writeFile(`${dir}/nonempty.json`, 'content')
|
||||
await expect(
|
||||
Disposable.use(getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd10"` }), noop)
|
||||
).rejects.toThrowError()
|
||||
})
|
||||
|
||||
it('sync should fail when changing algorithm', async () => {
|
||||
// encrypt with a non default algorithm
|
||||
const encryptor = _getEncryptor('aes-256-cbc', '73c1838d7d8a6088ca2317fb5f29cd91')
|
||||
|
||||
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "aes-256-gmc"}`)
|
||||
await fs.writeFile(`${dir}/metadata.json`, encryptor.encryptData(`{"random": "NOTSORANDOM"}`))
|
||||
|
||||
// remote is now non empty : can't modify key anymore
|
||||
await fs.writeFile(`${dir}/nonempty.json`, 'content')
|
||||
|
||||
await expect(
|
||||
Disposable.use(getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd91"` }), noop)
|
||||
).rejects.toThrowError()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import RemoteHandlerLocal from './local'
|
||||
import RemoteHandlerNfs from './nfs'
|
||||
import RemoteHandlerS3 from './s3'
|
||||
import RemoteHandlerSmb from './smb'
|
||||
export { DEFAULT_ENCRYPTION_ALGORITHM, UNENCRYPTED_ALGORITHM, isLegacyEncryptionAlgorithm } from './_encryptor'
|
||||
|
||||
const HANDLERS = {
|
||||
file: RemoteHandlerLocal,
|
||||
|
||||
1
@xen-orchestra/lite/.env.dist
Normal file
1
@xen-orchestra/lite/.env.dist
Normal file
@@ -0,0 +1 @@
|
||||
VITE_XO_HOST=
|
||||
28
@xen-orchestra/lite/.eslintrc.cjs
Normal file
28
@xen-orchestra/lite/.eslintrc.cjs
Normal file
@@ -0,0 +1,28 @@
|
||||
/* eslint-env node */
|
||||
require("@rushstack/eslint-patch/modern-module-resolution");
|
||||
|
||||
module.exports = {
|
||||
globals: {
|
||||
XO_LITE_GIT_HEAD: true,
|
||||
XO_LITE_VERSION: true,
|
||||
},
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
"plugin:vue/vue3-essential",
|
||||
"eslint:recommended",
|
||||
"@vue/eslint-config-typescript/recommended",
|
||||
"@vue/eslint-config-prettier",
|
||||
],
|
||||
plugins: ["@limegrass/import-alias"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@limegrass/import-alias/import-alias": [
|
||||
"error",
|
||||
{ aliasConfigPath: require("path").join(__dirname, "tsconfig.json") },
|
||||
],
|
||||
},
|
||||
};
|
||||
1
@xen-orchestra/lite/.npmignore
Symbolic link
1
@xen-orchestra/lite/.npmignore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/npmignore
|
||||
15
@xen-orchestra/lite/.prettierrc.cjs
Normal file
15
@xen-orchestra/lite/.prettierrc.cjs
Normal file
@@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
importOrder: [
|
||||
"^[^/]+$",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^@/components/(.*)$",
|
||||
"^@/composables/(.*)$",
|
||||
"^@/libs/(.*)$",
|
||||
"^@/router/(.*)$",
|
||||
"^@/stores/(.*)$",
|
||||
"^@/views/(.*)$",
|
||||
],
|
||||
importOrderSeparation: false,
|
||||
importOrderSortSpecifiers: true,
|
||||
importOrderParserPlugins: ["typescript", "decorators-legacy"],
|
||||
};
|
||||
3
@xen-orchestra/lite/.vscode/extensions.json
vendored
Normal file
3
@xen-orchestra/lite/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
11
@xen-orchestra/lite/CHANGELOG.md
Normal file
11
@xen-orchestra/lite/CHANGELOG.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# ChangeLog
|
||||
|
||||
## **0.2.0**
|
||||
|
||||
- Invalidate sessionId token after logout (PR [#6480](https://github.com/vatesfr/xen-orchestra/pull/6480))
|
||||
- Settings page (PR [#6418](https://github.com/vatesfr/xen-orchestra/pull/6418))
|
||||
- Uncollapse hosts in the tree by default (PR [#6428](https://github.com/vatesfr/xen-orchestra/pull/6428))
|
||||
|
||||
## **0.1.0**
|
||||
|
||||
- Initial implementation
|
||||
247
@xen-orchestra/lite/README.md
Normal file
247
@xen-orchestra/lite/README.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# POC XO Lite
|
||||
|
||||
- Clone
|
||||
- Copy `.env.dist` to `.env` and set vars
|
||||
- `yarn`
|
||||
- `yarn dev`
|
||||
|
||||
## Conventions
|
||||
|
||||
### File names
|
||||
|
||||
| Type | Format | Exemple |
|
||||
| ---------- | ---------------------------------------- | ----------------------------------- |
|
||||
| Component | `components/<PascalCase>.vue` | `components/FooBar.vue` |
|
||||
| View | `views/<PascalCase>View.vue` | `views/FooBarView.vue` |
|
||||
| Composable | `composables/<kebab-case>.composable.ts` | `composables/foo-bar.composable.ts` |
|
||||
| Store | `stores/<kebab-case>.store.ts` | `stores/foo-bar.store.ts` |
|
||||
| Other | `libs/<kebab-case>.ts` | `libs/foo-bar.ts` |
|
||||
|
||||
For components and views, prepend the subdirectories names to the resulting filename.
|
||||
|
||||
Example: `components/foo/bar/FooBarBaz.vue`
|
||||
|
||||
### Vue Components
|
||||
|
||||
Use Vue Single File Components (`*.vue`).
|
||||
|
||||
Insert blocks in the following order: `template`, `script` then `style`.
|
||||
|
||||
#### Template
|
||||
|
||||
Use HTML.
|
||||
|
||||
If your component only has one root element, add the component name as a class.
|
||||
|
||||
```vue
|
||||
<!-- MyComponent.vue -->
|
||||
<template>
|
||||
<div class="my-component">...</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### Script
|
||||
|
||||
Use composition API + TypeScript + `setup` attribute (`<script lang="ts" setup>`).
|
||||
|
||||
Note: When reading Vue official doc, don't forget to set "API Preference" toggle (in the upper left) on "Composition".
|
||||
|
||||
```vue
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
greetings: string;
|
||||
}>();
|
||||
|
||||
const firstName = ref("");
|
||||
const lastName = ref("");
|
||||
|
||||
const fullName = computed(
|
||||
() => `${props.greetings} ${firstName.value} ${lastName.value}`
|
||||
);
|
||||
</script>
|
||||
```
|
||||
|
||||
#### CSS
|
||||
|
||||
Always use `scoped` attribute (`<style scoped>`).
|
||||
|
||||
Nested rules are allowed.
|
||||
|
||||
Vue variables can be interpolated with `v-bind`.
|
||||
|
||||
```vue
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
|
||||
const fontSize = ref("2rem");
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.my-item {
|
||||
.nested {
|
||||
font-size: v-bind(fontSize);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Icons
|
||||
|
||||
This project is using Font Awesome 6 Free.
|
||||
|
||||
Here is how to use an icon in your template.
|
||||
|
||||
Note: `FontAwesomeIcon` is a global component that does not need to be imported.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<FontAwesomeIcon :icon="faDisplay" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
|
||||
</script>
|
||||
```
|
||||
|
||||
#### Font weight <=> Style name
|
||||
|
||||
Here is the equivalent between font weight and style name.
|
||||
|
||||
| Style name | Font weight |
|
||||
| ---------- | ----------- |
|
||||
| Solid | 900 |
|
||||
| Regular | 400 |
|
||||
| Light | 300 |
|
||||
| Thin | 100 |
|
||||
|
||||
### CSS
|
||||
|
||||
Always use `rem` unit (`1rem` = `10px`)
|
||||
|
||||
### Store
|
||||
|
||||
Use Pinia store with setup function.
|
||||
|
||||
State are `ref`
|
||||
|
||||
Getters are `computed`
|
||||
|
||||
Actions/Mutations are simple functions
|
||||
|
||||
#### Naming convention
|
||||
|
||||
For a `foobar` store, create a `store/foobar.store.ts` then use `defineStore('foobar', setupFunc)`
|
||||
|
||||
#### Example
|
||||
|
||||
```typescript
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
export const useFoobarStore = defineStore("foobar", () => {
|
||||
const aStateVar = ref(0);
|
||||
const otherStateVar = ref(0);
|
||||
const aGetter = computed(() => aStateVar.value * 2);
|
||||
const anAction = () => (otherStateVar.value += 10);
|
||||
|
||||
return {
|
||||
aStateVar,
|
||||
otherStateVar,
|
||||
aGetter,
|
||||
anAction,
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
#### Xen Api Collection Stores
|
||||
|
||||
When creating a store for a Xen Api objects collection, use the `createXenApiCollectionStoreContext` helper.
|
||||
|
||||
```typescript
|
||||
export const useConsoleStore = defineStore("console", () =>
|
||||
createXenApiCollectionStoreContext("console")
|
||||
);
|
||||
```
|
||||
|
||||
##### Extending the base context
|
||||
|
||||
Here is how to extend the base context:
|
||||
|
||||
```typescript
|
||||
import { computed } from "vue";
|
||||
|
||||
export const useFoobarStore = defineStore("foobar", () => {
|
||||
const baseContext = createXenApiCollectionStoreContext("foobar");
|
||||
|
||||
const myCustomGetter = computed(() => baseContext.ids.reverse());
|
||||
|
||||
return {
|
||||
...baseContext,
|
||||
myCustomGetter,
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### I18n
|
||||
|
||||
Internationalization of the app is done with [Vue-i18n](https://vue-i18n.intlify.dev/).
|
||||
|
||||
Locale files are located in `src/locales` directory.
|
||||
|
||||
Source of truth is `en-US.json` file.
|
||||
|
||||
To quickly check if there are missing translations in other locale files, open `main.ts` and check the `messages`
|
||||
property of `createI18n()` for TypeScript error.
|
||||
|
||||
#### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"hello": "Hello",
|
||||
"hello_name": "Hello {name}",
|
||||
"hello_linked": "@:hello_name how are you?",
|
||||
"hello_plural": "No hello | Hello to you | Hello to {count} persons"
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- String -->
|
||||
|
||||
<p>{{ $t("hello") }}</p>
|
||||
<!-- Hello -->
|
||||
<p>{{ $t("hello_name", { name: "World" }) }}</p>
|
||||
<!-- Hello World -->
|
||||
<p>{{ $t("hello_linked", { name: "World" }) }}</p>
|
||||
<!-- Hello World how are you? -->
|
||||
<p>{{ $tc("hello_plural", 0) }}</p>
|
||||
<!-- No hello -->
|
||||
<p>{{ $tc("hello_plural", 1) }}</p>
|
||||
<!-- Hello to you -->
|
||||
<p>{{ $tc("hello_plural", 4) }}</p>
|
||||
<!-- Hello to 4 persons -->
|
||||
|
||||
<!-- Date and time -->
|
||||
|
||||
<p>{{ $d(date, "date_short") }}</p>
|
||||
<!-- 9/10/2022 -->
|
||||
<p>{{ $d(date, "date_medium") }}</p>
|
||||
<!-- Sep 10, 2022 -->
|
||||
<p>{{ $d(date, "date_long") }}</p>
|
||||
<!-- September 10, 2022 -->
|
||||
<p>{{ $d(date, "datetime_short") }}</p>
|
||||
<!-- 9/10/2022, 06:30 PM -->
|
||||
<p>{{ $d(date, "datetime_medium") }}</p>
|
||||
<!-- Sep 10, 2022, 06:30 PM -->
|
||||
<p>{{ $d(date, "datetime_long") }}</p>
|
||||
<!-- September 10, 2022 at 06:30 PM -->
|
||||
<p>{{ $d(date, "time") }}</p>
|
||||
<!-- 06:30 PM -->
|
||||
|
||||
<!-- Number -->
|
||||
|
||||
<p>{{ $n(1234567.898765) }}</p>
|
||||
<!-- 1,234,567.899 -->
|
||||
```
|
||||
5
@xen-orchestra/lite/env.d.ts
vendored
Normal file
5
@xen-orchestra/lite/env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="json-rpc-2.0/dist" />
|
||||
|
||||
declare const XO_LITE_VERSION: string;
|
||||
declare const XO_LITE_GIT_HEAD: string;
|
||||
13
@xen-orchestra/lite/index.html
Normal file
13
@xen-orchestra/lite/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
72
@xen-orchestra/lite/package.json
Normal file
72
@xen-orchestra/lite/package.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "@xen-orchestra/lite",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
|
||||
"build": "run-p type-check build-only",
|
||||
"preview": "vite preview --port 4173",
|
||||
"build-only": "GIT_HEAD=$(git rev-parse HEAD) vite build",
|
||||
"deploy": "./scripts/deploy.sh",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.2.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.2.0",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.1",
|
||||
"@novnc/novnc": "^1.3.0",
|
||||
"@types/d3-time-format": "^4.0.0",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@vueuse/core": "^8.7.5",
|
||||
"complex-matcher": "^0.7.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
"echarts": "^5.3.3",
|
||||
"human-format": "^1.0.0",
|
||||
"json-rpc-2.0": "^1.3.0",
|
||||
"json5": "^2.2.1",
|
||||
"limit-concurrency-decorator": "^0.5.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"make-error": "^1.3.6",
|
||||
"pinia": "^2.0.14",
|
||||
"placement.js": "^1.0.0-beta.5",
|
||||
"vue": "^3.2.37",
|
||||
"vue-echarts": "^6.2.3",
|
||||
"vue-i18n": "9",
|
||||
"vue-router": "^4.0.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@intlify/vite-plugin-vue-i18n": "^6.0.1",
|
||||
"@limegrass/eslint-plugin-import-alias": "^1.0.5",
|
||||
"@rushstack/eslint-patch": "^1.1.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^3.2.0",
|
||||
"@types/node": "^16.11.41",
|
||||
"@vitejs/plugin-vue": "^2.3.3",
|
||||
"@vue/eslint-config-prettier": "^7.0.0",
|
||||
"@vue/eslint-config-typescript": "^11.0.0",
|
||||
"@vue/tsconfig": "^0.1.3",
|
||||
"eslint-plugin-vue": "^9.0.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss-nested": "^5.0.6",
|
||||
"typescript": "~4.7.4",
|
||||
"vite": "^2.9.12",
|
||||
"vue-tsc": "^0.38.1"
|
||||
},
|
||||
"private": true,
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/lite",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@xen-orchestra/lite",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
}
|
||||
}
|
||||
5
@xen-orchestra/lite/postcss.config.js
Normal file
5
@xen-orchestra/lite/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"postcss-nested": {},
|
||||
},
|
||||
};
|
||||
BIN
@xen-orchestra/lite/public/favicon.ico
Normal file
BIN
@xen-orchestra/lite/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
67
@xen-orchestra/lite/scripts/deploy.sh
Executable file
67
@xen-orchestra/lite/scripts/deploy.sh
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -ne 1 ]
|
||||
then
|
||||
echo "Usage: ./deploy.sh <LDAP username>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
USERNAME=$1
|
||||
DIST="dist"
|
||||
BASE="https://lite.xen-orchestra.com/dist"
|
||||
SERVER="www-xo.gpn.vates.fr"
|
||||
|
||||
echo "Building XO Lite"
|
||||
|
||||
(cd ../.. && yarn)
|
||||
yarn build-only --base="$BASE"
|
||||
|
||||
echo "Deploying XO Lite from $DIST"
|
||||
|
||||
echo "\"use strict\";
|
||||
(function () {
|
||||
const d = document;
|
||||
|
||||
function js(file) {
|
||||
const s = d.createElement(\"script\");
|
||||
s.defer = \"defer\";
|
||||
s.type = \"module\";
|
||||
s.crossOrigin = \"anonymous\";
|
||||
s.src = file;
|
||||
d.body.appendChild(s);
|
||||
}
|
||||
$(
|
||||
for filename in "$DIST"/assets/*.js; do
|
||||
echo " js(\"$BASE/assets/$(basename $filename)\");"
|
||||
done
|
||||
)
|
||||
|
||||
function css(file) {
|
||||
const s = d.createElement(\"link\");
|
||||
s.rel = \"stylesheet\";
|
||||
s.href = file;
|
||||
d.head.appendChild(s);
|
||||
}
|
||||
$(
|
||||
for filename in "$DIST"/assets/*.css; do
|
||||
echo " css(\"$BASE/assets/$(basename $filename)\");"
|
||||
done
|
||||
)
|
||||
})();" > "$DIST/index.js"
|
||||
|
||||
rsync \
|
||||
-r --delete --delete-excluded --exclude=index.html \
|
||||
"$DIST"/ \
|
||||
"$USERNAME@$SERVER:xo-lite"
|
||||
|
||||
echo "XO Lite files sent to server"
|
||||
|
||||
echo "→ Connect to the server using:"
|
||||
echo -e "\tssh $USERNAME@$SERVER"
|
||||
|
||||
echo "→ Log in as xo-lite using"
|
||||
echo -e "\tsudo -su xo-lite"
|
||||
|
||||
echo "→ Then run the following command to move the files to the \`latest\` folder:"
|
||||
echo -e "\trsync -r --delete --exclude=index.html /home/$USERNAME/xo-lite/ /home/xo-lite/public/latest"
|
||||
111
@xen-orchestra/lite/src/App.vue
Normal file
111
@xen-orchestra/lite/src/App.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<UiModal
|
||||
v-if="isSslModalOpen"
|
||||
color="error"
|
||||
:icon="faServer"
|
||||
@close="clearUnreachableHostsUrls"
|
||||
>
|
||||
<template #title>{{ $t("unreachable-hosts") }}</template>
|
||||
<template #subtitle>{{ $t("following-hosts-unreachable") }}</template>
|
||||
<p>{{ $t("allow-self-signed-ssl") }}</p>
|
||||
<ul>
|
||||
<li v-for="url in unreachableHostsUrls" :key="url.hostname">
|
||||
<a :href="url.href" target="_blank" rel="noopener">{{ url.href }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</UiModal>
|
||||
<div v-if="!xenApiStore.isConnected">
|
||||
<AppLogin />
|
||||
</div>
|
||||
<div v-else>
|
||||
<AppHeader />
|
||||
<div style="display: flex">
|
||||
<nav class="nav">
|
||||
<InfraPoolList />
|
||||
</nav>
|
||||
<main class="main">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
<AppTooltips />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { difference } from "lodash";
|
||||
import { computed, ref, watch, watchEffect } from "vue";
|
||||
import favicon from "@/assets/favicon.svg";
|
||||
import { faServer } from "@fortawesome/free-solid-svg-icons";
|
||||
import AppHeader from "@/components/AppHeader.vue";
|
||||
import AppLogin from "@/components/AppLogin.vue";
|
||||
import AppTooltips from "@/components/AppTooltips.vue";
|
||||
import InfraPoolList from "@/components/infra/InfraPoolList.vue";
|
||||
import UiModal from "@/components/ui/UiModal.vue";
|
||||
import { useChartTheme } from "@/composables/chart-theme.composable";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
|
||||
const unreachableHostsUrls = ref<URL[]>([]);
|
||||
const clearUnreachableHostsUrls = () => (unreachableHostsUrls.value = []);
|
||||
|
||||
let link: HTMLLinkElement | null = document.querySelector("link[rel~='icon']");
|
||||
if (link == null) {
|
||||
link = document.createElement("link");
|
||||
link.rel = "icon";
|
||||
document.getElementsByTagName("head")[0].appendChild(link);
|
||||
}
|
||||
link.href = favicon;
|
||||
|
||||
document.title = "XO Lite";
|
||||
|
||||
if (window.localStorage?.getItem("colorMode") !== "light") {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
|
||||
const xenApiStore = useXenApiStore();
|
||||
const hostStore = useHostStore();
|
||||
useChartTheme();
|
||||
|
||||
watchEffect(() => {
|
||||
if (xenApiStore.isConnected) {
|
||||
xenApiStore.init();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => hostStore.allRecords,
|
||||
(hosts, previousHosts) => {
|
||||
difference(hosts, previousHosts).forEach((host) => {
|
||||
const url = new URL("http://localhost");
|
||||
url.protocol = window.location.protocol;
|
||||
url.hostname = host.address;
|
||||
fetch(url, { mode: "no-cors" }).catch(() =>
|
||||
unreachableHostsUrls.value.push(url)
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const isSslModalOpen = computed(() => unreachableHostsUrls.value.length > 0);
|
||||
</script>
|
||||
|
||||
<style lang="postcss">
|
||||
@import "@/assets/base.css";
|
||||
|
||||
.nav {
|
||||
overflow: auto;
|
||||
width: 37rem;
|
||||
max-width: 37rem;
|
||||
height: calc(100vh - 9rem);
|
||||
padding: 0.5rem;
|
||||
border-right: 1px solid var(--color-blue-scale-400);
|
||||
background-color: var(--background-color-primary);
|
||||
}
|
||||
|
||||
.main {
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
height: calc(100vh - 9rem);
|
||||
background-color: var(--background-color-secondary);
|
||||
}
|
||||
</style>
|
||||
27
@xen-orchestra/lite/src/assets/base.css
Normal file
27
@xen-orchestra/lite/src/assets/base.css
Normal file
@@ -0,0 +1,27 @@
|
||||
@import "reset.css";
|
||||
@import "theme.css";
|
||||
/* TODO Serve fonts locally */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;0,900;1,400;1,500;1,600;1,700;1,900&display=swap");
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
font-size: 1.3rem;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: var(--color-blue-scale-100);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-extra-blue-base);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.card-view {
|
||||
padding: 1.2rem;
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
1
@xen-orchestra/lite/src/assets/favicon.svg
Normal file
1
@xen-orchestra/lite/src/assets/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 12 KiB |
1
@xen-orchestra/lite/src/assets/logo-title.svg
Normal file
1
@xen-orchestra/lite/src/assets/logo-title.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 39 KiB |
1
@xen-orchestra/lite/src/assets/logo.svg
Normal file
1
@xen-orchestra/lite/src/assets/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 12 KiB |
28
@xen-orchestra/lite/src/assets/reset.css
Normal file
28
@xen-orchestra/lite/src/assets/reset.css
Normal file
@@ -0,0 +1,28 @@
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: inherit;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
font-family: Poppins, sans-serif;
|
||||
}
|
||||
|
||||
body, h1, h2, h3, h4, h5, h6, p, ol, ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
ol, ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
75
@xen-orchestra/lite/src/assets/theme.css
Normal file
75
@xen-orchestra/lite/src/assets/theme.css
Normal file
@@ -0,0 +1,75 @@
|
||||
:root {
|
||||
--color-blue-scale-000: #000000;
|
||||
--color-blue-scale-100: #1A1B38;
|
||||
--color-blue-scale-200: #595A6F;
|
||||
--color-blue-scale-300: #9899A5;
|
||||
--color-blue-scale-400: #E5E5E7;
|
||||
--color-blue-scale-500: #FFFFFF;
|
||||
|
||||
--color-extra-blue-l60: #D1CEFB;
|
||||
--color-extra-blue-l40: #BBB5F9;
|
||||
--color-extra-blue-l20: #A39DF8;
|
||||
--color-extra-blue-base: #8F84FF;
|
||||
--color-extra-blue-d20: #716AC6;
|
||||
--color-extra-blue-d40: #554F94;
|
||||
--color-extra-blue-d60: #383563;
|
||||
|
||||
--color-green-infra-l60: #B5DBCA;
|
||||
--color-green-infra-l40: #91C9B0;
|
||||
--color-green-infra-l20: #70B795;
|
||||
--color-green-infra-base: #55A57B;
|
||||
--color-green-infra-d20: #438463;
|
||||
--color-green-infra-d40: #32634A;
|
||||
--color-green-infra-d60: #214231;
|
||||
|
||||
--color-orange-world-l60: #F2CDA8;
|
||||
--color-orange-world-l40: #EBB57D;
|
||||
--color-orange-world-l20: #E59D56;
|
||||
--color-orange-world-base: #EF7F18;
|
||||
--color-orange-world-d20: #BF6612;
|
||||
--color-orange-world-d40: #864F1F;
|
||||
--color-orange-world-d60: #5A3514;
|
||||
|
||||
--color-red-vates-l60: #DDA5A7;
|
||||
--color-red-vates-l40: #CE787C;
|
||||
--color-red-vates-l20: #BF4F51;
|
||||
--color-red-vates-base: #BE1621;
|
||||
--color-red-vates-d20: #8E2221;
|
||||
--color-red-vates-d40: #6A1919;
|
||||
--color-red-vates-d60: #471010;
|
||||
|
||||
--color-grayscale-200: #585757;
|
||||
|
||||
--background-color-primary: #FFFFFF;
|
||||
--background-color-secondary: #F6F6F7;
|
||||
--background-color-extra-blue: #F4F3FE;
|
||||
--background-color-green-infra: #ECF5F2;
|
||||
--background-color-orange-world: #FBF2E9;
|
||||
--background-color-red-vates: #F5E8E9;
|
||||
|
||||
--shadow-100: 0 0.1rem 0.1rem rgba(20, 20, 30, 0.06);
|
||||
--shadow-200: 0 0.1rem 0.3rem rgba(20, 20, 30, 0.1), 0 0.2rem 0.1rem rgba(20, 20, 30, 0.06), 0 0.1rem 0.1rem rgba(20, 20, 30, 0.08);
|
||||
--shadow-300: 0 0.3rem 0.5rem rgba(20, 20, 30, 0.1), 0 0.1rem 1.8rem rgba(20, 20, 30, 0.06), 0 0.6rem 1.0rem rgba(20, 20, 30, 0.08);
|
||||
--shadow-400: 0 1.1rem 1.5rem rgba(20, 20, 30, 0.1), 0 0.9rem 4.6rem rgba(20, 20, 30, 0.06), 0 2.4rem 3.8rem rgba(20, 20, 30, 0.04);
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--color-blue-scale-000: #FFFFFF;
|
||||
--color-blue-scale-100: #E5E5E7;
|
||||
--color-blue-scale-200: #9899A5;
|
||||
--color-blue-scale-300: #595A6F;
|
||||
--color-blue-scale-400: #1A1B38;
|
||||
--color-blue-scale-500: #000000;
|
||||
|
||||
--background-color-primary: #14141D;
|
||||
--background-color-secondary: #17182A;
|
||||
--background-color-extra-blue: #35335D;
|
||||
--background-color-green-infra: #243B3D;
|
||||
--background-color-orange-world: #493328;
|
||||
--background-color-red-vates: #3C1A28;
|
||||
|
||||
--shadow-100: 0 0.1rem 0.1rem rgba(20, 20, 30, 0.12);
|
||||
--shadow-200: 0 0.1rem 0.3rem rgba(20, 20, 30, 0.2), 0 0.2rem 0.1rem rgba(20, 20, 30, 0.12), 0 0.1rem 0.1rem rgba(20, 20, 30, 0.16);
|
||||
--shadow-300: 0 0.3rem 0.5rem rgba(20, 20, 30, 0.2), 0 0.1rem 1.8rem rgba(20, 20, 30, 0.12), 0 0.6rem 1.0rem rgba(20, 20, 30, 0.16);
|
||||
--shadow-400: 0 1.1rem 1.5rem rgba(20, 20, 30, 0.2), 0 0.9rem 4.6rem rgba(20, 20, 30, 0.12), 0 2.4rem 3.8rem rgba(20, 20, 30, 0.08);
|
||||
}
|
||||
101
@xen-orchestra/lite/src/components/AccountButton.vue
Normal file
101
@xen-orchestra/lite/src/components/AccountButton.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<AppMenu placement="bottom-end" shadow>
|
||||
<template #trigger="{ open, isOpen }">
|
||||
<button :class="{ active: isOpen }" class="account-button" @click="open">
|
||||
<UiIcon :icon="faCircleUser" class="user-icon" />
|
||||
<UiIcon :icon="faAngleDown" class="dropdown-icon" />
|
||||
</button>
|
||||
</template>
|
||||
<MenuItem :icon="faGear" @click="openSettings">{{
|
||||
$t("settings")
|
||||
}}</MenuItem>
|
||||
<MenuItem :icon="faMessage" @click="openFeedbackUrl">
|
||||
{{ $t("send-us-feedback") }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:icon="faArrowRightFromBracket"
|
||||
class="menu-item-logout"
|
||||
@click="logout"
|
||||
>
|
||||
{{ $t("log-out") }}
|
||||
</MenuItem>
|
||||
</AppMenu>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
faAngleDown,
|
||||
faArrowRightFromBracket,
|
||||
faCircleUser,
|
||||
faGear,
|
||||
faMessage,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import AppMenu from "@/components/menu/AppMenu.vue";
|
||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||
import UiIcon from "@/components/ui/UiIcon.vue";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const logout = () => {
|
||||
const xenApiStore = useXenApiStore();
|
||||
xenApiStore.disconnect();
|
||||
nextTick(() => router.push({ name: "home" }));
|
||||
};
|
||||
|
||||
const openFeedbackUrl = () => {
|
||||
window.open(
|
||||
"https://xcp-ng.org/forum/topic/4731/xen-orchestra-lite",
|
||||
"_blank",
|
||||
"noopener"
|
||||
);
|
||||
};
|
||||
|
||||
const openSettings = () => router.push({ name: "settings" });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.account-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
color: var(--color-blue-scale-100);
|
||||
border: none;
|
||||
border-radius: 0.8rem;
|
||||
background-color: var(--background-color-secondary);
|
||||
gap: 0.8rem;
|
||||
|
||||
&:disabled {
|
||||
color: var(--color-blue-scale-400);
|
||||
}
|
||||
|
||||
&:not(:disabled) {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
background-color: var(--background-color-primary);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&.active {
|
||||
color: var(--color-extra-blue-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-icon {
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.menu-item-logout {
|
||||
color: var(--color-red-vates-base);
|
||||
}
|
||||
</style>
|
||||
36
@xen-orchestra/lite/src/components/AppHeader.vue
Normal file
36
@xen-orchestra/lite/src/components/AppHeader.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<RouterLink :to="{ name: 'home' }">
|
||||
<img alt="XO Lite" src="../assets/logo.svg" />
|
||||
</RouterLink>
|
||||
<slot />
|
||||
<div class="right">
|
||||
<AccountButton />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import AccountButton from '@/components/AccountButton.vue'
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 8rem;
|
||||
padding: 1rem;
|
||||
border-bottom: 0.1rem solid var(--color-blue-scale-400);
|
||||
background-color: var(--background-color-secondary);
|
||||
|
||||
img {
|
||||
width: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
94
@xen-orchestra/lite/src/components/AppLogin.vue
Normal file
94
@xen-orchestra/lite/src/components/AppLogin.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="app-login form-container">
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<img alt="XO Lite" src="../assets/logo-title.svg" />
|
||||
<input v-model="login" name="login" readonly type="text" />
|
||||
<input
|
||||
v-model="password"
|
||||
:readonly="isConnecting"
|
||||
name="password"
|
||||
:placeholder="$t('password')"
|
||||
type="password"
|
||||
/>
|
||||
<UiButton :busy="isConnecting" type="submit">
|
||||
{{ $t("login") }}
|
||||
</UiButton>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from "pinia";
|
||||
import { onMounted, ref } from "vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
|
||||
const xenApiStore = useXenApiStore();
|
||||
const { isConnecting } = storeToRefs(xenApiStore);
|
||||
const login = ref("root");
|
||||
const password = ref("");
|
||||
|
||||
onMounted(() => {
|
||||
xenApiStore.reconnect();
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
await xenApiStore.connect(login.value, password.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.form-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
max-width: 100vw;
|
||||
background-color: var(--background-color-primary);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
min-width: 30em;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
padding: 8.5rem;
|
||||
background-color: var(--background-color-secondary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 4.8rem;
|
||||
font-weight: 900;
|
||||
line-height: 7.2rem;
|
||||
margin-bottom: 4.2rem;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 40rem;
|
||||
margin-bottom: 5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 120%;
|
||||
font-weight: bold;
|
||||
margin: 1.5rem 0 0.5rem 0;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 45rem;
|
||||
max-width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border: 1px solid var(--color-blue-scale-400);
|
||||
border-radius: 0.8rem;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
</style>
|
||||
167
@xen-orchestra/lite/src/components/AppTooltip.vue
Normal file
167
@xen-orchestra/lite/src/components/AppTooltip.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div v-if="!isDisabled" ref="tooltipElement" class="app-tooltip">
|
||||
<span class="triangle" />
|
||||
<span class="label">{{ content }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { isEmpty, isFunction, isString } from "lodash-es";
|
||||
import place from "placement.js";
|
||||
import { computed, ref, watchEffect } from "vue";
|
||||
import type { TooltipOptions } from "@/stores/tooltip.store";
|
||||
|
||||
const props = defineProps<{
|
||||
target: HTMLElement;
|
||||
options: TooltipOptions;
|
||||
}>();
|
||||
|
||||
const tooltipElement = ref<HTMLElement>();
|
||||
|
||||
const content = computed(() =>
|
||||
isString(props.options) ? props.options : props.options.content
|
||||
);
|
||||
|
||||
const isDisabled = computed(() => {
|
||||
if (isEmpty(content.value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isString(props.options)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isFunction(props.options.disabled)) {
|
||||
return props.options.disabled(props.target);
|
||||
}
|
||||
|
||||
return props.options.disabled ?? false;
|
||||
});
|
||||
|
||||
const placement = computed(() =>
|
||||
isString(props.options) ? "top" : props.options.placement ?? "top"
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
if (tooltipElement.value) {
|
||||
place(props.target, tooltipElement.value, {
|
||||
placement: placement.value,
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.app-tooltip {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 400;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
padding: 0.3125em 0.5em;
|
||||
pointer-events: none;
|
||||
color: var(--color-blue-scale-500);
|
||||
border-radius: 0.5em;
|
||||
background-color: var(--color-blue-scale-100);
|
||||
}
|
||||
|
||||
.triangle {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
width: 1.875em;
|
||||
height: 1.875em;
|
||||
}
|
||||
|
||||
[data-placement^="top"] {
|
||||
margin-bottom: 0.625em;
|
||||
|
||||
.triangle {
|
||||
bottom: -1.75em;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-placement^="right"] {
|
||||
margin-left: 0.625em;
|
||||
|
||||
.triangle {
|
||||
left: -1.75em;
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-placement^="bottom"] {
|
||||
margin-top: 0.625em;
|
||||
|
||||
.triangle {
|
||||
top: -1.75em;
|
||||
}
|
||||
}
|
||||
|
||||
[data-placement^="left"] {
|
||||
margin-right: 0.625em;
|
||||
|
||||
.triangle {
|
||||
right: -1.75em;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-placement="top-start"] .triangle {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
[data-placement="top-center"] .triangle {
|
||||
left: 50%;
|
||||
margin-left: -0.9375em;
|
||||
}
|
||||
|
||||
[data-placement="top-end"] .triangle {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
[data-placement="left-start"] .triangle {
|
||||
top: -0.25em;
|
||||
}
|
||||
|
||||
[data-placement="left-center"] .triangle {
|
||||
top: 50%;
|
||||
margin-top: -0.9375em;
|
||||
}
|
||||
|
||||
[data-placement="left-end"] .triangle {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
[data-placement="right-start"] .triangle {
|
||||
top: -0.25em;
|
||||
}
|
||||
|
||||
[data-placement="right-center"] .triangle {
|
||||
top: 50%;
|
||||
margin-top: -0.9375em;
|
||||
}
|
||||
|
||||
[data-placement="right-end"] .triangle {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
[data-placement="bottom-center"] .triangle {
|
||||
left: 50%;
|
||||
margin-left: -0.9375em;
|
||||
}
|
||||
|
||||
[data-placement="bottom-end"] .triangle {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.triangle::after {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-top: 1.875em;
|
||||
content: "";
|
||||
transform: rotate(45deg) skew(20deg, 20deg);
|
||||
border-radius: 0.3125em;
|
||||
background-color: var(--color-blue-scale-100);
|
||||
}
|
||||
</style>
|
||||
19
@xen-orchestra/lite/src/components/AppTooltips.vue
Normal file
19
@xen-orchestra/lite/src/components/AppTooltips.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<AppTooltip
|
||||
v-for="tooltip in tooltips"
|
||||
:key="tooltip.key"
|
||||
:options="tooltip.options"
|
||||
:target="tooltip.target"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from "pinia";
|
||||
import AppTooltip from "@/components/AppTooltip.vue";
|
||||
import { useTooltipStore } from "@/stores/tooltip.store";
|
||||
|
||||
const tooltipStore = useTooltipStore();
|
||||
const { tooltips } = storeToRefs(tooltipStore);
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
188
@xen-orchestra/lite/src/components/CollectionFilter.vue
Normal file
188
@xen-orchestra/lite/src/components/CollectionFilter.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<UiFilterGroup>
|
||||
<UiFilter
|
||||
v-for="filter in activeFilters"
|
||||
:key="filter"
|
||||
@edit="editFilter(filter)"
|
||||
@remove="emit('removeFilter', filter)"
|
||||
>
|
||||
{{ filter }}
|
||||
</UiFilter>
|
||||
|
||||
<UiActionButton :icon="faPlus" class="add-filter" @click="open">
|
||||
{{ $t("add-filter") }}
|
||||
</UiActionButton>
|
||||
</UiFilterGroup>
|
||||
|
||||
<UiModal v-if="isOpen" :icon="faFilter" @submit.prevent="handleSubmit">
|
||||
<div class="rows">
|
||||
<CollectionFilterRow
|
||||
v-for="(newFilter, index) in newFilters"
|
||||
:key="newFilter.id"
|
||||
v-model="newFilters[index]"
|
||||
:available-filters="availableFilters"
|
||||
@remove="removeNewFilter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="newFilters.some((filter) => filter.isAdvanced)"
|
||||
class="available-properties"
|
||||
>
|
||||
{{ $t("available-properties-for-advanced-filter") }}
|
||||
<div class="properties">
|
||||
<UiBadge
|
||||
v-for="(filter, property) in availableFilters"
|
||||
:key="property"
|
||||
:icon="getFilterIcon(filter)"
|
||||
>
|
||||
{{ property }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #buttons>
|
||||
<UiButton transparent @click="addNewFilter">
|
||||
{{ $t("add-or") }}
|
||||
</UiButton>
|
||||
<UiButton :disabled="!isFilterValid" type="submit">
|
||||
{{ $t(editedFilter ? "update" : "add") }}
|
||||
</UiButton>
|
||||
<UiButton outlined @click="handleCancel">
|
||||
{{ $t("cancel") }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Or, parse } from "complex-matcher";
|
||||
import { computed, ref } from "vue";
|
||||
import type { Filters, NewFilter } from "@/types/filter";
|
||||
import { faFilter, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import CollectionFilterRow from "@/components/CollectionFilterRow.vue";
|
||||
import UiActionButton from "@/components/ui/UiActionButton.vue";
|
||||
import UiBadge from "@/components/ui/UiBadge.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiFilter from "@/components/ui/UiFilter.vue";
|
||||
import UiFilterGroup from "@/components/ui/UiFilterGroup.vue";
|
||||
import UiModal from "@/components/ui/UiModal.vue";
|
||||
import useModal from "@/composables/modal.composable";
|
||||
import { getFilterIcon } from "@/libs/utils";
|
||||
|
||||
defineProps<{
|
||||
activeFilters: string[];
|
||||
availableFilters: Filters;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "addFilter", filter: string): void;
|
||||
(event: "removeFilter", filter: string): void;
|
||||
}>();
|
||||
|
||||
const { isOpen, open, close } = useModal();
|
||||
const newFilters = ref<NewFilter[]>([]);
|
||||
let newFilterId = 0;
|
||||
|
||||
const addNewFilter = () =>
|
||||
newFilters.value.push({
|
||||
id: newFilterId++,
|
||||
content: "",
|
||||
isAdvanced: false,
|
||||
builder: { property: "", comparison: "", value: "", negate: false },
|
||||
});
|
||||
|
||||
const removeNewFilter = (id: number) => {
|
||||
const index = newFilters.value.findIndex((newFilter) => newFilter.id === id);
|
||||
if (index >= 0) {
|
||||
newFilters.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
addNewFilter();
|
||||
|
||||
const generatedFilter = computed(() => {
|
||||
const filters = newFilters.value.filter(
|
||||
(newFilter) => newFilter.content !== ""
|
||||
);
|
||||
|
||||
if (filters.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (filters.length === 1) {
|
||||
return filters[0].content;
|
||||
}
|
||||
|
||||
return `|(${filters.map((filter) => filter.content).join(" ")})`;
|
||||
});
|
||||
|
||||
const isFilterValid = computed(() => generatedFilter.value !== "");
|
||||
|
||||
const editedFilter = ref();
|
||||
|
||||
const editFilter = (filter: string) => {
|
||||
const parsedFilter = parse(filter);
|
||||
|
||||
const nodes =
|
||||
parsedFilter instanceof Or ? parsedFilter.children : [parsedFilter];
|
||||
|
||||
newFilters.value = nodes.map((node) => ({
|
||||
id: newFilterId++,
|
||||
content: node.toString(),
|
||||
isAdvanced: true,
|
||||
builder: { property: "", comparison: "", value: "", negate: false },
|
||||
}));
|
||||
editedFilter.value = filter;
|
||||
open();
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
editedFilter.value = "";
|
||||
newFilters.value = [];
|
||||
addNewFilter();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (editedFilter.value) {
|
||||
emit("removeFilter", editedFilter.value);
|
||||
}
|
||||
emit("addFilter", generatedFilter.value);
|
||||
reset();
|
||||
close();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
reset();
|
||||
close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.properties {
|
||||
font-size: 1.6rem;
|
||||
margin-top: 1rem;
|
||||
|
||||
ul {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
li {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.available-properties {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.properties {
|
||||
display: flex;
|
||||
margin-top: 0.6rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
256
@xen-orchestra/lite/src/components/CollectionFilterRow.vue
Normal file
256
@xen-orchestra/lite/src/components/CollectionFilterRow.vue
Normal file
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div class="collection-filter-row">
|
||||
<span class="or">{{ $t("or") }}</span>
|
||||
<FormWidget v-if="newFilter.isAdvanced" class="form-widget-advanced">
|
||||
<input v-model="newFilter.content" />
|
||||
</FormWidget>
|
||||
<template v-else>
|
||||
<FormWidget :before="currentFilterIcon">
|
||||
<select v-model="newFilter.builder.property">
|
||||
<option v-if="!newFilter.builder.property" value="">
|
||||
- {{ $t("property") }} -
|
||||
</option>
|
||||
<option
|
||||
v-for="(filter, property) in availableFilters"
|
||||
:key="property"
|
||||
:value="property"
|
||||
>
|
||||
{{ filter.label ?? property }}
|
||||
</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
<template v-if="hasComparisonSelect">
|
||||
<FormWidget v-if="currentFilter?.type === 'string'">
|
||||
<select v-model="newFilter.builder.negate">
|
||||
<option :value="false">does</option>
|
||||
<option :value="true">does not</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
<FormWidget v-if="hasComparisonSelect">
|
||||
<select v-model="newFilter.builder.comparison">
|
||||
<option
|
||||
v-for="(label, type) in comparisons"
|
||||
:key="type"
|
||||
:value="type"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
</template>
|
||||
<FormWidget
|
||||
v-if="hasValueInput"
|
||||
:after="valueInputAfter"
|
||||
:before="valueInputBefore"
|
||||
>
|
||||
<input v-model="newFilter.builder.value" />
|
||||
</FormWidget>
|
||||
<template v-else-if="currentFilter?.type === 'enum'">
|
||||
<FormWidget>
|
||||
<select v-model="newFilter.builder.negate">
|
||||
<option :value="false">is</option>
|
||||
<option :value="true">is not</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
<FormWidget>
|
||||
<select v-model="newFilter.builder.value">
|
||||
<option v-if="!newFilter.builder.value" value="" />
|
||||
<option v-for="choice in enumChoices" :key="choice" :value="choice">
|
||||
{{ choice }}
|
||||
</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
</template>
|
||||
</template>
|
||||
<UiActionButton
|
||||
v-if="!newFilter.isAdvanced"
|
||||
@click="enableAdvancedMode"
|
||||
:icon="faPencil"
|
||||
/>
|
||||
<UiActionButton @click="emit('remove', newFilter.id)" :icon="faRemove" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch } from "vue";
|
||||
import type {
|
||||
Filter,
|
||||
FilterComparisonType,
|
||||
FilterComparisons,
|
||||
FilterType,
|
||||
Filters,
|
||||
NewFilter,
|
||||
} from "@/types/filter";
|
||||
import { faPencil, faRemove } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useVModel } from "@vueuse/core";
|
||||
import FormWidget from "@/components/FormWidget.vue";
|
||||
import UiActionButton from "@/components/ui/UiActionButton.vue";
|
||||
import { buildComplexMatcherNode } from "@/libs/complex-matcher.utils";
|
||||
import { getFilterIcon } from "@/libs/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
availableFilters: Filters;
|
||||
modelValue: NewFilter;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: NewFilter): void;
|
||||
(event: "remove", filterId: number): void;
|
||||
}>();
|
||||
|
||||
const newFilter = useVModel(props, "modelValue", emit);
|
||||
|
||||
const getDefaultComparisonType = () => {
|
||||
const defaultTypes: { [key in FilterType]: FilterComparisonType } = {
|
||||
string: "stringContains",
|
||||
boolean: "booleanTrue",
|
||||
number: "numberEquals",
|
||||
enum: "stringEquals",
|
||||
};
|
||||
|
||||
return defaultTypes[
|
||||
props.availableFilters[newFilter.value.builder.property].type
|
||||
];
|
||||
};
|
||||
|
||||
watch(
|
||||
() => newFilter.value.builder.property,
|
||||
() => {
|
||||
newFilter.value.builder.comparison = getDefaultComparisonType();
|
||||
newFilter.value.builder.value = "";
|
||||
newFilter.value.builder.negate = false;
|
||||
}
|
||||
);
|
||||
|
||||
const currentFilter = computed<Filter>(
|
||||
() => props.availableFilters[newFilter.value.builder.property]
|
||||
);
|
||||
|
||||
const currentFilterIcon = computed(() => getFilterIcon(currentFilter.value));
|
||||
|
||||
const hasValueInput = computed(() =>
|
||||
["string", "number"].includes(currentFilter.value?.type)
|
||||
);
|
||||
|
||||
const hasComparisonSelect = computed(
|
||||
() => newFilter.value.builder.property && currentFilter.value?.type !== "enum"
|
||||
);
|
||||
|
||||
const enumChoices = computed(() => {
|
||||
if (!newFilter.value.builder.property) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const availableFilter =
|
||||
props.availableFilters[newFilter.value.builder.property];
|
||||
|
||||
if (availableFilter.type !== "enum") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return availableFilter.choices;
|
||||
});
|
||||
|
||||
const generatedFilter = computed(() => {
|
||||
if (newFilter.value.isAdvanced) {
|
||||
return newFilter.value.content;
|
||||
}
|
||||
|
||||
if (!newFilter.value.builder.comparison) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const node = buildComplexMatcherNode(
|
||||
newFilter.value.builder.comparison,
|
||||
newFilter.value.builder.property,
|
||||
newFilter.value.builder.value,
|
||||
newFilter.value.builder.negate
|
||||
);
|
||||
|
||||
if (node) {
|
||||
return node.toString();
|
||||
}
|
||||
|
||||
return "";
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
const enableAdvancedMode = () => {
|
||||
newFilter.value.content = generatedFilter.value;
|
||||
newFilter.value.isAdvanced = true;
|
||||
};
|
||||
|
||||
watch(generatedFilter, (value) => {
|
||||
newFilter.value.content = value;
|
||||
});
|
||||
|
||||
const comparisons = computed<FilterComparisons>(() => {
|
||||
const comparisonsByType = {
|
||||
string: {
|
||||
stringContains: "contain",
|
||||
stringEquals: "equal",
|
||||
stringStartsWith: "start with",
|
||||
stringEndsWith: "end with",
|
||||
stringMatchesRegex: "match regex",
|
||||
},
|
||||
boolean: {
|
||||
booleanTrue: "is true",
|
||||
booleanFalse: "is false",
|
||||
},
|
||||
number: {
|
||||
numberLessThan: "<",
|
||||
numberLessThanOrEquals: "<=",
|
||||
numberEquals: "=",
|
||||
numberGreaterThanOrEquals: ">=",
|
||||
numberGreaterThan: ">",
|
||||
},
|
||||
enum: {},
|
||||
};
|
||||
|
||||
return comparisonsByType[currentFilter.value.type];
|
||||
});
|
||||
|
||||
const valueInputBefore = computed(() => {
|
||||
return newFilter.value.builder.comparison === "stringMatchesRegex"
|
||||
? "/"
|
||||
: undefined;
|
||||
});
|
||||
|
||||
const valueInputAfter = computed(() => {
|
||||
return newFilter.value.builder.comparison === "stringMatchesRegex"
|
||||
? "/i"
|
||||
: undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.collection-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--background-color-secondary);
|
||||
gap: 1rem;
|
||||
|
||||
.or {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&:only-child {
|
||||
.or,
|
||||
.remove {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child .or {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.form-widget-advanced {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
112
@xen-orchestra/lite/src/components/CollectionSorter.vue
Normal file
112
@xen-orchestra/lite/src/components/CollectionSorter.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<UiFilterGroup class="collection-sorter">
|
||||
<UiFilter
|
||||
v-for="[property, isAscending] in activeSorts"
|
||||
:key="property"
|
||||
@edit="emit('toggleSortDirection', property)"
|
||||
@remove="emit('removeSort', property)"
|
||||
>
|
||||
<span class="property">
|
||||
<UiIcon :icon="isAscending ? faCaretUp : faCaretDown" />
|
||||
{{ property }}
|
||||
</span>
|
||||
</UiFilter>
|
||||
|
||||
<UiActionButton :icon="faPlus" class="add-sort" @click="open">
|
||||
{{ $t("add-sort") }}
|
||||
</UiActionButton>
|
||||
</UiFilterGroup>
|
||||
|
||||
<UiModal v-if="isOpen" @submit.prevent="handleSubmit" :icon="faSort">
|
||||
<div class="form-widgets">
|
||||
<FormWidget :label="$t('sort-by')">
|
||||
<select v-model="newSortProperty">
|
||||
<option v-if="!newSortProperty"></option>
|
||||
<option
|
||||
v-for="(sort, property) in availableSorts"
|
||||
:key="property"
|
||||
:value="property"
|
||||
>
|
||||
{{ sort.label ?? property }}
|
||||
</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
<FormWidget>
|
||||
<select v-model="newSortIsAscending">
|
||||
<option :value="true">{{ $t("ascending") }}</option>
|
||||
<option :value="false">{{ $t("descending") }}</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
</div>
|
||||
<template #buttons>
|
||||
<UiButton type="submit">{{ $t("add") }}</UiButton>
|
||||
<UiButton outlined @click="handleCancel">
|
||||
{{ $t("cancel") }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import type { ActiveSorts, Sorts } from "@/types/sort";
|
||||
import {
|
||||
faCaretDown,
|
||||
faCaretUp,
|
||||
faPlus,
|
||||
faSort,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import FormWidget from "@/components/FormWidget.vue";
|
||||
import UiActionButton from "@/components/ui/UiActionButton.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiFilter from "@/components/ui/UiFilter.vue";
|
||||
import UiFilterGroup from "@/components/ui/UiFilterGroup.vue";
|
||||
import UiIcon from "@/components/ui/UiIcon.vue";
|
||||
import UiModal from "@/components/ui/UiModal.vue";
|
||||
import useModal from "@/composables/modal.composable";
|
||||
|
||||
defineProps<{
|
||||
availableSorts: Sorts;
|
||||
activeSorts: ActiveSorts;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "toggleSortDirection", property: string): void;
|
||||
(event: "addSort", property: string, isAscending: boolean): void;
|
||||
(event: "removeSort", property: string): void;
|
||||
}>();
|
||||
|
||||
const { isOpen, open, close } = useModal();
|
||||
|
||||
const newSortProperty = ref();
|
||||
const newSortIsAscending = ref<boolean>(true);
|
||||
|
||||
const reset = () => {
|
||||
newSortProperty.value = undefined;
|
||||
newSortIsAscending.value = true;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit("addSort", newSortProperty.value, newSortIsAscending.value);
|
||||
reset();
|
||||
close();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
reset();
|
||||
close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.form-widgets {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.property {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
</style>
|
||||
102
@xen-orchestra/lite/src/components/CollectionTable.vue
Normal file
102
@xen-orchestra/lite/src/components/CollectionTable.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="filter-and-sort">
|
||||
<CollectionFilter
|
||||
v-if="availableFilters !== undefined"
|
||||
:active-filters="filters"
|
||||
:available-filters="availableFilters"
|
||||
@add-filter="addFilter"
|
||||
@remove-filter="removeFilter"
|
||||
/>
|
||||
|
||||
<CollectionSorter
|
||||
v-if="availableSorts !== undefined"
|
||||
:active-sorts="sorts"
|
||||
:available-sorts="availableSorts"
|
||||
@add-sort="addSort"
|
||||
@remove-sort="removeSort"
|
||||
@toggle-sort-direction="toggleSortDirection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UiTable>
|
||||
<template #header>
|
||||
<td v-if="isSelectable">
|
||||
<input v-model="areAllSelected" type="checkbox" />
|
||||
</td>
|
||||
<slot name="header" />
|
||||
</template>
|
||||
|
||||
<tr v-for="item in filteredAndSortedCollection" :key="item[idProperty]">
|
||||
<td v-if="isSelectable">
|
||||
<input
|
||||
v-model="selected"
|
||||
:value="item[props.idProperty]"
|
||||
type="checkbox"
|
||||
/>
|
||||
</td>
|
||||
<slot :item="item" name="row" />
|
||||
</tr>
|
||||
</UiTable>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, toRef, watch } from "vue";
|
||||
import type { Filters } from "@/types/filter";
|
||||
import type { Sorts } from "@/types/sort";
|
||||
import CollectionFilter from "@/components/CollectionFilter.vue";
|
||||
import CollectionSorter from "@/components/CollectionSorter.vue";
|
||||
import UiTable from "@/components/ui/UiTable.vue";
|
||||
import useCollectionFilter from "@/composables/collection-filter.composable";
|
||||
import useCollectionSorter from "@/composables/collection-sorter.composable";
|
||||
import useFilteredCollection from "@/composables/filtered-collection.composable";
|
||||
import useMultiSelect from "@/composables/multi-select.composable";
|
||||
import useSortedCollection from "@/composables/sorted-collection.composable";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string[];
|
||||
availableFilters?: Filters;
|
||||
availableSorts?: Sorts;
|
||||
collection: Record<string, any>[];
|
||||
idProperty: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", selectedRefs: string[]): void;
|
||||
}>();
|
||||
|
||||
const isSelectable = computed(() => props.modelValue !== undefined);
|
||||
|
||||
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter();
|
||||
const { sorts, addSort, removeSort, toggleSortDirection, compareFn } =
|
||||
useCollectionSorter();
|
||||
|
||||
const filteredCollection = useFilteredCollection(
|
||||
toRef(props, "collection"),
|
||||
predicate
|
||||
);
|
||||
|
||||
const filteredAndSortedCollection = useSortedCollection(
|
||||
filteredCollection,
|
||||
compareFn
|
||||
);
|
||||
|
||||
const usableRefs = computed(() =>
|
||||
props.collection.map((item) => item[props.idProperty])
|
||||
);
|
||||
|
||||
const selectableRefs = computed(() =>
|
||||
filteredAndSortedCollection.value.map((item) => item[props.idProperty])
|
||||
);
|
||||
|
||||
const { selected, areAllSelected } = useMultiSelect(usableRefs, selectableRefs);
|
||||
|
||||
watch(selected, (selected) => emit("update:modelValue", selected), {
|
||||
immediate: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.filter-and-sort {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
38
@xen-orchestra/lite/src/components/ColumnHeader.vue
Normal file
38
@xen-orchestra/lite/src/components/ColumnHeader.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<th>
|
||||
<div class="content">
|
||||
<span class="label">
|
||||
<UiIcon :icon="icon" />
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
import UiIcon from "@/components/ui/UiIcon.vue";
|
||||
|
||||
defineProps<{
|
||||
icon?: IconDefinition;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.filter-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
141
@xen-orchestra/lite/src/components/FormWidget.vue
Normal file
141
@xen-orchestra/lite/src/components/FormWidget.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<label class="form-widget">
|
||||
<span v-if="label || $slots.label" class="label">
|
||||
<slot name="label">
|
||||
{{ label }}
|
||||
</slot>
|
||||
</span>
|
||||
<span class="widget">
|
||||
<span v-if="before || $slots.before" class="before">
|
||||
<slot name="before">
|
||||
<UiIcon v-if="isIcon(before)" :icon="before" fixed-width />
|
||||
<template v-else>{{ before }}</template>
|
||||
</slot>
|
||||
</span>
|
||||
<span class="element">
|
||||
<slot />
|
||||
</span>
|
||||
<span v-if="after || $slots.after" class="after">
|
||||
<slot name="after">
|
||||
<UiIcon v-if="isIcon(after)" :icon="after" fixed-width />
|
||||
<template v-else>{{ after }}</template>
|
||||
</slot>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
import UiIcon from "@/components/ui/UiIcon.vue";
|
||||
|
||||
defineProps<{
|
||||
before?: IconDefinition | string | object; // "object" added as workaround
|
||||
after?: IconDefinition | string | object; // See https://github.com/vuejs/core/issues/4294
|
||||
label?: string;
|
||||
inline?: boolean;
|
||||
}>();
|
||||
|
||||
const isIcon = (maybeIcon: any): maybeIcon is IconDefinition =>
|
||||
typeof maybeIcon === "object";
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.form-widget {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
font-size: 1.6rem;
|
||||
height: 3.8rem;
|
||||
}
|
||||
|
||||
.widget {
|
||||
display: inline-flex;
|
||||
flex: 1;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
padding: 0 0.7rem;
|
||||
border: 1px solid var(--color-blue-scale-400);
|
||||
border-radius: 0.8rem;
|
||||
background-color: var(--color-blue-scale-500);
|
||||
box-shadow: var(--shadow-100);
|
||||
gap: 0.1rem;
|
||||
|
||||
&:focus-within {
|
||||
outline: 1px solid var(--color-extra-blue-l40);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-widget:hover .widget {
|
||||
border-color: var(--color-extra-blue-l60);
|
||||
}
|
||||
|
||||
.element {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.before,
|
||||
.after {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
:slotted(input),
|
||||
:slotted(select),
|
||||
:slotted(textarea) {
|
||||
font-size: inherit;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--color-blue-scale-100);
|
||||
background-color: var(--color-blue-scale-500);
|
||||
flex: 1;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
:slotted(input[type="checkbox"]) {
|
||||
font: inherit;
|
||||
display: grid;
|
||||
flex: 1.5rem 0 0;
|
||||
width: 1.15em;
|
||||
height: 1.15em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transform: translateY(-0.075em);
|
||||
color: currentColor;
|
||||
border-radius: 0.15em;
|
||||
background-color: #fff;
|
||||
appearance: none;
|
||||
place-content: center;
|
||||
|
||||
&::before {
|
||||
width: 0.65em;
|
||||
height: 0.65em;
|
||||
content: "";
|
||||
transition: 120ms transform ease-in-out;
|
||||
transform: scale(0);
|
||||
transform-origin: center;
|
||||
box-shadow: inset 1em 1em blue;
|
||||
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||
}
|
||||
|
||||
&:checked::before {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--color-blue-scale-200);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
49
@xen-orchestra/lite/src/components/PowerStateIcon.vue
Normal file
49
@xen-orchestra/lite/src/components/PowerStateIcon.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<UiIcon :class="className" :icon="icon" class="power-state-icon" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
faMoon,
|
||||
faPause,
|
||||
faPlay,
|
||||
faQuestion,
|
||||
faStop,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import UiIcon from "@/components/ui/UiIcon.vue";
|
||||
import type { PowerState } from "@/libs/xen-api";
|
||||
|
||||
const props = defineProps<{
|
||||
state: PowerState;
|
||||
}>();
|
||||
|
||||
const icons = {
|
||||
Running: faPlay,
|
||||
Paused: faPause,
|
||||
Suspended: faMoon,
|
||||
Halted: faStop,
|
||||
};
|
||||
|
||||
const icon = computed(() => icons[props.state] ?? faQuestion);
|
||||
|
||||
const className = computed(() => `state-${props.state.toLocaleLowerCase()}`);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.power-state-icon {
|
||||
color: var(--color-extra-blue-d60);
|
||||
|
||||
&.state-running {
|
||||
color: var(--color-green-infra-base);
|
||||
}
|
||||
|
||||
&.state-paused {
|
||||
color: var(--color-blue-scale-300);
|
||||
}
|
||||
|
||||
&.state-suspended {
|
||||
color: var(--color-extra-blue-d20);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
63
@xen-orchestra/lite/src/components/ProgressBar.vue
Normal file
63
@xen-orchestra/lite/src/components/ProgressBar.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="progress-bar-component">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill" />
|
||||
</div>
|
||||
<div class="badge" v-if="label !== undefined">
|
||||
<span class="circle" />
|
||||
{{ label }}
|
||||
<UiBadge>{{ badgeLabel ?? progressWithUnit }}</UiBadge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import UiBadge from "@/components/ui/UiBadge.vue";
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
badgeLabel?: string | number;
|
||||
label?: string;
|
||||
maxValue?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
maxValue: 100,
|
||||
});
|
||||
|
||||
const progressWithUnit = computed(() => {
|
||||
const progress = Math.round((props.value / props.maxValue) * 100);
|
||||
return `${progress}%`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.badge {
|
||||
text-align: right;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.circle {
|
||||
display: inline-block;
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
background-color: #716ac6;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
overflow: hidden;
|
||||
height: 1.2rem;
|
||||
border-radius: 0.4rem;
|
||||
background-color: var(--color-blue-scale-400);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
transition: width 1s ease-in-out;
|
||||
width: v-bind(progressWithUnit);
|
||||
height: 1.2rem;
|
||||
background-color: var(--color-extra-blue-d20);
|
||||
}
|
||||
</style>
|
||||
71
@xen-orchestra/lite/src/components/ProgressCircle.vue
Normal file
71
@xen-orchestra/lite/src/components/ProgressCircle.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<svg
|
||||
class="progress-circle"
|
||||
viewBox="0 0 36 36"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
class="progress-circle-background"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
/>
|
||||
<path
|
||||
class="progress-circle-fill"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
/>
|
||||
<text class="progress-circle-text" text-anchor="middle" x="50%" y="50%">
|
||||
{{ progress }}%
|
||||
</text>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
maxValue?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
maxValue: 100,
|
||||
});
|
||||
|
||||
const progress = computed(() => {
|
||||
if (props.maxValue === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round((props.value / props.maxValue) * 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.progress-circle-fill {
|
||||
animation: progress 1s ease-out forwards;
|
||||
fill: none;
|
||||
stroke: var(--color-green-infra-base);
|
||||
stroke-width: 1.2;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: v-bind(progress), 100;
|
||||
}
|
||||
|
||||
.progress-circle-background {
|
||||
fill: none;
|
||||
stroke-width: 1.2;
|
||||
stroke: var(--color-blue-scale-400);
|
||||
}
|
||||
|
||||
.progress-circle-text {
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
fill: var(--color-green-infra-base);
|
||||
text-anchor: middle;
|
||||
alignment-baseline: middle;
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
0% {
|
||||
stroke-dasharray: 0, 100;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
53
@xen-orchestra/lite/src/components/RemoteConsole.vue
Normal file
53
@xen-orchestra/lite/src/components/RemoteConsole.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div ref="vmConsoleContainer" class="vm-console" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeUnmount, ref, watchEffect } from "vue";
|
||||
import VncClient from "@novnc/novnc/core/rfb";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
|
||||
const props = defineProps<{
|
||||
location: string;
|
||||
}>();
|
||||
|
||||
const vmConsoleContainer = ref<HTMLDivElement>();
|
||||
const xenApiStore = useXenApiStore();
|
||||
let vncClient: VncClient | undefined;
|
||||
|
||||
watchEffect(() => {
|
||||
if (!vmConsoleContainer.value || !xenApiStore.currentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (vncClient !== undefined) {
|
||||
vncClient.disconnect();
|
||||
vncClient = undefined;
|
||||
}
|
||||
|
||||
const url = new URL(props.location);
|
||||
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
url.searchParams.set("session_id", xenApiStore.currentSessionId);
|
||||
|
||||
vncClient = new VncClient(vmConsoleContainer.value, url.toString(), {
|
||||
wsProtocols: ["binary"],
|
||||
});
|
||||
|
||||
vncClient.scaleViewport = true;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
vncClient?.disconnect();
|
||||
vncClient = undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.vm-console {
|
||||
height: 80rem;
|
||||
|
||||
& > :deep(div) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
@xen-orchestra/lite/src/components/TabBar.vue
Normal file
15
@xen-orchestra/lite/src/components/TabBar.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="tab-bar">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 6.5rem;
|
||||
background-color: var(--background-color-primary);
|
||||
border-bottom: 1px solid var(--color-blue-scale-400);
|
||||
}
|
||||
</style>
|
||||
54
@xen-orchestra/lite/src/components/TabBarItem.vue
Normal file
54
@xen-orchestra/lite/src/components/TabBarItem.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<span v-if="disabled" class="tab-bar-item disabled">
|
||||
<slot />
|
||||
</span>
|
||||
<RouterLink v-else class="tab-bar-item" v-bind="$props">
|
||||
<slot />
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { RouterLinkProps } from "vue-router";
|
||||
|
||||
// https://vuejs.org/api/sfc-script-setup.html#type-only-props-emit-declarations
|
||||
interface Props extends RouterLinkProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.tab-bar-item {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1.2em;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-blue-scale-100);
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
border-bottom-color: var(--color-extra-blue-base);
|
||||
background-color: var(--background-color-secondary);
|
||||
}
|
||||
|
||||
&:active:not(.disabled) {
|
||||
color: var(--color-extra-blue-base);
|
||||
border-bottom-color: var(--color-extra-blue-base);
|
||||
background-color: var(--background-color-secondary);
|
||||
}
|
||||
|
||||
&.router-link-active {
|
||||
color: var(--color-extra-blue-base);
|
||||
border-bottom-color: var(--color-extra-blue-base);
|
||||
background-color: var(--background-color-primary);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: var(--color-blue-scale-400);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
42
@xen-orchestra/lite/src/components/TitleBar.vue
Normal file
42
@xen-orchestra/lite/src/components/TitleBar.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="title-bar">
|
||||
<UiIcon :icon="icon" class="icon" />
|
||||
<div class="title">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
import UiIcon from "@/components/ui/UiIcon.vue";
|
||||
|
||||
defineProps<{
|
||||
icon: IconDefinition;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.title-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 8rem;
|
||||
padding: 0 2rem;
|
||||
border-bottom: 1px solid var(--color-blue-scale-400);
|
||||
background-color: var(--background-color-primary);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 3.8rem;
|
||||
color: var(--color-extra-blue-base);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 3rem;
|
||||
color: var(--color-blue-scale-100);
|
||||
}
|
||||
</style>
|
||||
94
@xen-orchestra/lite/src/components/UsageBar.vue
Normal file
94
@xen-orchestra/lite/src/components/UsageBar.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div v-if="data.length !== 0">
|
||||
<div class="header">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<ProgressBar
|
||||
v-for="item in computedData.sortedArray"
|
||||
:key="item.id"
|
||||
:value="item.value"
|
||||
:label="item.label"
|
||||
:badge-label="item.badgeLabel"
|
||||
/>
|
||||
<div class="footer">
|
||||
<slot name="footer" :total-percent="computedData.totalPercentUsage" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import ProgressBar from "@/components/ProgressBar.vue";
|
||||
|
||||
interface Data {
|
||||
id: string;
|
||||
value: number;
|
||||
label?: string;
|
||||
badgeLabel?: string;
|
||||
maxValue?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: Array<Data>;
|
||||
nItems?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const computedData = computed(() => {
|
||||
const _data = props.data;
|
||||
let totalPercentUsage = 0;
|
||||
return {
|
||||
sortedArray: _data
|
||||
.map((item) => {
|
||||
const value = Math.round((item.value / (item.maxValue ?? 100)) * 100);
|
||||
totalPercentUsage += value;
|
||||
return {
|
||||
...item,
|
||||
value,
|
||||
};
|
||||
})
|
||||
.sort((item, nextItem) => nextItem.value - item.value)
|
||||
.slice(0, props.nItems ?? _data.length),
|
||||
totalPercentUsage,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
color: var(--color-extra-blue-base);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--color-extra-blue-base);
|
||||
margin-bottom: 2rem;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--color-blue-scale-300);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.progress-bar-component:nth-of-type(2) .progress-bar-fill,
|
||||
.progress-bar-component:nth-of-type(2) .circle {
|
||||
background-color: var(--color-extra-blue-d60);
|
||||
}
|
||||
.progress-bar-component:nth-of-type(3) .progress-bar-fill,
|
||||
.progress-bar-component:nth-of-type(3) .circle {
|
||||
background-color: var(--color-extra-blue-d40);
|
||||
}
|
||||
.progress-bar-component:nth-of-type(4) .progress-bar-fill,
|
||||
.progress-bar-component:nth-of-type(4) .circle {
|
||||
background-color: var(--color-extra-blue-d20);
|
||||
}
|
||||
.progress-bar-component .progress-bar-fill,
|
||||
.progress-bar-component .circle {
|
||||
background-color: var(--color-extra-blue-l20);
|
||||
}
|
||||
</style>
|
||||
59
@xen-orchestra/lite/src/components/charts/ChartSummary.vue
Normal file
59
@xen-orchestra/lite/src/components/charts/ChartSummary.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="chart-summary">
|
||||
<div>
|
||||
<div class="label">{{ $t("total-used") }}</div>
|
||||
<div>
|
||||
{{ usedPercent }}%
|
||||
<br />
|
||||
{{ valueFormatter(used) }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">{{ $t("total-free") }}</div>
|
||||
<div>
|
||||
{{ freePercent }}%
|
||||
<br />
|
||||
{{ valueFormatter(total - used) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject } from "vue";
|
||||
import { percent } from "@/libs/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
total: number;
|
||||
used: number;
|
||||
}>();
|
||||
|
||||
const usedPercent = computed(() => percent(props.used, props.total));
|
||||
|
||||
const freePercent = computed(() =>
|
||||
percent(props.total - props.used, props.total)
|
||||
);
|
||||
|
||||
const valueFormatter = inject("valueFormatter") as (value: number) => string;
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.chart-summary {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
margin-top: 2rem;
|
||||
color: var(--color-blue-scale-200);
|
||||
gap: 4rem;
|
||||
|
||||
& > div {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
38
@xen-orchestra/lite/src/components/charts/LinearChart.md
Normal file
38
@xen-orchestra/lite/src/components/charts/LinearChart.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# LinearChart component
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<LinearChart
|
||||
title="Chart title"
|
||||
subtitle="Chart subtitle"
|
||||
:data="data"
|
||||
:value-formatter="customValueFormatter"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { LinearChartData } from "@/types/chart";
|
||||
import LinearChart from "@/components/charts/LinearChart.vue";
|
||||
|
||||
const data: LinearChartData = [
|
||||
{
|
||||
label: "First series",
|
||||
data: [
|
||||
{ date: "...", value: 1234 },
|
||||
{ date: "...", value: 1234 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Second series",
|
||||
data: [
|
||||
{ date: "...", value: 1234 },
|
||||
{ date: "...", value: 1234 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const customValueFormatter = (value: number) => {
|
||||
return `${value} (Doubled: ${value * 2})`;
|
||||
};
|
||||
</script>
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user