feat(lite): initial Vue.js implementation
This commit is contained in:
parent
0623d837c1
commit
ecb580a629
1
@xen-orchestra/lite/.env.dist
Normal file
1
@xen-orchestra/lite/.env.dist
Normal file
@ -0,0 +1 @@
|
||||
VITE_XO_HOST=
|
21
@xen-orchestra/lite/.eslintrc.cjs
Normal file
21
@xen-orchestra/lite/.eslintrc.cjs
Normal file
@ -0,0 +1,21 @@
|
||||
/* eslint-env node */
|
||||
require("@rushstack/eslint-patch/modern-module-resolution");
|
||||
|
||||
module.exports = {
|
||||
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",
|
||||
},
|
||||
};
|
29
@xen-orchestra/lite/.gitignore
vendored
Normal file
29
@xen-orchestra/lite/.gitignore
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.env
|
14
@xen-orchestra/lite/.prettierrc.cjs
Normal file
14
@xen-orchestra/lite/.prettierrc.cjs
Normal file
@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
importOrder: [
|
||||
"^[^/]+$",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^@/components/(.*)$",
|
||||
"^@/composables/(.*)$",
|
||||
"^@/libs/(.*)$",
|
||||
"^@/router/(.*)$",
|
||||
"^@/stores/(.*)$",
|
||||
"^@/views/(.*)$",
|
||||
],
|
||||
importOrderSeparation: false,
|
||||
importOrderSortSpecifiers: true,
|
||||
};
|
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"]
|
||||
}
|
173
@xen-orchestra/lite/README.md
Normal file
173
@xen-orchestra/lite/README.md
Normal file
@ -0,0 +1,173 @@
|
||||
# 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
|
||||
|
||||
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>
|
||||
```
|
||||
|
||||
### 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,
|
||||
};
|
||||
});
|
||||
```
|
2
@xen-orchestra/lite/env.d.ts
vendored
Normal file
2
@xen-orchestra/lite/env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="json-rpc-2.0/dist" />
|
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="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
47
@xen-orchestra/lite/package.json
Normal file
47
@xen-orchestra/lite/package.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "xo-lite",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check build-only",
|
||||
"preview": "vite preview --port 4173",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"precommit": "yarn lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.1",
|
||||
"@novnc/novnc": "^1.3.0",
|
||||
"@vueuse/core": "^8.7.5",
|
||||
"complex-matcher": "^0.7.0",
|
||||
"json-rpc-2.0": "^1.3.0",
|
||||
"pinia": "^2.0.14",
|
||||
"vue": "^3.2.37",
|
||||
"vue-router": "^4.0.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@types/novnc__novnc": "^1.3.0",
|
||||
"@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": "^8.5.0",
|
||||
"eslint-plugin-vue": "^9.0.0",
|
||||
"husky": "^8.0.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss-nested": "^5.0.6",
|
||||
"prettier": "^2.5.1",
|
||||
"typescript": "~4.7.4",
|
||||
"vite": "^2.9.12",
|
||||
"vue-tsc": "^0.38.1"
|
||||
}
|
||||
}
|
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 |
53
@xen-orchestra/lite/src/App.vue
Normal file
53
@xen-orchestra/lite/src/App.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div v-if="!xenApiStore.isConnected" style="display: flex">
|
||||
<AppLogin />
|
||||
</div>
|
||||
<div v-else>
|
||||
<AppHeader />
|
||||
<div style="display: flex">
|
||||
<nav class="nav">
|
||||
<InfraPoolList />
|
||||
</nav>
|
||||
<main class="main">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watchEffect } from "vue";
|
||||
import AppHeader from "@/components/AppHeader.vue";
|
||||
import AppLogin from "@/components/AppLogin.vue";
|
||||
import InfraPoolList from "@/components/infra/InfraPoolList.vue";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
|
||||
const xenApiStore = useXenApiStore();
|
||||
|
||||
watchEffect(() => {
|
||||
if (xenApiStore.isConnected) {
|
||||
xenApiStore.init();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss">
|
||||
@import "@/assets/base.css";
|
||||
|
||||
.nav {
|
||||
overflow: auto;
|
||||
width: 37rem;
|
||||
max-width: 37rem;
|
||||
height: calc(100vh - 9rem);
|
||||
padding: 0.5rem;
|
||||
background-color: var(--background-color-primary);
|
||||
border-right: 1px solid var(--color-blue-scale-400);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
background-color: var(--background-color-secondary);
|
||||
height: calc(100vh - 9rem);
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
14
@xen-orchestra/lite/src/assets/base.css
Normal file
14
@xen-orchestra/lite/src/assets/base.css
Normal file
@ -0,0 +1,14 @@
|
||||
@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-family: Poppins, sans-serif;
|
||||
font-size: 1.3rem;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: var(--color-blue-scale-100);
|
||||
}
|
BIN
@xen-orchestra/lite/src/assets/logo.png
Normal file
BIN
@xen-orchestra/lite/src/assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.0 KiB |
27
@xen-orchestra/lite/src/assets/reset.css
Normal file
27
@xen-orchestra/lite/src/assets/reset.css
Normal file
@ -0,0 +1,27 @@
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: inherit;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
79
@xen-orchestra/lite/src/assets/theme.css
Normal file
79
@xen-orchestra/lite/src/assets/theme.css
Normal file
@ -0,0 +1,79 @@
|
||||
body {
|
||||
--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: #D1CEFB;
|
||||
--color-green-infra-l40: #BBB5F9;
|
||||
--color-green-infra-l20: #A39DF8;
|
||||
--color-green-infra-base: #8F84FF;
|
||||
--color-green-infra-d20: #716AC6;
|
||||
--color-green-infra-d40: #554F94;
|
||||
--color-green-infra-d60: #383563;
|
||||
|
||||
--color-orange-world-l60: #D1CEFB;
|
||||
--color-orange-world-l40: #BBB5F9;
|
||||
--color-orange-world-l20: #A39DF8;
|
||||
--color-orange-world-base: #8F84FF;
|
||||
--color-orange-world-d20: #716AC6;
|
||||
--color-orange-world-d40: #554F94;
|
||||
--color-orange-world-d60: #383563;
|
||||
|
||||
--color-red-vates-l60: #D1CEFB;
|
||||
--color-red-vates-l40: #BBB5F9;
|
||||
--color-red-vates-l20: #A39DF8;
|
||||
--color-red-vates-base: #8F84FF;
|
||||
--color-red-vates-d20: #716AC6;
|
||||
--color-red-vates-d40: #554F94;
|
||||
--color-red-vates-d60: #383563;
|
||||
|
||||
--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);
|
||||
|
||||
--form-control-disabled: var(--color-blue-scale-200);
|
||||
--form-background: var(--color-blue-scale-500);
|
||||
--form-control-color: var(--color-blue-scale-100);
|
||||
}
|
||||
|
||||
body.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);
|
||||
}
|
41
@xen-orchestra/lite/src/components/AppHeader.vue
Normal file
41
@xen-orchestra/lite/src/components/AppHeader.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<RouterLink :to="{ name: 'home' }">
|
||||
<img alt="XO Lite" src="../assets/logo.png" style="width: 8rem" />
|
||||
</RouterLink>
|
||||
<slot />
|
||||
<span style="cursor: pointer" @click="toggleTheme">Switch theme</span>
|
||||
<span style="cursor: pointer; margin-left: 1rem" @click="logout">
|
||||
Logout
|
||||
</span>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const toggleTheme = () => {
|
||||
document.body.classList.toggle("dark");
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const xenApiStore = useXenApiStore();
|
||||
xenApiStore.disconnect();
|
||||
nextTick(() => router.push({ name: "home" }));
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 8rem;
|
||||
padding: 0 0.8rem;
|
||||
border-bottom: 0.1rem solid var(--color-blue-scale-400);
|
||||
background-color: var(--background-color-secondary);
|
||||
}
|
||||
</style>
|
84
@xen-orchestra/lite/src/components/AppLogin.vue
Normal file
84
@xen-orchestra/lite/src/components/AppLogin.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="app-login form-container">
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<img alt="XO Lite" src="../assets/logo.png" />
|
||||
<h1>Xen Orchestra Lite</h1>
|
||||
<input v-model="login" name="login" readonly type="text" />
|
||||
<input
|
||||
v-model="password"
|
||||
:readonly="isConnecting"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
<UiButton :busy="isConnecting" type="submit">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 scoped>
|
||||
.form-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
background-color: var(--background-color-primary);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
padding: 0 0 8.5rem 0;
|
||||
background-color: var(--background-color-secondary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 4.8rem;
|
||||
font-weight: 900;
|
||||
line-height: 7.2rem;
|
||||
margin-bottom: 4.2rem;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 60rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 120%;
|
||||
font-weight: bold;
|
||||
margin: 1.5rem 0 0.5rem 0;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 45rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border: 1px solid var(--color-blue-scale-400);
|
||||
border-radius: 0.8rem;
|
||||
background-color: white;
|
||||
}
|
||||
</style>
|
412
@xen-orchestra/lite/src/components/CollectionFilter.vue
Normal file
412
@xen-orchestra/lite/src/components/CollectionFilter.vue
Normal file
@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<UiFilterGroup>
|
||||
<UiFilter
|
||||
v-for="filter in activeFilters"
|
||||
:key="filter"
|
||||
@remove="emit('removeFilter', filter)"
|
||||
>
|
||||
{{ filter }}
|
||||
</UiFilter>
|
||||
|
||||
<UiButton
|
||||
:icon-left="faPlus"
|
||||
class="add-filter"
|
||||
color="action"
|
||||
@click="open"
|
||||
>
|
||||
Add filter
|
||||
</UiButton>
|
||||
</UiFilterGroup>
|
||||
|
||||
<UiModal v-if="isOpen">
|
||||
Add a filter
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="form-row" v-for="(item, index) in items" :key="index">
|
||||
<span v-if="items.length > 1" style="width: 2rem">{{
|
||||
index > 0 ? "OR" : ""
|
||||
}}</span>
|
||||
<FormWidget
|
||||
v-if="!isAdvancedModeEnabled"
|
||||
:before="filterIconForIndex(index)"
|
||||
>
|
||||
<select v-model="items[index].selectedFilter">
|
||||
<option v-if="!selectedFilter"></option>
|
||||
<option
|
||||
v-for="availableFilter in availableFilters"
|
||||
:key="availableFilter.property"
|
||||
:value="availableFilter"
|
||||
>
|
||||
{{ availableFilter.label ?? availableFilter.property }}
|
||||
</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
|
||||
<FormWidget
|
||||
v-if="!isAdvancedModeEnabled && items[index].selectedFilter"
|
||||
>
|
||||
<select v-model="items[index].filterPattern">
|
||||
<option
|
||||
v-for="comparison in getComparisonsForIndex(index)"
|
||||
:key="comparison.pattern"
|
||||
:value="comparison.pattern"
|
||||
>
|
||||
{{ comparison.label }}
|
||||
</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
|
||||
<FormWidget
|
||||
v-if="isFilterTakingValue(index)"
|
||||
:after="getComparisonForIndex(index)?.after"
|
||||
:before="getComparisonForIndex(index)?.before"
|
||||
>
|
||||
<input v-model="items[index].filterValue" />
|
||||
</FormWidget>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<UiButtonGroup>
|
||||
<UiButton color="action" @click="addItem">+OR</UiButton>
|
||||
|
||||
<UiButton
|
||||
type="submit"
|
||||
:disabled="!generatedFilter || !isGeneratedFilterValid"
|
||||
>
|
||||
Add
|
||||
</UiButton>
|
||||
|
||||
<UiButton color="action" type="button" @click="handleCancel">
|
||||
Cancel
|
||||
</UiButton>
|
||||
</UiButtonGroup>
|
||||
</form>
|
||||
|
||||
<div style="margin-top: 1rem">
|
||||
<UiButton
|
||||
v-if="!isAdvancedModeEnabled"
|
||||
@click="activateAdvancedMode"
|
||||
color="action"
|
||||
>
|
||||
Switch to advanced mode...
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as CM from "complex-matcher";
|
||||
import { computed, ref } from "vue";
|
||||
import type {
|
||||
AvailableFilter,
|
||||
FilterComparisons,
|
||||
FilterType,
|
||||
} from "@/types/filter";
|
||||
import { faSquareCheck } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faFont, faHashtag, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import FormWidget from "@/components/FormWidget.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiButtonGroup from "@/components/ui/UiButtonGroup.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 { escapeRegExp } from "@/libs/utils";
|
||||
|
||||
defineProps<{
|
||||
availableFilters: AvailableFilter[];
|
||||
activeFilters: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "addFilter", filter: string): void;
|
||||
(event: "removeFilter", filter: string): void;
|
||||
}>();
|
||||
|
||||
const { open, close, isOpen } = useModal();
|
||||
|
||||
const getComparisonsForIndex = (index: number) => {
|
||||
const selectedFilter = items.value[index]?.selectedFilter;
|
||||
|
||||
if (!selectedFilter) {
|
||||
return [];
|
||||
}
|
||||
|
||||
switch (selectedFilter.type) {
|
||||
case "string":
|
||||
return [
|
||||
{ label: "contains", pattern: "%p:%v", default: true },
|
||||
{ label: "equals", pattern: "%p:/^%v$/i", escape: true },
|
||||
{ label: "starts with", pattern: "%p:/^%v/i", escape: true },
|
||||
{ label: "ends with", pattern: "%p:/%v$/i", escape: true },
|
||||
{
|
||||
label: "matches regex",
|
||||
pattern: "%p:/%v/i",
|
||||
before: "/",
|
||||
after: "/i",
|
||||
},
|
||||
];
|
||||
case "number":
|
||||
return [
|
||||
{ label: "<", pattern: "%p:<%v" },
|
||||
{ label: "<=", pattern: "%p:<=%v" },
|
||||
{ label: "=", pattern: "%p:%v", default: true },
|
||||
{ label: ">=", pattern: "%p:>=%v" },
|
||||
{ label: ">", pattern: "%p:>%v" },
|
||||
];
|
||||
case "boolean":
|
||||
return [
|
||||
{ label: "is true", pattern: "%p?", default: true },
|
||||
{ label: "is false", pattern: "!%p?" },
|
||||
];
|
||||
case "enum":
|
||||
return selectedFilter.choices.map((choice, index) => ({
|
||||
label: choice,
|
||||
pattern: `%p:/^${escapeRegExp(choice)}$/`,
|
||||
default: index === 0,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const comparisons = computed<FilterComparisons>(() => ({
|
||||
string: [
|
||||
{ label: "contains", pattern: "%p:%v", default: true },
|
||||
{ label: "equals", pattern: "%p:/^%v$/i", escape: true },
|
||||
{ label: "starts with", pattern: "%p:/^%v/i", escape: true },
|
||||
{ label: "ends with", pattern: "%p:/%v$/i", escape: true },
|
||||
{
|
||||
label: "matches regex",
|
||||
pattern: "%p:/%v/i",
|
||||
before: "/",
|
||||
after: "/i",
|
||||
},
|
||||
],
|
||||
number: [
|
||||
{ label: "<", pattern: "%p:<%v" },
|
||||
{ label: "<=", pattern: "%p:<=%v" },
|
||||
{ label: "=", pattern: "%p:%v", default: true },
|
||||
{ label: ">=", pattern: "%p:>=%v" },
|
||||
{ label: ">", pattern: "%p:>%v" },
|
||||
],
|
||||
boolean: [
|
||||
{ label: "is true", pattern: "%p?", default: true },
|
||||
{ label: "is false", pattern: "!%p?" },
|
||||
],
|
||||
enum:
|
||||
selectedFilter.value?.type !== "enum"
|
||||
? []
|
||||
: selectedFilter.value.choices.map((choice, index) => ({
|
||||
label: choice,
|
||||
pattern: `%p:/^${escapeRegExp(choice)}$/`,
|
||||
default: index === 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
const isAdvancedModeEnabled = ref(false);
|
||||
|
||||
const selectedFilter = ref<AvailableFilter>();
|
||||
|
||||
const filterPattern = ref<string>();
|
||||
|
||||
const currentComparison = computed(() => {
|
||||
if (!selectedFilter.value) {
|
||||
return;
|
||||
}
|
||||
return comparisons.value[selectedFilter.value.type].find(
|
||||
(comparison) => comparison.pattern === filterPattern.value
|
||||
);
|
||||
});
|
||||
|
||||
const getComparisonForIndex = (index: number) => {
|
||||
const type = items.value[index]?.selectedFilter?.type;
|
||||
const pattern = items.value[index]?.filterPattern;
|
||||
|
||||
if (!type || !pattern) {
|
||||
return;
|
||||
}
|
||||
|
||||
return comparisons.value[type].find(
|
||||
(comparison) => comparison.pattern === pattern
|
||||
);
|
||||
};
|
||||
|
||||
const getDefaultPatternForFilterType = (filterType?: FilterType) => {
|
||||
if (!filterType) {
|
||||
return;
|
||||
}
|
||||
|
||||
return comparisons.value[filterType].find(({ default: def }) => def)?.pattern;
|
||||
};
|
||||
|
||||
const items = ref<
|
||||
{
|
||||
selectedFilter: AvailableFilter | undefined;
|
||||
filterPattern: string;
|
||||
filterValue: string;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const filterIconForIndex = (index: number) => {
|
||||
const selectedFilter = items.value[index]?.selectedFilter;
|
||||
|
||||
if (!selectedFilter) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedFilter.icon) {
|
||||
return selectedFilter.icon;
|
||||
}
|
||||
|
||||
switch (selectedFilter.type) {
|
||||
case "string":
|
||||
return faFont;
|
||||
case "number":
|
||||
return faHashtag;
|
||||
case "boolean":
|
||||
return faSquareCheck;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// watch(selectedFilter, (newSelectedFilter) => {
|
||||
// if (newSelectedFilter && !items.value.length) {
|
||||
// items.value.push({
|
||||
// filterPattern:
|
||||
// getDefaultPatternForFilterType(newSelectedFilter.type) || "",
|
||||
// filterValue: "",
|
||||
// });
|
||||
// }
|
||||
// filterPattern.value = getDefaultPatternForFilterType(newSelectedFilter?.type);
|
||||
// });
|
||||
|
||||
const addItem = () => {
|
||||
items.value.push({
|
||||
selectedFilter: undefined,
|
||||
filterPattern: "",
|
||||
filterValue: "",
|
||||
});
|
||||
};
|
||||
|
||||
addItem();
|
||||
|
||||
const isFilterTakingValue = (index: number) => {
|
||||
const comparison = getComparisonForIndex(index);
|
||||
|
||||
return comparison?.pattern.includes("%v") || isAdvancedModeEnabled.value;
|
||||
};
|
||||
|
||||
// const isFilterTakingValue = computed(() => {
|
||||
// return (
|
||||
// currentComparison.value?.pattern.includes("%v") ||
|
||||
// isAdvancedModeEnabled.value
|
||||
// );
|
||||
// });
|
||||
|
||||
const filterValue = ref<string>();
|
||||
|
||||
const generatedFilter = computed(() => {
|
||||
if (items.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = items.value
|
||||
.map((item) => {
|
||||
return item.filterPattern
|
||||
.replace("%p", item.selectedFilter?.property)
|
||||
.replace(
|
||||
"%v",
|
||||
currentComparison.value?.escape
|
||||
? escapeRegExp(item.filterValue)
|
||||
: item.filterValue
|
||||
);
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
if (items.value.length === 1) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return `|(${result})`;
|
||||
});
|
||||
|
||||
// const generatedFilter2 = computed(() => {
|
||||
// if (isAdvancedModeEnabled.value) {
|
||||
// return filterValue.value;
|
||||
// }
|
||||
//
|
||||
// if (!selectedFilter.value || !filterPattern.value) {
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// if (isFilterTakingValue.value) {
|
||||
// if (!filterValue.value) {
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// return filterPattern.value
|
||||
// .replace("%p", selectedFilter.value.property)
|
||||
// .replace(
|
||||
// "%v",
|
||||
// currentComparison.value?.escape
|
||||
// ? escapeRegExp(filterValue.value)
|
||||
// : filterValue.value
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// return filterPattern.value.replace("%p", selectedFilter.value.property);
|
||||
// });
|
||||
|
||||
const isGeneratedFilterValid = computed(() => {
|
||||
if (!generatedFilter.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (generatedFilter.value) {
|
||||
CM.parse(generatedFilter.value);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const activateAdvancedMode = () => {
|
||||
filterValue.value = generatedFilter.value;
|
||||
isAdvancedModeEnabled.value = true;
|
||||
};
|
||||
|
||||
const resetAndClose = () => {
|
||||
items.value = [];
|
||||
addItem();
|
||||
selectedFilter.value = undefined;
|
||||
filterValue.value = undefined;
|
||||
isAdvancedModeEnabled.value = false;
|
||||
close();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (generatedFilter.value) {
|
||||
emit("addFilter", generatedFilter.value);
|
||||
resetAndClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
resetAndClose();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.add-filter {
|
||||
height: 3.4rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
69
@xen-orchestra/lite/src/components/CollectionTable.vue
Normal file
69
@xen-orchestra/lite/src/components/CollectionTable.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<CollectionFilter
|
||||
v-if="availableFilters"
|
||||
:active-filters="filters"
|
||||
:available-filters="availableFilters"
|
||||
@add-filter="addFilter"
|
||||
@remove-filter="removeFilter"
|
||||
/>
|
||||
|
||||
<UiTable>
|
||||
<template #header>
|
||||
<td v-if="isSelectable">
|
||||
<input v-model="areAllSelected" type="checkbox" />
|
||||
</td>
|
||||
<slot name="header" />
|
||||
</template>
|
||||
|
||||
<tr v-for="item in filteredCollection" :key="item.$ref">
|
||||
<td v-if="isSelectable">
|
||||
<input v-model="selected" :value="item.$ref" type="checkbox" />
|
||||
</td>
|
||||
<slot :item="item" name="row" />
|
||||
</tr>
|
||||
</UiTable>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, toRef, watch } from "vue";
|
||||
import type { AvailableFilter } from "@/types/filter";
|
||||
import CollectionFilter from "@/components/CollectionFilter.vue";
|
||||
import UiTable from "@/components/ui/UiTable.vue";
|
||||
import useCollectionFilter from "@/composables/collection-filter.composable";
|
||||
import useFilteredCollection from "@/composables/filtered-collection.composable";
|
||||
import useMultiSelect from "@/composables/multi-select.composable";
|
||||
import type { XenApiRecord } from "@/libs/xen-api";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string[];
|
||||
availableFilters?: AvailableFilter[];
|
||||
collection: XenApiRecord[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", selectedRefs: string[]): void;
|
||||
}>();
|
||||
|
||||
const isSelectable = computed(() => props.modelValue !== undefined);
|
||||
|
||||
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter();
|
||||
|
||||
const filteredCollection = useFilteredCollection(
|
||||
toRef(props, "collection"),
|
||||
predicate
|
||||
);
|
||||
|
||||
const usableRefs = computed(() => props.collection.map((item) => item.$ref));
|
||||
|
||||
const selectableRefs = computed(() =>
|
||||
filteredCollection.value.map((item) => item.$ref)
|
||||
);
|
||||
|
||||
const { selected, areAllSelected } = useMultiSelect(usableRefs, selectableRefs);
|
||||
|
||||
watch(selected, (selected) => emit("update:modelValue", selected), {
|
||||
immediate: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
128
@xen-orchestra/lite/src/components/FormWidget.vue
Normal file
128
@xen-orchestra/lite/src/components/FormWidget.vue
Normal file
@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<label :class="{ inline }" 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">
|
||||
<FontAwesomeIcon 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">
|
||||
<FontAwesomeIcon 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";
|
||||
|
||||
defineProps<{
|
||||
before?: IconDefinition | string;
|
||||
after?: IconDefinition | string;
|
||||
label?: string;
|
||||
inline?: boolean;
|
||||
}>();
|
||||
|
||||
const isIcon = (maybeIcon: any): maybeIcon is IconDefinition =>
|
||||
typeof maybeIcon === "object";
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-widget {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
font-size: 1.6rem;
|
||||
height: 3.8rem;
|
||||
}
|
||||
|
||||
.widget {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
padding: 0 0.7rem;
|
||||
border: 1px solid var(--color-blue-scale-400);
|
||||
border-radius: 0.8rem;
|
||||
background-color: var(--form-background);
|
||||
box-shadow: var(--shadow-100);
|
||||
gap: 0.1rem;
|
||||
|
||||
&:focus-within {
|
||||
outline: 1px solid var(--color-extra-blue-l40);
|
||||
}
|
||||
}
|
||||
|
||||
.form-widget:hover .widget {
|
||||
border-color: var(--color-extra-blue-l60);
|
||||
}
|
||||
|
||||
.element {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.before,
|
||||
.after {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:slotted(input),
|
||||
:slotted(select),
|
||||
:slotted(textarea) {
|
||||
font-family: Poppins, sans-serif;
|
||||
font-size: inherit;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--color-blue-scale-100);
|
||||
background-color: var(--form-background);
|
||||
}
|
||||
|
||||
: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(--form-control-disabled);
|
||||
--form-control-color: var(--form-control-disabled);
|
||||
}
|
||||
}
|
||||
</style>
|
54
@xen-orchestra/lite/src/components/PowerStateIcon.vue
Normal file
54
@xen-orchestra/lite/src/components/PowerStateIcon.vue
Normal file
@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<FontAwesomeIcon class="power-state-icon" :class="className" :icon="icon" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
faMoon,
|
||||
faPause,
|
||||
faPlay,
|
||||
faQuestion,
|
||||
faStop,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import type { PowerState } from "@/libs/xen-api";
|
||||
|
||||
const props = defineProps<{
|
||||
state: PowerState | "Unknown";
|
||||
}>();
|
||||
|
||||
const icon = computed(() => {
|
||||
switch (props.state) {
|
||||
case "Running":
|
||||
return faPlay;
|
||||
case "Paused":
|
||||
return faPause;
|
||||
case "Suspended":
|
||||
return faMoon;
|
||||
case "Unknown":
|
||||
return faQuestion;
|
||||
default:
|
||||
return faStop;
|
||||
}
|
||||
});
|
||||
|
||||
const className = computed(() => props.state.toLocaleLowerCase());
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
.power-state-icon {
|
||||
color: var(--color-extra-blue-d60);
|
||||
|
||||
&.running {
|
||||
color: var(--color-green-infra-base);
|
||||
}
|
||||
|
||||
&.paused {
|
||||
color: var(--color-blue-scale-300);
|
||||
}
|
||||
|
||||
&.suspended {
|
||||
color: var(--color-extra-blue-d20);
|
||||
}
|
||||
}
|
||||
</style>
|
69
@xen-orchestra/lite/src/components/ProgressBar.vue
Normal file
69
@xen-orchestra/lite/src/components/ProgressBar.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<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 {
|
||||
animation: progress 1s ease-out forwards;
|
||||
width: v-bind(progressWithUnit);
|
||||
height: 1.2rem;
|
||||
background-color: var(--color-extra-blue-d20);
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
0% {
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
</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>
|
52
@xen-orchestra/lite/src/components/RemoteConsole.vue
Normal file
52
@xen-orchestra/lite/src/components/RemoteConsole.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<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) {
|
||||
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();
|
||||
});
|
||||
</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: 8rem;
|
||||
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: 2rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 2.5rem;
|
||||
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>
|
41
@xen-orchestra/lite/src/components/TitleBar.vue
Normal file
41
@xen-orchestra/lite/src/components/TitleBar.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="title-bar">
|
||||
<FontAwesomeIcon :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";
|
||||
|
||||
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: 4.8rem;
|
||||
color: var(--color-extra-blue-base);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 3.6rem;
|
||||
color: var(--color-blue-scale-100);
|
||||
}
|
||||
</style>
|
90
@xen-orchestra/lite/src/components/UsageBar.vue
Normal file
90
@xen-orchestra/lite/src/components/UsageBar.vue
Normal file
@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="header">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<ProgressBar
|
||||
v-for="(item, index) in computedData.sortedArray"
|
||||
:key="index"
|
||||
:value="item.value"
|
||||
:label="item.label"
|
||||
:badge-label="item.badgeLabel"
|
||||
/>
|
||||
<div class="footer">
|
||||
<slot name="footer" :total-percent="computedData.totalPercentUsage" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import ProgressBar from "@/components/ProgressBar.vue";
|
||||
|
||||
interface Data {
|
||||
value: number;
|
||||
label?: string;
|
||||
badgeLabel?: string;
|
||||
maxValue?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: Array<Data>;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
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),
|
||||
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>
|
24
@xen-orchestra/lite/src/components/infra/InfraAction.vue
Normal file
24
@xen-orchestra/lite/src/components/infra/InfraAction.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="infra-action">
|
||||
<FontAwesomeIcon :icon="icon" fixed-width />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
defineProps<{
|
||||
icon: IconDefinition;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.infra-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
</style>
|
58
@xen-orchestra/lite/src/components/infra/InfraHostItem.vue
Normal file
58
@xen-orchestra/lite/src/components/infra/InfraHostItem.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<li v-if="host" class="infra-host-item">
|
||||
<InfraItemLabel
|
||||
:current="isCurrentHost"
|
||||
:icon="faServer"
|
||||
:route="{ name: 'host.dashboard', params: { uuid: host.uuid } }"
|
||||
>
|
||||
{{ host.name_label }}
|
||||
<template #actions>
|
||||
<InfraAction
|
||||
:icon="isExpanded ? faAngleDown : faAngleUp"
|
||||
@click="toggle()"
|
||||
/>
|
||||
</template>
|
||||
</InfraItemLabel>
|
||||
|
||||
<InfraVmList v-show="isExpanded" :host-opaque-ref="hostOpaqueRef" />
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
faAngleDown,
|
||||
faAngleUp,
|
||||
faServer,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { useToggle } from "@vueuse/core";
|
||||
import InfraAction from "@/components/infra/InfraAction.vue";
|
||||
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
|
||||
import InfraVmList from "@/components/infra/InfraVmList.vue";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
import { useUiStore } from "@/stores/ui.store";
|
||||
|
||||
const props = defineProps<{
|
||||
hostOpaqueRef: string;
|
||||
}>();
|
||||
|
||||
const hostStore = useHostStore();
|
||||
const host = computed(() => hostStore.getRecord(props.hostOpaqueRef));
|
||||
|
||||
const uiStore = useUiStore();
|
||||
|
||||
const isCurrentHost = computed(
|
||||
() => props.hostOpaqueRef === uiStore.currentHostOpaqueRef
|
||||
);
|
||||
const [isExpanded, toggle] = useToggle();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.infra-host-item:deep(.link) {
|
||||
padding-left: 3rem;
|
||||
}
|
||||
|
||||
.infra-vm-list:deep(.link) {
|
||||
padding-left: 4.5rem;
|
||||
}
|
||||
</style>
|
21
@xen-orchestra/lite/src/components/infra/InfraHostList.vue
Normal file
21
@xen-orchestra/lite/src/components/infra/InfraHostList.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<ul class="infra-host-list">
|
||||
<li v-if="!isReady">Chargement des hosts en cours...</li>
|
||||
<template v-else>
|
||||
<InfraHostItem
|
||||
v-for="opaqueRef in opaqueRefs"
|
||||
:key="opaqueRef"
|
||||
:host-opaque-ref="opaqueRef"
|
||||
/>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from "pinia";
|
||||
import InfraHostItem from "@/components/infra/InfraHostItem.vue";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
|
||||
const hostStore = useHostStore();
|
||||
const { opaqueRefs, isReady } = storeToRefs(hostStore);
|
||||
</script>
|
83
@xen-orchestra/lite/src/components/infra/InfraItemLabel.vue
Normal file
83
@xen-orchestra/lite/src/components/infra/InfraItemLabel.vue
Normal file
@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<RouterLink v-slot="{ isActive, href, navigate }" :to="route" custom>
|
||||
<div
|
||||
:class="{ current: isActive || $props.current }"
|
||||
class="infra-item-label"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<a :href="href" class="link" @click="navigate">
|
||||
<FontAwesomeIcon :icon="icon" class="icon" />
|
||||
<div class="text">
|
||||
<slot />
|
||||
</div>
|
||||
</a>
|
||||
<div class="actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { RouteLocationRaw } from "vue-router";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
defineProps<{
|
||||
icon: IconDefinition;
|
||||
route: RouteLocationRaw;
|
||||
current?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.infra-item-label {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 6rem;
|
||||
margin-bottom: 0.2rem;
|
||||
color: var(--color-blue-scale-100);
|
||||
border-radius: 0.8rem;
|
||||
background-color: var(--background-color-primary);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-blue-scale-100);
|
||||
background-color: var(--background-color-secondary);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--color-extra-blue-base);
|
||||
background-color: var(--background-color-primary);
|
||||
}
|
||||
|
||||
&.current {
|
||||
color: var(--color-blue-scale-100);
|
||||
background-color: var(--background-color-extra-blue);
|
||||
|
||||
.icon {
|
||||
color: var(--color-extra-blue-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: 1.5rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.text {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<li class="infra-loading-item">
|
||||
<div class="infra-item-label-placeholder">
|
||||
<div class="link-placeholder">
|
||||
<FontAwesomeIcon :icon="icon" class="icon" />
|
||||
<div class="loader"> </div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
defineProps<{
|
||||
icon: IconDefinition;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.infra-item-label-placeholder {
|
||||
display: flex;
|
||||
height: 6rem;
|
||||
margin-bottom: 0.2rem;
|
||||
background-color: var(--background-color-primary);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--color-blue-scale-100);
|
||||
}
|
||||
|
||||
.link-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
padding: 0 1.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.loader {
|
||||
flex: 1;
|
||||
animation: pulse alternate 1s infinite;
|
||||
background-color: var(--background-color-extra-blue);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
42
@xen-orchestra/lite/src/components/infra/InfraPoolList.vue
Normal file
42
@xen-orchestra/lite/src/components/infra/InfraPoolList.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<ul class="infra-pool-list">
|
||||
<InfraLoadingItem v-if="!isReady" :icon="faBuilding" />
|
||||
<li v-else class="infra-pool-item">
|
||||
<InfraItemLabel
|
||||
:icon="faBuilding"
|
||||
:route="{ name: 'pool.dashboard', params: { uuid: pool.uuid } }"
|
||||
current
|
||||
>
|
||||
{{ pool.name_label }}
|
||||
</InfraItemLabel>
|
||||
|
||||
<InfraHostList />
|
||||
|
||||
<InfraVmList />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from "pinia";
|
||||
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
|
||||
import InfraHostList from "@/components/infra/InfraHostList.vue";
|
||||
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
|
||||
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
|
||||
import InfraVmList from "@/components/infra/InfraVmList.vue";
|
||||
import { usePoolStore } from "@/stores/pool.store";
|
||||
|
||||
const poolStore = usePoolStore();
|
||||
const { pool, isReady } = storeToRefs(poolStore);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.infra-pool-list {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.infra-vm-list:deep(.link) {
|
||||
padding-left: 3rem;
|
||||
}
|
||||
</style>
|
86
@xen-orchestra/lite/src/components/infra/InfraVmItem.vue
Normal file
86
@xen-orchestra/lite/src/components/infra/InfraVmItem.vue
Normal file
@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<li ref="rootElement" class="infra-vm-item">
|
||||
<InfraItemLabel
|
||||
v-if="isVisible"
|
||||
:icon="faDisplay"
|
||||
:route="{ name: 'vm.console', params: { uuid: vm.uuid } }"
|
||||
>
|
||||
{{ vm.name_label }}
|
||||
<template #actions>
|
||||
<InfraAction :class="powerStateClass" :icon="powerStateIcon" />
|
||||
</template>
|
||||
</InfraItemLabel>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
faDisplay,
|
||||
faMoon,
|
||||
faPause,
|
||||
faPlay,
|
||||
faStop,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { useIntersectionObserver } from "@vueuse/core";
|
||||
import InfraAction from "@/components/infra/InfraAction.vue";
|
||||
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
|
||||
const props = defineProps<{
|
||||
vmOpaqueRef: string;
|
||||
}>();
|
||||
|
||||
const rootElement = ref();
|
||||
const isVisible = ref(false);
|
||||
|
||||
const { stop } = useIntersectionObserver(rootElement, ([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
isVisible.value = true;
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
const vmStore = useVmStore();
|
||||
|
||||
const vm = computed(() => vmStore.getRecord(props.vmOpaqueRef));
|
||||
|
||||
const powerStateIcon = computed(() => {
|
||||
switch (vm.value?.power_state) {
|
||||
case "Running":
|
||||
return faPlay;
|
||||
case "Paused":
|
||||
return faPause;
|
||||
case "Suspended":
|
||||
return faMoon;
|
||||
default:
|
||||
return faStop;
|
||||
}
|
||||
});
|
||||
|
||||
const powerStateClass = computed(() =>
|
||||
vm.value?.power_state.toLocaleLowerCase()
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.infra-vm-item {
|
||||
height: 6rem;
|
||||
}
|
||||
|
||||
.infra-action {
|
||||
color: var(--color-extra-blue-d60);
|
||||
|
||||
&.running {
|
||||
color: var(--color-green-infra-base);
|
||||
}
|
||||
|
||||
&.paused {
|
||||
color: var(--color-blue-scale-300);
|
||||
}
|
||||
|
||||
&.suspended {
|
||||
color: var(--color-extra-blue-d20);
|
||||
}
|
||||
}
|
||||
</style>
|
32
@xen-orchestra/lite/src/components/infra/InfraVmList.vue
Normal file
32
@xen-orchestra/lite/src/components/infra/InfraVmList.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<ul class="infra-vm-list">
|
||||
<template v-if="!isReady">
|
||||
<InfraLoadingItem v-for="i in 3" :icon="faDisplay" :key="i" />
|
||||
</template>
|
||||
<InfraVmItem
|
||||
v-for="vmOpaqueRef in vmOpaqueRefs"
|
||||
:key="vmOpaqueRef"
|
||||
:vm-opaque-ref="vmOpaqueRef"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
|
||||
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
|
||||
import InfraVmItem from "@/components/infra/InfraVmItem.vue";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
|
||||
const props = defineProps<{
|
||||
hostOpaqueRef?: string;
|
||||
}>();
|
||||
|
||||
const vmStore = useVmStore();
|
||||
const { opaqueRefsByHostRef, isReady } = storeToRefs(vmStore);
|
||||
|
||||
const vmOpaqueRefs = computed(() =>
|
||||
opaqueRefsByHostRef.value.get(props.hostOpaqueRef ?? "OpaqueRef:NULL")
|
||||
);
|
||||
</script>
|
17
@xen-orchestra/lite/src/components/pool/PoolHeader.vue
Normal file
17
@xen-orchestra/lite/src/components/pool/PoolHeader.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<TitleBar :icon="faBuilding">
|
||||
{{ name }}
|
||||
</TitleBar>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
|
||||
import TitleBar from "@/components/TitleBar.vue";
|
||||
import { usePoolStore } from "@/stores/pool.store";
|
||||
|
||||
const poolStore = usePoolStore();
|
||||
const name = computed(() =>
|
||||
poolStore.isReady ? poolStore.pool.name_label : "..."
|
||||
);
|
||||
</script>
|
61
@xen-orchestra/lite/src/components/pool/PoolTabBar.vue
Normal file
61
@xen-orchestra/lite/src/components/pool/PoolTabBar.vue
Normal file
@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<TabBar>
|
||||
<TabBarItem
|
||||
:disabled="!isReady"
|
||||
:to="{ name: 'pool.dashboard', params: { uuid: poolUuid } }"
|
||||
>Dashboard</TabBarItem
|
||||
>
|
||||
<TabBarItem
|
||||
:disabled="!isReady"
|
||||
:to="{ name: 'pool.alarms', params: { uuid: poolUuid } }"
|
||||
>Alarms</TabBarItem
|
||||
>
|
||||
<TabBarItem
|
||||
:disabled="!isReady"
|
||||
:to="{ name: 'pool.stats', params: { uuid: poolUuid } }"
|
||||
>Stats</TabBarItem
|
||||
>
|
||||
<TabBarItem
|
||||
:disabled="!isReady"
|
||||
:to="{ name: 'pool.system', params: { uuid: poolUuid } }"
|
||||
>System</TabBarItem
|
||||
>
|
||||
<TabBarItem
|
||||
:disabled="!isReady"
|
||||
:to="{ name: 'pool.network', params: { uuid: poolUuid } }"
|
||||
>Network</TabBarItem
|
||||
>
|
||||
<TabBarItem
|
||||
:disabled="!isReady"
|
||||
:to="{ name: 'pool.storage', params: { uuid: poolUuid } }"
|
||||
>Storage</TabBarItem
|
||||
>
|
||||
<TabBarItem
|
||||
:disabled="!isReady"
|
||||
:to="{ name: 'pool.tasks', params: { uuid: poolUuid } }"
|
||||
>Tasks</TabBarItem
|
||||
>
|
||||
<TabBarItem
|
||||
:disabled="!isReady"
|
||||
:to="{ name: 'pool.hosts', params: { uuid: poolUuid } }"
|
||||
>Hosts</TabBarItem
|
||||
>
|
||||
<TabBarItem
|
||||
:disabled="!isReady"
|
||||
:to="{ name: 'pool.vms', params: { uuid: poolUuid } }"
|
||||
>VMs</TabBarItem
|
||||
>
|
||||
</TabBar>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import TabBar from "@/components/TabBar.vue";
|
||||
import TabBarItem from "@/components/TabBarItem.vue";
|
||||
import { usePoolStore } from "@/stores/pool.store";
|
||||
|
||||
const poolStore = usePoolStore();
|
||||
const { pool, isReady } = storeToRefs(poolStore);
|
||||
const poolUuid = computed(() => pool.value?.uuid);
|
||||
</script>
|
@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<UiCard>
|
||||
<UiTitle type="h4">Status</UiTitle>
|
||||
<template v-if="isReady">
|
||||
<PoolDashboardStatusItem
|
||||
:active="activeHostsCount"
|
||||
:total="totalHostsCount"
|
||||
label="Hosts"
|
||||
/>
|
||||
<UiSeparator />
|
||||
<PoolDashboardStatusItem
|
||||
:active="activeVmsCount"
|
||||
:total="totalVmsCount"
|
||||
label="VMs"
|
||||
/>
|
||||
</template>
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import PoolDashboardStatusItem from "@/components/pool/dashboard/PoolDashboardStatusItem.vue";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiSeparator from "@/components/ui/UiSeparator.vue";
|
||||
import UiTitle from "@/components/ui/UiTitle.vue";
|
||||
import { useHostMetricsStore } from "@/stores/host-metrics.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
|
||||
const vmStore = useVmStore();
|
||||
const hostMetricsStore = useHostMetricsStore();
|
||||
|
||||
const isReady = computed(() => vmStore.isReady && hostMetricsStore.isReady);
|
||||
|
||||
const totalHostsCount = computed(() => hostMetricsStore.opaqueRefs.length);
|
||||
const activeHostsCount = computed(() => {
|
||||
return hostMetricsStore.opaqueRefs.filter(
|
||||
(opaqueRef) => hostMetricsStore.getRecord(opaqueRef)?.live
|
||||
).length;
|
||||
});
|
||||
|
||||
const totalVmsCount = computed(() => vmStore.opaqueRefs.length);
|
||||
const activeVmsCount = computed(() => {
|
||||
return vmStore.opaqueRefs.filter(
|
||||
(opaqueRef) => vmStore.getRecord(opaqueRef)?.power_state === "Running"
|
||||
).length;
|
||||
});
|
||||
</script>
|
@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="pool-dashboard-status-item">
|
||||
<ProgressCircle :max-value="total" :value="active" />
|
||||
<div class="content">
|
||||
<UiTitle type="h5">{{ label }}</UiTitle>
|
||||
<div class="status-line">
|
||||
<div class="bullet" />
|
||||
<div class="label">Active</div>
|
||||
<div class="count">{{ active }}</div>
|
||||
</div>
|
||||
<div class="status-line">
|
||||
<div class="bullet inactive" />
|
||||
<div class="label">Inactive</div>
|
||||
<div class="count">{{ inactive }}</div>
|
||||
</div>
|
||||
<div class="total">
|
||||
Total <span>{{ total }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import ProgressCircle from "@/components/ProgressCircle.vue";
|
||||
import UiTitle from "@/components/ui/UiTitle.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
label: string;
|
||||
active: number;
|
||||
total: number;
|
||||
}>();
|
||||
|
||||
const inactive = computed(() => props.total - props.active);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.pool-dashboard-status-item {
|
||||
display: flex;
|
||||
gap: 3.4rem;
|
||||
}
|
||||
|
||||
.progress-circle {
|
||||
width: 6.4rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bullet {
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
border-radius: 0.65rem;
|
||||
background-color: var(--color-green-infra-base);
|
||||
|
||||
&.inactive {
|
||||
background-color: var(--color-blue-scale-400);
|
||||
}
|
||||
}
|
||||
|
||||
.status-line {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex: 1;
|
||||
font-weight: 400;
|
||||
font-size: 1.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 1rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
17
@xen-orchestra/lite/src/components/ui/UiBadge.vue
Normal file
17
@xen-orchestra/lite/src/components/ui/UiBadge.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<span class="ui-badge">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-badge {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
line-height: 100%;
|
||||
padding: 0.1rem 0.5rem;
|
||||
color: var(--color-blue-scale-500);
|
||||
border-radius: 9.6rem;
|
||||
background-color: var(--color-blue-scale-300);
|
||||
}
|
||||
</style>
|
70
@xen-orchestra/lite/src/components/ui/UiButton.vue
Normal file
70
@xen-orchestra/lite/src/components/ui/UiButton.vue
Normal file
@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<button
|
||||
:class="`color-${buttonColor}`"
|
||||
:disabled="isBusy || isDisabled"
|
||||
:type="type || 'button'"
|
||||
class="ui-button"
|
||||
>
|
||||
<FontAwesomeIcon v-if="isBusy" :icon="faSpinner" spin />
|
||||
<FontAwesomeIcon v-else-if="iconLeft" :icon="iconLeft" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, unref } from "vue";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const props = defineProps<{
|
||||
type?: "button" | "reset" | "submit";
|
||||
busy?: boolean;
|
||||
disabled?: boolean;
|
||||
iconLeft?: IconDefinition;
|
||||
color?: "default" | "action";
|
||||
}>();
|
||||
|
||||
const isGroupBusy = inject("isButtonGroupBusy", false);
|
||||
const isBusy = computed(() => props.busy || unref(isGroupBusy));
|
||||
|
||||
const isGroupDisabled = inject("isButtonGroupDisabled", false);
|
||||
const isDisabled = computed(() => props.disabled || unref(isGroupDisabled));
|
||||
|
||||
const buttonGroupColor = inject("buttonGroupColor", "default");
|
||||
const buttonColor = computed(() => props.color || unref(buttonGroupColor));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ui-button {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 400;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 3.8rem;
|
||||
margin: 0;
|
||||
padding: 0 1rem;
|
||||
color: var(--color-blue-scale-500);
|
||||
border: none;
|
||||
border-radius: 0.8rem;
|
||||
background-color: var(--color-extra-blue-base);
|
||||
gap: 1rem;
|
||||
|
||||
&:not([disabled]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.color-action {
|
||||
color: var(--color-grayscale-200);
|
||||
background-color: var(--color-white);
|
||||
|
||||
&:not([disabled]):hover {
|
||||
background-color: var(--background-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
27
@xen-orchestra/lite/src/components/ui/UiButtonGroup.vue
Normal file
27
@xen-orchestra/lite/src/components/ui/UiButtonGroup.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="ui-button-group">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { provide, toRef } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
busy?: boolean;
|
||||
disabled?: boolean;
|
||||
color?: "default" | "action";
|
||||
}>();
|
||||
provide("isButtonGroupBusy", toRef(props, "busy"));
|
||||
provide("isButtonGroupDisabled", toRef(props, "disabled"));
|
||||
provide("buttonGroupColor", toRef(props, "color"));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ui-button-group {
|
||||
display: flex;
|
||||
justify-content: left;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
14
@xen-orchestra/lite/src/components/ui/UiCard.vue
Normal file
14
@xen-orchestra/lite/src/components/ui/UiCard.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div class="ui-card">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-card {
|
||||
padding: 2.1rem;
|
||||
border-radius: 0.8rem;
|
||||
background-color: var(--background-color-primary);
|
||||
box-shadow: var(--shadow-200);
|
||||
}
|
||||
</style>
|
35
@xen-orchestra/lite/src/components/ui/UiFilter.vue
Normal file
35
@xen-orchestra/lite/src/components/ui/UiFilter.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<span class="ui-filter">
|
||||
<slot />
|
||||
<FontAwesomeIcon :icon="faRemove" class="remove" @click="emit('remove')" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { faRemove } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "remove"): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ui-filter {
|
||||
font-size: 1.6rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 3.4rem;
|
||||
padding: 0 1rem;
|
||||
color: var(--color-extra-blue-base);
|
||||
border: 1px solid var(--color-extra-blue-base);
|
||||
border-radius: 1.7rem;
|
||||
background-color: var(--background-color-extra-blue);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.remove {
|
||||
cursor: pointer;
|
||||
color: red;
|
||||
}
|
||||
</style>
|
16
@xen-orchestra/lite/src/components/ui/UiFilterGroup.vue
Normal file
16
@xen-orchestra/lite/src/components/ui/UiFilterGroup.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="ui-filter-group">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style scoped>
|
||||
.ui-filter-group {
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
background-color: var(--background-color-primary);
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
38
@xen-orchestra/lite/src/components/ui/UiModal.vue
Normal file
38
@xen-orchestra/lite/src/components/ui/UiModal.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="ui-modal">
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style scoped>
|
||||
.ui-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #00000080;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
background-color: white;
|
||||
min-width: 40rem;
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
box-shadow: var(--shadow-400);
|
||||
}
|
||||
|
||||
:slotted(.ui-button-group) {
|
||||
justify-content: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
11
@xen-orchestra/lite/src/components/ui/UiSeparator.vue
Normal file
11
@xen-orchestra/lite/src/components/ui/UiSeparator.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<hr class="ui-separator" />
|
||||
</template>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-separator {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-blue-scale-400);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
</style>
|
45
@xen-orchestra/lite/src/components/ui/UiTable.vue
Normal file
45
@xen-orchestra/lite/src/components/ui/UiTable.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<table class="ui-table">
|
||||
<thead>
|
||||
<tr class="header-row">
|
||||
<slot name="header" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="body">
|
||||
<slot />
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style scoped>
|
||||
.ui-table {
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
:slotted(th),
|
||||
:slotted(td) {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid lightgrey;
|
||||
border-right: 1px solid lightgrey;
|
||||
text-align: left;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
:slotted(.header-row th) {
|
||||
color: var(--color-extra-blue-base);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 400;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
:slotted(.body td) {
|
||||
font-weight: 400;
|
||||
font-size: 1.6rem;
|
||||
line-height: 2.4rem;
|
||||
}
|
||||
</style>
|
65
@xen-orchestra/lite/src/components/ui/UiTitle.vue
Normal file
65
@xen-orchestra/lite/src/components/ui/UiTitle.vue
Normal file
@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<component :is="tag" :class="type" class="ui-title">
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
type: "display" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
}>();
|
||||
|
||||
const tag = computed(() => {
|
||||
if (props.type === "display") {
|
||||
return "h1";
|
||||
}
|
||||
|
||||
return props.type;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-title {
|
||||
line-height: 150%;
|
||||
align-self: stretch;
|
||||
flex-grow: 0;
|
||||
color: var(--color-blue-scale-100);
|
||||
|
||||
&.display {
|
||||
font-size: 6.4rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
&.h1 {
|
||||
font-size: 4.8rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
&.h2 {
|
||||
font-size: 3.6rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&.h3 {
|
||||
font-size: 2.4rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
&.h4 {
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.h5 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.h6 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
</style>
|
46
@xen-orchestra/lite/src/components/vm/VmsActionsBar.vue
Normal file
46
@xen-orchestra/lite/src/components/vm/VmsActionsBar.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<UiButtonGroup
|
||||
:disabled="selectedRefs.length === 0"
|
||||
class="vms-actions-bar"
|
||||
color="action"
|
||||
>
|
||||
<UiButton :icon-left="faPowerOff">Change power state</UiButton>
|
||||
<UiButton :icon-left="faRoute">Migrate</UiButton>
|
||||
<UiButton :icon-left="faCopy">Copy</UiButton>
|
||||
<UiButton :icon-left="faEdit">Edit config</UiButton>
|
||||
<UiButton :icon-left="faCamera">Snapshot</UiButton>
|
||||
<UiButton :icon-left="faBox">Backup</UiButton>
|
||||
<UiButton :icon-left="faTrashCan">Delete</UiButton>
|
||||
<UiButton :icon-left="faFileExport">Export</UiButton>
|
||||
</UiButtonGroup>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
faCopy,
|
||||
faEdit,
|
||||
faTrashCan,
|
||||
} from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faBox,
|
||||
faCamera,
|
||||
faFileExport,
|
||||
faPowerOff,
|
||||
faRoute,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
|
||||
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
selectedRefs: string[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vms-actions-bar {
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-blue-scale-400);
|
||||
background-color: var(--background-color-primary);
|
||||
}
|
||||
</style>
|
22
@xen-orchestra/lite/src/composables/busy.composable.md
Normal file
22
@xen-orchestra/lite/src/composables/busy.composable.md
Normal file
@ -0,0 +1,22 @@
|
||||
# useBusy composable
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<span class="error" v-if="error">{{ error }}</span>
|
||||
<button @click="run" :disabled="isBusy">Do something</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import useBusy from "@/composables/busy.composable";
|
||||
|
||||
async function doSomething() {
|
||||
try {
|
||||
// Doing some async work
|
||||
} catch (e) {
|
||||
throw "Something bad happened";
|
||||
}
|
||||
}
|
||||
|
||||
const { isBusy, error, run } = useBusy(doSomething);
|
||||
</script>
|
||||
```
|
26
@xen-orchestra/lite/src/composables/busy.composable.ts
Normal file
26
@xen-orchestra/lite/src/composables/busy.composable.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
export default function useBusy<T extends (...args: any[]) => any>(func: T) {
|
||||
const isBusy = ref(false);
|
||||
const error = ref<any>();
|
||||
|
||||
const run = async (...args: Parameters<T>): Promise<ReturnType<T>> => {
|
||||
error.value = undefined;
|
||||
isBusy.value = true;
|
||||
|
||||
try {
|
||||
return await func(...args);
|
||||
} catch (_error) {
|
||||
error.value = _error;
|
||||
throw _error;
|
||||
} finally {
|
||||
isBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isBusy,
|
||||
error,
|
||||
run,
|
||||
};
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
# useCollectionFilter composable
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<CollectionFilter
|
||||
:active-filters="filters"
|
||||
:available-filters="availableFilters"
|
||||
@add-filter="addFilter"
|
||||
@remove-filter="removeFilter"
|
||||
/>
|
||||
|
||||
<div v-for="item in filteredCollection">...</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import CollectionFilter from "@/components/CollectionFilter.vue";
|
||||
import useCollectionFilter from "@/composables/collection-filter.composable";
|
||||
|
||||
const collection = [
|
||||
{ name: "Foo", age: 5, registered: true },
|
||||
{ name: "Bar", age: 12, registered: false },
|
||||
{ name: "Foo Bar", age: 2, registered: true },
|
||||
{ name: "Bar Baz", age: 45, registered: false },
|
||||
{ name: "Foo Baz", age: 32, registered: false },
|
||||
{ name: "Foo Bar Baz", age: 32, registered: true },
|
||||
];
|
||||
|
||||
const availableFilters: AvailableFilter[] = [
|
||||
{ property: "name", label: "Name", type: "string" },
|
||||
{ property: "age", label: "Age", type: "number" },
|
||||
{ property: "registered", label: "Registered", type: "boolean", icon: faKey },
|
||||
];
|
||||
|
||||
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter();
|
||||
|
||||
const filteredCollection = computed(() => collection.filter(predicate));
|
||||
</script>
|
||||
```
|
@ -0,0 +1,56 @@
|
||||
import * as CM from "complex-matcher";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { type LocationQueryValue, useRoute, useRouter } from "vue-router";
|
||||
|
||||
interface Config {
|
||||
queryStringParam?: string;
|
||||
}
|
||||
|
||||
export default function useCollectionFilter(
|
||||
config: Config = { queryStringParam: "filter" }
|
||||
) {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const filtersSet = ref(
|
||||
config.queryStringParam
|
||||
? queryToSet(route.query[config.queryStringParam] as LocationQueryValue)
|
||||
: new Set<string>()
|
||||
);
|
||||
const filters = computed(() => Array.from(filtersSet.value.values()));
|
||||
|
||||
if (config.queryStringParam) {
|
||||
const queryStringParam = config.queryStringParam;
|
||||
watch(filters, (value) => {
|
||||
router.replace({ query: { [queryStringParam]: value.join(" ") } });
|
||||
});
|
||||
}
|
||||
|
||||
const addFilter = (filter: string) => {
|
||||
filtersSet.value.add(filter);
|
||||
};
|
||||
|
||||
const removeFilter = (filter: string) => {
|
||||
filtersSet.value.delete(filter);
|
||||
};
|
||||
|
||||
const predicate = computed(() => {
|
||||
return CM.parse(
|
||||
Array.from(filters.value.values()).join(" ")
|
||||
).createPredicate();
|
||||
});
|
||||
|
||||
return {
|
||||
filters,
|
||||
addFilter,
|
||||
removeFilter,
|
||||
predicate,
|
||||
};
|
||||
}
|
||||
|
||||
function queryToSet(filter: LocationQueryValue): Set<string> {
|
||||
if (filter) {
|
||||
return new Set(filter.split(" "));
|
||||
}
|
||||
|
||||
return new Set();
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
# useFilteredCollection composable
|
||||
|
||||
```vue
|
||||
<script lang="ts" setup>
|
||||
import useFilteredCollection from "./filtered-collection.composable";
|
||||
|
||||
const players = [
|
||||
{ name: "Foo", team: "Blue" },
|
||||
{ name: "Bar", team: "Red" },
|
||||
{ name: "Baz", team: "Blue" },
|
||||
];
|
||||
|
||||
const bluePlayers = useFilteredCollection(
|
||||
players,
|
||||
(player) => player.team === "Blue"
|
||||
);
|
||||
</script>
|
||||
```
|
@ -0,0 +1,13 @@
|
||||
import { computed, unref } from "vue";
|
||||
import type { MaybeRef } from "@vueuse/core";
|
||||
|
||||
export default function useFilteredCollection<T>(
|
||||
collection: MaybeRef<T[]>,
|
||||
predicate: MaybeRef<(value: T) => boolean>
|
||||
) {
|
||||
const filteredCollection = computed(() => {
|
||||
return unref(collection).filter(unref(predicate));
|
||||
});
|
||||
|
||||
return filteredCollection;
|
||||
}
|
32
@xen-orchestra/lite/src/composables/modal.composable.md
Normal file
32
@xen-orchestra/lite/src/composables/modal.composable.md
Normal file
@ -0,0 +1,32 @@
|
||||
# useModal composable
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div v-for="item in items">
|
||||
{{ item.name }} <button @click="openRemoveModal(item)">Delete</button>
|
||||
</div>
|
||||
|
||||
<UiModal v-if="isRemoveModalOpen">
|
||||
Are you sure you want to delete {{ removeModalPayload.name }}
|
||||
|
||||
<button @click="handleRemove">Yes</button>
|
||||
<button @click="closeRemoveModal">No</button>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import useModal from "@/composables/modal.composable";
|
||||
|
||||
const {
|
||||
payload: removeModalPayload,
|
||||
isOpen: isRemoveModalOpen,
|
||||
open: openRemoveModal,
|
||||
close: closeRemoveModal,
|
||||
} = useModal();
|
||||
|
||||
async function handleRemove() {
|
||||
await removeItem(removeModalPayload.id);
|
||||
closeRemoveModal();
|
||||
}
|
||||
</script>
|
||||
```
|
23
@xen-orchestra/lite/src/composables/modal.composable.ts
Normal file
23
@xen-orchestra/lite/src/composables/modal.composable.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
export default function useModal() {
|
||||
const $payload = ref();
|
||||
const $isOpen = ref(false);
|
||||
|
||||
const open = (payload?: any) => {
|
||||
$isOpen.value = true;
|
||||
$payload.value = payload;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
$isOpen.value = false;
|
||||
$payload.value = undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
payload: $payload,
|
||||
isOpen: $isOpen,
|
||||
open,
|
||||
close,
|
||||
};
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
# useMultiSelect composable
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input type="checkbox" v-model="areAllSelected" />
|
||||
</th>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items">
|
||||
<td>
|
||||
<input type="checkbox" :value="item.id" v-model="selected" />
|
||||
</td>
|
||||
<td>{{ item.name }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- You can use something else than a "Select All" checkbox -->
|
||||
<button @click="areAllSelected = !areAllSelected">Toggle all selected</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import useMultiSelect from "./multi-select.composable";
|
||||
|
||||
const { selected, areAllSelected } = useMultiSelect();
|
||||
</script>
|
||||
```
|
@ -0,0 +1,40 @@
|
||||
import { computed, ref, unref } from "vue";
|
||||
import type { MaybeRef } from "@vueuse/core";
|
||||
|
||||
export default function useMultiSelect<T>(
|
||||
usableIds: MaybeRef<T[]>,
|
||||
selectableIds?: MaybeRef<T[]>
|
||||
) {
|
||||
const $selected = ref<Set<T>>(new Set());
|
||||
|
||||
const selected = computed({
|
||||
get() {
|
||||
return unref(usableIds).filter((usableId) =>
|
||||
$selected.value.has(usableId)
|
||||
);
|
||||
},
|
||||
set(ids: T[]) {
|
||||
$selected.value = new Set(ids);
|
||||
},
|
||||
});
|
||||
|
||||
const areAllSelected = computed({
|
||||
get() {
|
||||
return (unref(selectableIds) ?? unref(usableIds)).every((id) =>
|
||||
$selected.value.has(id)
|
||||
);
|
||||
},
|
||||
set(value: boolean) {
|
||||
if (value) {
|
||||
$selected.value = new Set(unref(selectableIds) ?? unref(usableIds));
|
||||
} else {
|
||||
$selected.value = new Set();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
selected,
|
||||
areAllSelected,
|
||||
};
|
||||
}
|
49
@xen-orchestra/lite/src/composables/sortable.composable.ts
Normal file
49
@xen-orchestra/lite/src/composables/sortable.composable.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { computed, ref, unref } from "vue";
|
||||
import type { MaybeRef } from "@vueuse/core";
|
||||
|
||||
export default function useSortable<T>(data: MaybeRef<T[]>) {
|
||||
const sortProperty = ref<keyof T>();
|
||||
const sortAscending = ref(true);
|
||||
const sortBy = (property: keyof T) => {
|
||||
if (sortProperty.value !== property) {
|
||||
sortProperty.value = property;
|
||||
sortAscending.value = true;
|
||||
} else if (sortAscending.value) {
|
||||
sortAscending.value = !sortAscending.value;
|
||||
} else {
|
||||
sortAscending.value = true;
|
||||
sortProperty.value = undefined;
|
||||
}
|
||||
};
|
||||
const isSortedBy = (property: keyof T) => {
|
||||
return sortProperty.value === property;
|
||||
};
|
||||
const isAscending = computed(() => sortAscending.value === true);
|
||||
const records = computed(() => {
|
||||
let records = [...unref(data)];
|
||||
|
||||
if (sortProperty.value) {
|
||||
records = records.sort((record1, record2) => {
|
||||
const value1 = record1[sortProperty.value!];
|
||||
const value2 = record2[sortProperty.value!];
|
||||
switch (true) {
|
||||
case value1 < value2:
|
||||
return sortAscending.value ? -1 : 1;
|
||||
case value1 > value2:
|
||||
return sortAscending.value ? 1 : -1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return records;
|
||||
});
|
||||
|
||||
return {
|
||||
sortBy,
|
||||
records,
|
||||
isAscending,
|
||||
isSortedBy,
|
||||
};
|
||||
}
|
20
@xen-orchestra/lite/src/libs/utils.ts
Normal file
20
@xen-orchestra/lite/src/libs/utils.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export function sortRecordsByNameLabel(
|
||||
record1: { name_label: string },
|
||||
record2: { name_label: string }
|
||||
) {
|
||||
const label1 = record1.name_label.toLocaleLowerCase();
|
||||
const label2 = record2.name_label.toLocaleLowerCase();
|
||||
|
||||
switch (true) {
|
||||
case label1 < label2:
|
||||
return -1;
|
||||
case label1 > label2:
|
||||
return 1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeRegExp(string: string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
251
@xen-orchestra/lite/src/libs/xen-api.ts
Normal file
251
@xen-orchestra/lite/src/libs/xen-api.ts
Normal file
@ -0,0 +1,251 @@
|
||||
import { JSONRPCClient } from "json-rpc-2.0";
|
||||
|
||||
export type RawObjectType =
|
||||
| "Bond"
|
||||
| "Certificate"
|
||||
| "Cluster"
|
||||
| "Cluster_host"
|
||||
| "DR_task"
|
||||
| "Feature"
|
||||
| "GPU_group"
|
||||
| "PBD"
|
||||
| "PCI"
|
||||
| "PGPU"
|
||||
| "PIF"
|
||||
| "PIF_metrics"
|
||||
| "PUSB"
|
||||
| "PVS_cache_storage"
|
||||
| "PVS_proxy"
|
||||
| "PVS_server"
|
||||
| "PVS_site"
|
||||
| "SDN_controller"
|
||||
| "SM"
|
||||
| "SR"
|
||||
| "USB_group"
|
||||
| "VBD"
|
||||
| "VBD_metrics"
|
||||
| "VDI"
|
||||
| "VGPU"
|
||||
| "VGPU_type"
|
||||
| "VIF"
|
||||
| "VIF_metrics"
|
||||
| "VLAN"
|
||||
| "VM"
|
||||
| "VMPP"
|
||||
| "VMSS"
|
||||
| "VM_appliance"
|
||||
| "VM_guest_metrics"
|
||||
| "VM_metrics"
|
||||
| "VUSB"
|
||||
| "blob"
|
||||
| "console"
|
||||
| "crashdump"
|
||||
| "host"
|
||||
| "host_cpu"
|
||||
| "host_crashdump"
|
||||
| "host_metrics"
|
||||
| "host_patch"
|
||||
| "network"
|
||||
| "network_sriov"
|
||||
| "pool"
|
||||
| "pool_patch"
|
||||
| "pool_update"
|
||||
| "role"
|
||||
| "secret"
|
||||
| "subject"
|
||||
| "task"
|
||||
| "tunnel";
|
||||
|
||||
export type PowerState = "Running" | "Paused" | "Halted" | "Suspended";
|
||||
|
||||
export type ObjectType = Lowercase<RawObjectType>;
|
||||
|
||||
export interface XenApiRecord {
|
||||
$ref: string;
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
type RawXenApiRecord<T extends XenApiRecord> = Omit<T, "$ref">;
|
||||
|
||||
export interface XenApiPool extends XenApiRecord {
|
||||
name_label: string;
|
||||
}
|
||||
|
||||
export interface XenApiHost extends XenApiRecord {
|
||||
name_label: string;
|
||||
metrics: string;
|
||||
resident_VMs: string[];
|
||||
}
|
||||
|
||||
export interface XenApiVm extends XenApiRecord {
|
||||
name_label: string;
|
||||
name_description: string;
|
||||
power_state: PowerState;
|
||||
resident_on: string;
|
||||
consoles: string[];
|
||||
is_control_domain: boolean;
|
||||
is_a_snapshot: boolean;
|
||||
is_a_template: boolean;
|
||||
}
|
||||
|
||||
export interface XenApiConsole extends XenApiRecord {
|
||||
protocol: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface XenApiHostMetrics extends XenApiRecord {
|
||||
live: boolean;
|
||||
memory_free: number;
|
||||
memory_total: number;
|
||||
}
|
||||
|
||||
export type XenApiVmMetrics = XenApiRecord;
|
||||
|
||||
export type XenApiVmGuestMetrics = XenApiRecord;
|
||||
|
||||
type WatchCallbackResult = {
|
||||
id: string;
|
||||
class: ObjectType;
|
||||
operation: "add" | "mod" | "del";
|
||||
ref: string;
|
||||
snapshot: object;
|
||||
};
|
||||
|
||||
type WatchCallback = (results: WatchCallbackResult[]) => void;
|
||||
|
||||
export default class XenApi {
|
||||
#client: JSONRPCClient;
|
||||
#sessionId: string | undefined;
|
||||
|
||||
#types: string[] = [];
|
||||
|
||||
#watchCallBack: WatchCallback | undefined;
|
||||
#watching = false;
|
||||
#fromToken: string | undefined;
|
||||
|
||||
constructor(hostUrl: string) {
|
||||
this.#client = new JSONRPCClient((request) => {
|
||||
return fetch(`${hostUrl}/jsonrpc`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
}).then((response) => {
|
||||
if (response.status === 200) {
|
||||
return response.json().then((json) => this.#client.receive(json));
|
||||
} else if (request.id !== undefined) {
|
||||
return Promise.reject(new Error(response.statusText));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async connectWithPassword(username: string, password: string) {
|
||||
this.#sessionId = await this.#call("session.login_with_password", [
|
||||
username,
|
||||
password,
|
||||
]);
|
||||
|
||||
await this.loadTypes();
|
||||
|
||||
return this.#sessionId;
|
||||
}
|
||||
|
||||
async connectWithSessionId(sessionId: string) {
|
||||
this.#sessionId = sessionId;
|
||||
|
||||
try {
|
||||
await this.#call("session.get_all_subject_identifiers", [
|
||||
this.#sessionId,
|
||||
]);
|
||||
|
||||
await this.loadTypes();
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error?.message === "SESSION_INVALID") {
|
||||
return false;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.stopWatch();
|
||||
this.#sessionId = undefined;
|
||||
}
|
||||
|
||||
async loadTypes() {
|
||||
this.#types = (await this.#call<string[]>("system.listMethods"))
|
||||
.filter((method: string) => method.endsWith(".get_all_records"))
|
||||
.map((method: string) => method.slice(0, method.indexOf(".")))
|
||||
.filter((type: string) => type !== "message");
|
||||
}
|
||||
|
||||
get sessionId() {
|
||||
return this.#sessionId;
|
||||
}
|
||||
|
||||
#call<T = any>(method: string, args: any[] = []): PromiseLike<T> {
|
||||
return this.#client.request(method, args);
|
||||
}
|
||||
|
||||
async loadRecords<T extends XenApiRecord>(
|
||||
type: RawObjectType
|
||||
): Promise<Map<string, T>> {
|
||||
const result = await this.#call<{ [key: string]: RawXenApiRecord<T> }>(
|
||||
`${type}.get_all_records`,
|
||||
[this.sessionId]
|
||||
);
|
||||
|
||||
const entries = Object.entries(result).map<[string, T]>(([key, entry]) => [
|
||||
key,
|
||||
{ $ref: key, ...entry } as T,
|
||||
]);
|
||||
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
async #watch() {
|
||||
if (!this.#fromToken) {
|
||||
throw new Error("call `injectWatchEvent` before startWatch");
|
||||
}
|
||||
// load pools
|
||||
while (this.#watching) {
|
||||
if (!this.#watchCallBack) {
|
||||
// no callback , skip this call
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
const result: { token: string; events: any } = await this.#call(
|
||||
"event.from",
|
||||
[this.sessionId, this.#types, this.#fromToken, 5.001]
|
||||
);
|
||||
this.#fromToken = result.token;
|
||||
this.#watchCallBack?.(result.events);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
startWatch() {
|
||||
this.#watching = true;
|
||||
this.#watch();
|
||||
}
|
||||
|
||||
stopWatch() {
|
||||
this.#watchCallBack = undefined;
|
||||
this.#watching = false;
|
||||
}
|
||||
|
||||
registerWatchCallBack(callback: WatchCallback) {
|
||||
this.#watchCallBack = callback;
|
||||
}
|
||||
|
||||
async injectWatchEvent(poolRef: string) {
|
||||
this.#fromToken = await this.#call("event.inject", [
|
||||
this.sessionId,
|
||||
"pool",
|
||||
poolRef,
|
||||
]);
|
||||
}
|
||||
}
|
13
@xen-orchestra/lite/src/main.ts
Normal file
13
@xen-orchestra/lite/src/main.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp } from "vue";
|
||||
import App from "@/App.vue";
|
||||
import router from "@/router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.component("FontAwesomeIcon", FontAwesomeIcon);
|
||||
|
||||
app.mount("#app");
|
49
@xen-orchestra/lite/src/router/index.ts
Normal file
49
@xen-orchestra/lite/src/router/index.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import pool from "@/router/pool";
|
||||
import DemoView from "@/views/DemoView.vue";
|
||||
import HomeView from "@/views/HomeView.vue";
|
||||
import HostDashboardView from "@/views/host/HostDashboardView.vue";
|
||||
import HostRootView from "@/views/host/HostRootView.vue";
|
||||
import VmConsoleView from "@/views/vm/VmConsoleView.vue";
|
||||
import VmRootView from "@/views/vm/VmRootView.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: "/demo",
|
||||
name: "demo",
|
||||
component: DemoView,
|
||||
},
|
||||
pool,
|
||||
{
|
||||
path: "/host/:uuid",
|
||||
component: HostRootView,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "host.dashboard",
|
||||
component: HostDashboardView,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/vm/:uuid",
|
||||
component: VmRootView,
|
||||
children: [
|
||||
{
|
||||
path: "console",
|
||||
name: "vm.console",
|
||||
component: VmConsoleView,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default router;
|
63
@xen-orchestra/lite/src/router/pool.ts
Normal file
63
@xen-orchestra/lite/src/router/pool.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import PoolAlarmsView from "@/views/pool/PoolAlarmsView.vue";
|
||||
import PoolDashboardView from "@/views/pool/PoolDashboardView.vue";
|
||||
import PoolHostsView from "@/views/pool/PoolHostsView.vue";
|
||||
import PoolNetworkView from "@/views/pool/PoolNetworkView.vue";
|
||||
import PoolRootView from "@/views/pool/PoolRootView.vue";
|
||||
import PoolStatsView from "@/views/pool/PoolStatsView.vue";
|
||||
import PoolStorageView from "@/views/pool/PoolStorageView.vue";
|
||||
import PoolSystemView from "@/views/pool/PoolSystemView.vue";
|
||||
import PoolTasksView from "@/views/pool/PoolTasksView.vue";
|
||||
import PoolVmsView from "@/views/pool/PoolVmsView.vue";
|
||||
|
||||
export default {
|
||||
path: "/pool/:uuid",
|
||||
component: PoolRootView,
|
||||
redirect: { name: "pool.dashboard" },
|
||||
children: [
|
||||
{
|
||||
path: "dashboard",
|
||||
name: "pool.dashboard",
|
||||
component: PoolDashboardView,
|
||||
},
|
||||
{
|
||||
path: "alarms",
|
||||
name: "pool.alarms",
|
||||
component: PoolAlarmsView,
|
||||
},
|
||||
{
|
||||
path: "stats",
|
||||
name: "pool.stats",
|
||||
component: PoolStatsView,
|
||||
},
|
||||
{
|
||||
path: "system",
|
||||
name: "pool.system",
|
||||
component: PoolSystemView,
|
||||
},
|
||||
{
|
||||
path: "network",
|
||||
name: "pool.network",
|
||||
component: PoolNetworkView,
|
||||
},
|
||||
{
|
||||
path: "storage",
|
||||
name: "pool.storage",
|
||||
component: PoolStorageView,
|
||||
},
|
||||
{
|
||||
path: "tasks",
|
||||
name: "pool.tasks",
|
||||
component: PoolTasksView,
|
||||
},
|
||||
{
|
||||
path: "hosts",
|
||||
name: "pool.hosts",
|
||||
component: PoolHostsView,
|
||||
},
|
||||
{
|
||||
path: "vms",
|
||||
name: "pool.vms",
|
||||
component: PoolVmsView,
|
||||
},
|
||||
],
|
||||
};
|
7
@xen-orchestra/lite/src/stores/console.store.ts
Normal file
7
@xen-orchestra/lite/src/stores/console.store.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineStore } from "pinia";
|
||||
import type { XenApiConsole } from "@/libs/xen-api";
|
||||
import { createRecordContext } from "@/stores/index";
|
||||
|
||||
export const useConsoleStore = defineStore("console", () =>
|
||||
createRecordContext<XenApiConsole>("console")
|
||||
);
|
7
@xen-orchestra/lite/src/stores/host-metrics.store.ts
Normal file
7
@xen-orchestra/lite/src/stores/host-metrics.store.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineStore } from "pinia";
|
||||
import type { XenApiHostMetrics } from "@/libs/xen-api";
|
||||
import { createRecordContext } from "@/stores/index";
|
||||
|
||||
export const useHostMetricsStore = defineStore("host-metrics", () =>
|
||||
createRecordContext<XenApiHostMetrics>("host_metrics")
|
||||
);
|
8
@xen-orchestra/lite/src/stores/host.store.ts
Normal file
8
@xen-orchestra/lite/src/stores/host.store.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { sortRecordsByNameLabel } from "@/libs/utils";
|
||||
import type { XenApiHost } from "@/libs/xen-api";
|
||||
import { createRecordContext } from "@/stores/index";
|
||||
|
||||
export const useHostStore = defineStore("host", () =>
|
||||
createRecordContext<XenApiHost>("host", { sort: sortRecordsByNameLabel })
|
||||
);
|
73
@xen-orchestra/lite/src/stores/index.ts
Normal file
73
@xen-orchestra/lite/src/stores/index.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { computed, ref } from "vue";
|
||||
import type { ObjectType, RawObjectType, XenApiRecord } from "@/libs/xen-api";
|
||||
import { useRecordsStore } from "@/stores/records.store";
|
||||
|
||||
type Options<T extends XenApiRecord> = {
|
||||
filter?: (record: T) => boolean;
|
||||
sort?: (record1: T, record2: T) => 1 | 0 | -1;
|
||||
};
|
||||
|
||||
export function createRecordContext<T extends XenApiRecord>(
|
||||
objectType: RawObjectType,
|
||||
options: Options<T> = {}
|
||||
) {
|
||||
let isInitialized = false;
|
||||
const isReady = ref(false);
|
||||
|
||||
async function init() {
|
||||
if (isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
isInitialized = true;
|
||||
|
||||
const xapiRecordsStore = useRecordsStore();
|
||||
await xapiRecordsStore.loadRecords(objectType);
|
||||
isReady.value = true;
|
||||
}
|
||||
|
||||
const opaqueRefs = computed<string[]>(() => {
|
||||
const xapiRecordsStore = useRecordsStore();
|
||||
let opaqueRefs: string[] = Array.from(
|
||||
xapiRecordsStore.getRecordsOpaqueRefs(
|
||||
objectType.toLocaleLowerCase() as ObjectType
|
||||
)
|
||||
);
|
||||
|
||||
if (options.filter) {
|
||||
opaqueRefs = opaqueRefs.filter((opaqueRef) =>
|
||||
options.filter!(xapiRecordsStore.getRecord(opaqueRef))
|
||||
);
|
||||
}
|
||||
|
||||
if (options.sort) {
|
||||
opaqueRefs = opaqueRefs.sort((opaqueRef1, opaqueRef2) => {
|
||||
return options.sort!(
|
||||
xapiRecordsStore.getRecord(opaqueRef1),
|
||||
xapiRecordsStore.getRecord(opaqueRef2)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return opaqueRefs;
|
||||
});
|
||||
|
||||
const allRecords = computed(() =>
|
||||
opaqueRefs.value.map((opaqueRef) => getRecord(opaqueRef))
|
||||
);
|
||||
|
||||
const getRecord = (opaqueRef: string) =>
|
||||
useRecordsStore().getRecord<T>(opaqueRef);
|
||||
|
||||
const getRecordByUuid = (uuid: string) =>
|
||||
useRecordsStore().getRecordByUuid<T>(uuid);
|
||||
|
||||
return {
|
||||
init,
|
||||
opaqueRefs,
|
||||
getRecord,
|
||||
getRecordByUuid,
|
||||
isReady,
|
||||
allRecords,
|
||||
};
|
||||
}
|
21
@xen-orchestra/lite/src/stores/pool.store.ts
Normal file
21
@xen-orchestra/lite/src/stores/pool.store.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import type { XenApiPool } from "@/libs/xen-api";
|
||||
import { createRecordContext } from "@/stores/index";
|
||||
|
||||
export const usePoolStore = defineStore("pool", () => {
|
||||
const { init, opaqueRefs, getRecord, isReady } =
|
||||
createRecordContext<XenApiPool>("pool");
|
||||
|
||||
const poolOpaqueRef = computed(() => opaqueRefs.value[0]);
|
||||
const pool = computed(() =>
|
||||
isReady.value ? getRecord(poolOpaqueRef.value) : undefined
|
||||
);
|
||||
|
||||
return {
|
||||
init,
|
||||
pool,
|
||||
poolOpaqueRef,
|
||||
isReady,
|
||||
};
|
||||
});
|
87
@xen-orchestra/lite/src/stores/records.store.ts
Normal file
87
@xen-orchestra/lite/src/stores/records.store.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { reactive, shallowReactive } from "vue";
|
||||
import type { ObjectType, RawObjectType, XenApiRecord } from "@/libs/xen-api";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
|
||||
export const useRecordsStore = defineStore("records", () => {
|
||||
const recordsByOpaqueRef = shallowReactive<Map<string, XenApiRecord>>(
|
||||
new Map()
|
||||
);
|
||||
const opaqueRefsByObjectType = reactive<Map<ObjectType, Set<string>>>(
|
||||
new Map()
|
||||
);
|
||||
const uuidToOpaqueRefMapping = reactive<Map<string, string>>(new Map());
|
||||
|
||||
async function loadRecords<T extends XenApiRecord>(
|
||||
objectType: RawObjectType
|
||||
) {
|
||||
const xenApiStore = useXenApiStore();
|
||||
const xapi = await xenApiStore.getXapi();
|
||||
const loadedRecords = await xapi.loadRecords<T>(objectType);
|
||||
|
||||
const lowercaseObjectType = objectType.toLocaleLowerCase() as ObjectType;
|
||||
|
||||
if (!opaqueRefsByObjectType.has(lowercaseObjectType)) {
|
||||
opaqueRefsByObjectType.set(lowercaseObjectType, new Set());
|
||||
}
|
||||
|
||||
const opaqueRefs = opaqueRefsByObjectType.get(lowercaseObjectType);
|
||||
for (const [opaqueRef, record] of loadedRecords) {
|
||||
recordsByOpaqueRef.set(opaqueRef, record);
|
||||
opaqueRefs?.add(opaqueRef);
|
||||
uuidToOpaqueRefMapping.set(record.uuid, opaqueRef);
|
||||
}
|
||||
}
|
||||
|
||||
function addOrReplaceRecord<T extends XenApiRecord>(
|
||||
objectType: ObjectType,
|
||||
opaqueRef: string,
|
||||
record: T
|
||||
) {
|
||||
recordsByOpaqueRef.set(opaqueRef, record);
|
||||
opaqueRefsByObjectType.get(objectType)?.add(opaqueRef);
|
||||
uuidToOpaqueRefMapping.set(record.uuid, opaqueRef);
|
||||
}
|
||||
|
||||
function removeRecord(objectType: ObjectType, opaqueRef: string) {
|
||||
recordsByOpaqueRef.delete(opaqueRef);
|
||||
opaqueRefsByObjectType.get(objectType)?.delete(opaqueRef);
|
||||
|
||||
for (const [currentUuid, currentOpaqueRef] of uuidToOpaqueRefMapping) {
|
||||
if (currentOpaqueRef === opaqueRef) {
|
||||
uuidToOpaqueRefMapping.delete(currentUuid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getRecord<T extends XenApiRecord>(opaqueRef: string): T {
|
||||
if (!recordsByOpaqueRef.has(opaqueRef)) {
|
||||
throw new Error(`No record with ref ${opaqueRef}`);
|
||||
}
|
||||
return recordsByOpaqueRef.get(opaqueRef) as T;
|
||||
}
|
||||
|
||||
function getRecordByUuid<T extends XenApiRecord>(
|
||||
uuid: string
|
||||
): T | undefined {
|
||||
const opaqueRef = uuidToOpaqueRefMapping.get(uuid);
|
||||
|
||||
if (opaqueRef) {
|
||||
return recordsByOpaqueRef.get(opaqueRef) as T;
|
||||
}
|
||||
}
|
||||
|
||||
function getRecordsOpaqueRefs(objectType: ObjectType) {
|
||||
return opaqueRefsByObjectType.get(objectType) || new Set();
|
||||
}
|
||||
|
||||
return {
|
||||
loadRecords,
|
||||
addOrReplaceRecord,
|
||||
removeRecord,
|
||||
getRecord,
|
||||
getRecordsOpaqueRefs,
|
||||
getRecordByUuid,
|
||||
};
|
||||
});
|
10
@xen-orchestra/lite/src/stores/ui.store.ts
Normal file
10
@xen-orchestra/lite/src/stores/ui.store.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
export const useUiStore = defineStore("ui", () => {
|
||||
const currentHostOpaqueRef = ref();
|
||||
|
||||
return {
|
||||
currentHostOpaqueRef,
|
||||
};
|
||||
});
|
7
@xen-orchestra/lite/src/stores/vm-guest-metrics.store.ts
Normal file
7
@xen-orchestra/lite/src/stores/vm-guest-metrics.store.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineStore } from "pinia";
|
||||
import type { XenApiVmGuestMetrics } from "@/libs/xen-api";
|
||||
import { createRecordContext } from "@/stores/index";
|
||||
|
||||
export const useVmGuestMetricsStore = defineStore("vm-guest-metrics", () =>
|
||||
createRecordContext<XenApiVmGuestMetrics>("VM_guest_metrics")
|
||||
);
|
7
@xen-orchestra/lite/src/stores/vm-metrics.store.ts
Normal file
7
@xen-orchestra/lite/src/stores/vm-metrics.store.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineStore } from "pinia";
|
||||
import type { XenApiVmMetrics } from "@/libs/xen-api";
|
||||
import { createRecordContext } from "@/stores/index";
|
||||
|
||||
export const useVmMetricsStore = defineStore("vm-metrics", () =>
|
||||
createRecordContext<XenApiVmMetrics>("VM_metrics")
|
||||
);
|
34
@xen-orchestra/lite/src/stores/vm.store.ts
Normal file
34
@xen-orchestra/lite/src/stores/vm.store.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import { sortRecordsByNameLabel } from "@/libs/utils";
|
||||
import type { XenApiVm } from "@/libs/xen-api";
|
||||
import { createRecordContext } from "@/stores/index";
|
||||
|
||||
export const useVmStore = defineStore("vm", () => {
|
||||
const baseVmContext = createRecordContext<XenApiVm>("VM", {
|
||||
filter: (vm) =>
|
||||
!vm.is_a_snapshot && !vm.is_a_template && !vm.is_control_domain,
|
||||
sort: sortRecordsByNameLabel,
|
||||
});
|
||||
|
||||
const opaqueRefsByHostRef = computed(() => {
|
||||
const vmsOpaqueRefsByHostOpaqueRef = new Map<string, string[]>();
|
||||
|
||||
baseVmContext.opaqueRefs.value.forEach((opaqueRef) => {
|
||||
const vm = baseVmContext.getRecord(opaqueRef);
|
||||
|
||||
if (!vmsOpaqueRefsByHostOpaqueRef.has(vm.resident_on)) {
|
||||
vmsOpaqueRefsByHostOpaqueRef.set(vm.resident_on, []);
|
||||
}
|
||||
|
||||
vmsOpaqueRefsByHostOpaqueRef.get(vm.resident_on)?.push(opaqueRef);
|
||||
});
|
||||
|
||||
return vmsOpaqueRefsByHostOpaqueRef;
|
||||
});
|
||||
|
||||
return {
|
||||
...baseVmContext,
|
||||
opaqueRefsByHostRef,
|
||||
};
|
||||
});
|
120
@xen-orchestra/lite/src/stores/xen-api.store.ts
Normal file
120
@xen-orchestra/lite/src/stores/xen-api.store.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, watchEffect } from "vue";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import type { XenApiRecord } from "@/libs/xen-api";
|
||||
import XenApi from "@/libs/xen-api";
|
||||
import { useConsoleStore } from "@/stores/console.store";
|
||||
import { useHostMetricsStore } from "@/stores/host-metrics.store";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
import { usePoolStore } from "@/stores/pool.store";
|
||||
import { useRecordsStore } from "@/stores/records.store";
|
||||
import { useVmGuestMetricsStore } from "@/stores/vm-guest-metrics.store";
|
||||
import { useVmMetricsStore } from "@/stores/vm-metrics.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
|
||||
export const useXenApiStore = defineStore("xen-api", () => {
|
||||
const xenApi = new XenApi(import.meta.env.VITE_XO_HOST);
|
||||
const currentSessionId = useLocalStorage<string | null>("sessionId", null);
|
||||
const isConnected = ref(false);
|
||||
const isConnecting = ref(false);
|
||||
|
||||
async function getXapi() {
|
||||
if (!currentSessionId.value) {
|
||||
throw new Error("Not connected to xapi");
|
||||
}
|
||||
|
||||
return xenApi;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const poolStore = usePoolStore();
|
||||
await poolStore.init();
|
||||
|
||||
const xapi = await getXapi();
|
||||
|
||||
watchEffect(async () => {
|
||||
if (!poolStore.poolOpaqueRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
await xapi.injectWatchEvent(poolStore.poolOpaqueRef);
|
||||
|
||||
xapi.registerWatchCallBack((results) => {
|
||||
const recordsStore = useRecordsStore();
|
||||
results.forEach((result) => {
|
||||
if (result.operation === "del") {
|
||||
recordsStore.removeRecord(result.class, result.ref);
|
||||
} else {
|
||||
recordsStore.addOrReplaceRecord(
|
||||
result.class,
|
||||
result.ref,
|
||||
result.snapshot as XenApiRecord
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
xapi.startWatch();
|
||||
});
|
||||
|
||||
const hostStore = useHostStore();
|
||||
const vmStore = useVmStore();
|
||||
|
||||
await Promise.all([hostStore.init(), vmStore.init()]);
|
||||
|
||||
const hostMetricsStore = useHostMetricsStore();
|
||||
const vmMetricsStore = useVmMetricsStore();
|
||||
const vmGuestMetricsStore = useVmGuestMetricsStore();
|
||||
|
||||
await Promise.all([
|
||||
hostMetricsStore.init(),
|
||||
vmMetricsStore.init(),
|
||||
vmGuestMetricsStore.init(),
|
||||
]);
|
||||
|
||||
const consoleStore = useConsoleStore();
|
||||
consoleStore.init();
|
||||
}
|
||||
|
||||
async function connect(username: string, password: string) {
|
||||
try {
|
||||
currentSessionId.value = await xenApi.connectWithPassword(
|
||||
username,
|
||||
password
|
||||
);
|
||||
isConnected.value = true;
|
||||
} finally {
|
||||
isConnecting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reconnect() {
|
||||
if (!currentSessionId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isConnecting.value = true;
|
||||
isConnected.value = await xenApi.connectWithSessionId(
|
||||
currentSessionId.value
|
||||
);
|
||||
} finally {
|
||||
isConnecting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
currentSessionId.value = null;
|
||||
xenApi.disconnect();
|
||||
}
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
connect,
|
||||
reconnect,
|
||||
disconnect,
|
||||
init,
|
||||
getXapi,
|
||||
currentSessionId,
|
||||
};
|
||||
});
|
85
@xen-orchestra/lite/src/types/complex-matcher.d.ts
vendored
Normal file
85
@xen-orchestra/lite/src/types/complex-matcher.d.ts
vendored
Normal file
@ -0,0 +1,85 @@
|
||||
declare module "complex-matcher" {
|
||||
type ComparisonOperator = ">" | ">=" | "<" | "<=";
|
||||
|
||||
class Node {
|
||||
createPredicate(): (value) => boolean;
|
||||
}
|
||||
|
||||
class Null extends Node {
|
||||
match(): true;
|
||||
toString(): "";
|
||||
}
|
||||
|
||||
class And extends Node {
|
||||
constructor(children: Node[]);
|
||||
match(value: any): boolean;
|
||||
toString(isNested?: boolean): string;
|
||||
children: Node[];
|
||||
}
|
||||
|
||||
class Comparison extends Node {
|
||||
constructor(operator: ComparisonOperator, value: number);
|
||||
match(value: any): boolean;
|
||||
toString(): string;
|
||||
_operator: ComparisonOperator;
|
||||
_value: number;
|
||||
}
|
||||
|
||||
class Or extends Node {
|
||||
constructor(children: Node[]);
|
||||
match(value: any): boolean;
|
||||
toString(): string;
|
||||
children: Node[];
|
||||
}
|
||||
|
||||
class Not extends Node {
|
||||
constructor(child: Node);
|
||||
match(value: any): boolean;
|
||||
toString(): string;
|
||||
child: Node;
|
||||
}
|
||||
|
||||
class Number extends Node {
|
||||
constructor(value: number);
|
||||
match(value: any): boolean;
|
||||
toString(): string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
class Property extends Node {
|
||||
constructor(name: string, child: Node);
|
||||
match(value: any): boolean;
|
||||
toString(): string;
|
||||
name: string;
|
||||
child: Node;
|
||||
}
|
||||
|
||||
class GlobPattern extends Node {
|
||||
constructor(value: string);
|
||||
match(re: RegExp, value: any): boolean;
|
||||
toString(): string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
class RegExpNode extends Node {
|
||||
constructor(pattern: string, flags: string);
|
||||
match(value: any): boolean;
|
||||
toString(): string;
|
||||
re: RegExp;
|
||||
}
|
||||
|
||||
class StringNode extends Node {
|
||||
constructor(value: string);
|
||||
match(lcValue: string, value: any): boolean;
|
||||
toString(): string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
class TruthyProperty extends Node {
|
||||
constructor(name: string);
|
||||
match(value: any): boolean;
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
function parse(input: string, pos? = 0, end?: input.length): Node;
|
||||
}
|
31
@xen-orchestra/lite/src/types/filter.ts
Normal file
31
@xen-orchestra/lite/src/types/filter.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
export type FilterType = "string" | "boolean" | "number" | "enum";
|
||||
|
||||
export interface FilterComparison {
|
||||
label: string;
|
||||
pattern: string;
|
||||
default?: boolean;
|
||||
before?: string | IconDefinition;
|
||||
after?: string | IconDefinition;
|
||||
escape?: boolean;
|
||||
}
|
||||
|
||||
export type FilterComparisons = Record<FilterType, FilterComparison[]>;
|
||||
|
||||
interface AvailableFilterCommon {
|
||||
property: string;
|
||||
label?: string;
|
||||
icon?: IconDefinition;
|
||||
}
|
||||
|
||||
export interface AvailableFilterEnum extends AvailableFilterCommon {
|
||||
type: "enum";
|
||||
choices: string[];
|
||||
}
|
||||
|
||||
interface AvailableFilterOther extends AvailableFilterCommon {
|
||||
type: Exclude<FilterType, "enum">;
|
||||
}
|
||||
|
||||
export type AvailableFilter = AvailableFilterEnum | AvailableFilterOther;
|
22
@xen-orchestra/lite/src/views/DemoView.vue
Normal file
22
@xen-orchestra/lite/src/views/DemoView.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="foo">
|
||||
<select>
|
||||
<option></option>
|
||||
<option value="foo">Foo</option>
|
||||
<option value="foo">Foo</option>
|
||||
<option value="foo">Foo</option>
|
||||
<option value="foo">Foo</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="foo">
|
||||
<input placeholder="Bar" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style scoped>
|
||||
.foo:has(option[value]:checked) {
|
||||
border: 1px solid blue;
|
||||
}
|
||||
</style>
|
19
@xen-orchestra/lite/src/views/HomeView.vue
Normal file
19
@xen-orchestra/lite/src/views/HomeView.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<template>Chargement en cours...</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watchEffect } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { usePoolStore } from "@/stores/pool.store";
|
||||
|
||||
const router = useRouter();
|
||||
const poolStore = usePoolStore();
|
||||
|
||||
watchEffect(() => {
|
||||
if (poolStore.pool) {
|
||||
router.push({
|
||||
name: "pool.dashboard",
|
||||
params: { uuid: poolStore.pool.uuid },
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
4
@xen-orchestra/lite/src/views/host/HostDashboardView.vue
Normal file
4
@xen-orchestra/lite/src/views/host/HostDashboardView.vue
Normal file
@ -0,0 +1,4 @@
|
||||
<template>
|
||||
Host dashboard
|
||||
<RouterView />
|
||||
</template>
|
5
@xen-orchestra/lite/src/views/host/HostRootView.vue
Normal file
5
@xen-orchestra/lite/src/views/host/HostRootView.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
1
@xen-orchestra/lite/src/views/pool/PoolAlarmsView.vue
Normal file
1
@xen-orchestra/lite/src/views/pool/PoolAlarmsView.vue
Normal file
@ -0,0 +1 @@
|
||||
<template>Alarms</template>
|
102
@xen-orchestra/lite/src/views/pool/PoolDashboardView.vue
Normal file
102
@xen-orchestra/lite/src/views/pool/PoolDashboardView.vue
Normal file
@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="pool-dashboard-view">
|
||||
<PoolDashboardStatus class="item" />
|
||||
<UiCard style="min-width: 40rem">
|
||||
<UiTitle type="h4">RAM usage</UiTitle>
|
||||
<UsageBar title="Hosts" :data="data.slice(0, 5)">
|
||||
<template #header>
|
||||
<span>Host</span>
|
||||
<span>Top 5</span>
|
||||
</template>
|
||||
</UsageBar>
|
||||
</UiCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import PoolDashboardStatus from "@/components/pool/dashboard/PoolDashboardStatus.vue";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiTitle from "@/components/ui/UiTitle.vue";
|
||||
|
||||
const data = [
|
||||
{
|
||||
value: 61,
|
||||
maxValue: 128,
|
||||
label: "R620-L2",
|
||||
badgeLabel: "61/128 GB",
|
||||
},
|
||||
{
|
||||
value: 38,
|
||||
maxValue: 64,
|
||||
label: "R620-L1",
|
||||
badgeLabel: "38/64 GB",
|
||||
},
|
||||
{
|
||||
value: 118,
|
||||
maxValue: 512,
|
||||
label: "R620-L3",
|
||||
badgeLabel: "118/512 GB",
|
||||
},
|
||||
{
|
||||
value: 60,
|
||||
maxValue: 1000,
|
||||
label: "R620-L5",
|
||||
badgeLabel: "60/1000 GB",
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
maxValue: 32,
|
||||
label: "R620-L4",
|
||||
badgeLabel: "4/32 GB",
|
||||
},
|
||||
{
|
||||
value: 60,
|
||||
maxValue: 1000,
|
||||
label: "R620-L5",
|
||||
badgeLabel: "60/1000 GB",
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
maxValue: 32,
|
||||
label: "R620-L4",
|
||||
badgeLabel: "4/32 GB",
|
||||
},
|
||||
{
|
||||
value: 60,
|
||||
maxValue: 1000,
|
||||
label: "R620-L5",
|
||||
badgeLabel: "60/1000 GB",
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
maxValue: 32,
|
||||
label: "R620-L4",
|
||||
badgeLabel: "4/32 GB",
|
||||
},
|
||||
{
|
||||
value: 60,
|
||||
maxValue: 1000,
|
||||
label: "R620-L5",
|
||||
badgeLabel: "60/1000 GB",
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
maxValue: 32,
|
||||
label: "R620-L4",
|
||||
badgeLabel: "4/32 GB",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.pool-dashboard-view {
|
||||
display: flex;
|
||||
padding: 2rem;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.item {
|
||||
min-width: 37rem;
|
||||
}
|
||||
</style>
|
1
@xen-orchestra/lite/src/views/pool/PoolHostsView.vue
Normal file
1
@xen-orchestra/lite/src/views/pool/PoolHostsView.vue
Normal file
@ -0,0 +1 @@
|
||||
<template>Hosts</template>
|
1
@xen-orchestra/lite/src/views/pool/PoolNetworkView.vue
Normal file
1
@xen-orchestra/lite/src/views/pool/PoolNetworkView.vue
Normal file
@ -0,0 +1 @@
|
||||
<template>Network</template>
|
20
@xen-orchestra/lite/src/views/pool/PoolRootView.vue
Normal file
20
@xen-orchestra/lite/src/views/pool/PoolRootView.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="pool-root-view">
|
||||
<PoolHeader />
|
||||
<PoolTabBar />
|
||||
<div class="view">
|
||||
<RouterView />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PoolHeader from "@/components/pool/PoolHeader.vue";
|
||||
import PoolTabBar from "@/components/pool/PoolTabBar.vue";
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view {
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
1
@xen-orchestra/lite/src/views/pool/PoolStatsView.vue
Normal file
1
@xen-orchestra/lite/src/views/pool/PoolStatsView.vue
Normal file
@ -0,0 +1 @@
|
||||
<template>Stats</template>
|
1
@xen-orchestra/lite/src/views/pool/PoolStorageView.vue
Normal file
1
@xen-orchestra/lite/src/views/pool/PoolStorageView.vue
Normal file
@ -0,0 +1 @@
|
||||
<template>Storage</template>
|
1
@xen-orchestra/lite/src/views/pool/PoolSystemView.vue
Normal file
1
@xen-orchestra/lite/src/views/pool/PoolSystemView.vue
Normal file
@ -0,0 +1 @@
|
||||
<template>System</template>
|
1
@xen-orchestra/lite/src/views/pool/PoolTasksView.vue
Normal file
1
@xen-orchestra/lite/src/views/pool/PoolTasksView.vue
Normal file
@ -0,0 +1 @@
|
||||
<template>Tasks</template>
|
53
@xen-orchestra/lite/src/views/pool/PoolVmsView.vue
Normal file
53
@xen-orchestra/lite/src/views/pool/PoolVmsView.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<UiCard class="pool-vms-view">
|
||||
<VmsActionsBar :selected-refs="selectedVmsRefs" />
|
||||
<CollectionTable
|
||||
:available-filters="filters"
|
||||
:collection="vms"
|
||||
v-model="selectedVmsRefs"
|
||||
>
|
||||
<template #header>
|
||||
<th>
|
||||
<FontAwesomeIcon :icon="faPowerOff" />
|
||||
</th>
|
||||
<th>Name</th>
|
||||
</template>
|
||||
<template #row="{ item: vm }">
|
||||
<td>
|
||||
<PowerStateIcon :state="vm.power_state" />
|
||||
</td>
|
||||
<td>{{ vm.name_label }}</td>
|
||||
</template>
|
||||
</CollectionTable>
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import type { AvailableFilter } from "@/types/filter";
|
||||
import { faPowerOff } from "@fortawesome/free-solid-svg-icons";
|
||||
import CollectionTable from "@/components/CollectionTable.vue";
|
||||
import PowerStateIcon from "@/components/PowerStateIcon.vue";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import VmsActionsBar from "@/components/vm/VmsActionsBar.vue";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
|
||||
const vmStore = useVmStore();
|
||||
const { allRecords: vms } = storeToRefs(vmStore);
|
||||
|
||||
const filters: AvailableFilter[] = [
|
||||
{ property: "name_label", label: "VM Name", type: "string" },
|
||||
{
|
||||
property: "power_state",
|
||||
label: "VM State",
|
||||
icon: faPowerOff,
|
||||
type: "enum",
|
||||
choices: ["Running", "Halted", "Paused"],
|
||||
},
|
||||
];
|
||||
|
||||
const selectedVmsRefs = ref([]);
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
32
@xen-orchestra/lite/src/views/vm/VmConsoleView.vue
Normal file
32
@xen-orchestra/lite/src/views/vm/VmConsoleView.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div v-if="!isReady">Loading...</div>
|
||||
<div v-else-if="!isVmRunning">Console is only available for running VMs.</div>
|
||||
<RemoteConsole v-else-if="vmConsole" :location="vmConsole.location" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import RemoteConsole from "@/components/RemoteConsole.vue";
|
||||
import { useConsoleStore } from "@/stores/console.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
|
||||
const route = useRoute();
|
||||
const vmStore = useVmStore();
|
||||
const consoleStore = useConsoleStore();
|
||||
|
||||
const isReady = computed(() => vmStore.isReady || consoleStore.isReady);
|
||||
|
||||
const vm = computed(() => vmStore.getRecordByUuid(route.params.uuid as string));
|
||||
const isVmRunning = computed(() => vm.value?.power_state === "Running");
|
||||
|
||||
const vmConsole = computed(() => {
|
||||
const consoleOpaqueRef = vm.value?.consoles[0];
|
||||
|
||||
if (!consoleOpaqueRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
return consoleStore.getRecord(consoleOpaqueRef);
|
||||
});
|
||||
</script>
|
19
@xen-orchestra/lite/src/views/vm/VmRootView.vue
Normal file
19
@xen-orchestra/lite/src/views/vm/VmRootView.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watchEffect } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useUiStore } from "@/stores/ui.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
|
||||
const route = useRoute();
|
||||
const vmStore = useVmStore();
|
||||
const uiStore = useUiStore();
|
||||
|
||||
watchEffect(() => {
|
||||
const vm = vmStore.getRecordByUuid(route.params.uuid as string);
|
||||
uiStore.currentHostOpaqueRef = vm?.resident_on;
|
||||
});
|
||||
</script>
|
8
@xen-orchestra/lite/tsconfig.config.json
Normal file
8
@xen-orchestra/lite/tsconfig.config.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.node.json",
|
||||
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
17
@xen-orchestra/lite/tsconfig.json
Normal file
17
@xen-orchestra/lite/tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.web.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2017"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.config.json"
|
||||
}
|
||||
]
|
||||
}
|
13
@xen-orchestra/lite/vite.config.ts
Normal file
13
@xen-orchestra/lite/vite.config.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { URL, fileURLToPath } from "url";
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user