feat(lite): initial Vue.js implementation

This commit is contained in:
Thierry Goettelmann 2022-07-21 16:25:31 +02:00 committed by Julien Fontanet
parent 0623d837c1
commit ecb580a629
102 changed files with 7678 additions and 38 deletions

View File

@ -0,0 +1 @@
VITE_XO_HOST=

View 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
View 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

View File

@ -0,0 +1,14 @@
module.exports = {
importOrder: [
"^[^/]+$",
"<THIRD_PARTY_MODULES>",
"^@/components/(.*)$",
"^@/composables/(.*)$",
"^@/libs/(.*)$",
"^@/router/(.*)$",
"^@/stores/(.*)$",
"^@/views/(.*)$",
],
importOrderSeparation: false,
importOrderSortSpecifiers: true,
};

View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

View 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
View File

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="json-rpc-2.0/dist" />

View 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>

View 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"
}
}

View File

@ -0,0 +1,5 @@
module.exports = {
plugins: {
"postcss-nested": {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View 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>

View 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);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

View 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;
}

View 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);
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -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">&nbsp;</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>
```

View 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,
};
}

View File

@ -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>
```

View File

@ -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();
}

View File

@ -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>
```

View File

@ -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;
}

View 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>
```

View 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,
};
}

View File

@ -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>
```

View File

@ -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,
};
}

View 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,
};
}

View 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, "\\$&");
}

View 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,
]);
}
}

View 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");

View 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;

View 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,
},
],
};

View 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")
);

View 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")
);

View 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 })
);

View 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,
};
}

View 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,
};
});

View 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,
};
});

View File

@ -0,0 +1,10 @@
import { defineStore } from "pinia";
import { ref } from "vue";
export const useUiStore = defineStore("ui", () => {
const currentHostOpaqueRef = ref();
return {
currentHostOpaqueRef,
};
});

View 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")
);

View 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")
);

View 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,
};
});

View 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,
};
});

View 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;
}

View 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;

View 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>

View 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>

View File

@ -0,0 +1,4 @@
<template>
Host dashboard
<RouterView />
</template>

View File

@ -0,0 +1,5 @@
<template>
<RouterView />
</template>
<script lang="ts" setup></script>

View File

@ -0,0 +1 @@
<template>Alarms</template>

View 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>

View File

@ -0,0 +1 @@
<template>Hosts</template>

View File

@ -0,0 +1 @@
<template>Network</template>

View 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>

View File

@ -0,0 +1 @@
<template>Stats</template>

View File

@ -0,0 +1 @@
<template>Storage</template>

View File

@ -0,0 +1 @@
<template>System</template>

View File

@ -0,0 +1 @@
<template>Tasks</template>

View 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>

View 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>

View 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>

View File

@ -0,0 +1,8 @@
{
"extends": "@vue/tsconfig/tsconfig.node.json",
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*"],
"compilerOptions": {
"composite": true,
"types": ["node"]
}
}

View 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"
}
]
}

View 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