chore(lite): add type branding to XAPI record's $ref & uuid (#6884)

Type branding enhances our type safety by preventing the incorrect usage of
`XenApiRecord`'s `$ref` and `uuid`. It ensures that these types are not
interchangeable.
This commit is contained in:
Thierry Goettelmann
2023-06-15 14:01:27 +02:00
committed by GitHub
parent fcc76fb8d0
commit 2de26030ff
20 changed files with 114 additions and 89 deletions

View File

@@ -6,23 +6,26 @@
<slot v-else />
</template>
<script lang="ts" setup>
<script
generic="T extends XenApiRecord<string>, I extends T['uuid']"
lang="ts"
setup
>
import UiSpinner from "@/components/ui/UiSpinner.vue";
import type { XenApiRecord } from "@/libs/xen-api";
import ObjectNotFoundView from "@/views/ObjectNotFoundView.vue";
import { computed } from "vue";
import { useRouter } from "vue-router";
const props = defineProps<{
isReady: boolean;
uuidChecker: (uuid: string) => boolean;
id?: string;
uuidChecker: (uuid: I) => boolean;
id?: I;
}>();
const { currentRoute } = useRouter();
const id = computed(
() => props.id ?? (currentRoute.value.params.uuid as string)
);
const id = computed(() => props.id ?? (currentRoute.value.params.uuid as I));
const isRecordNotFound = computed(
() => props.isReady && !props.uuidChecker(id.value)

View File

@@ -29,6 +29,7 @@ import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import InfraVmList from "@/components/infra/InfraVmList.vue";
import { vTooltip } from "@/directives/tooltip.directive";
import type { XenApiHost } from "@/libs/xen-api";
import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store";
import { useUiStore } from "@/stores/ui.store";
@@ -42,7 +43,7 @@ import { useToggle } from "@vueuse/core";
import { computed } from "vue";
const props = defineProps<{
hostOpaqueRef: string;
hostOpaqueRef: XenApiHost["$ref"];
}>();
const { getByOpaqueRef } = useHostStore().subscribe();

View File

@@ -19,13 +19,14 @@
import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import PowerStateIcon from "@/components/PowerStateIcon.vue";
import type { XenApiVm } from "@/libs/xen-api";
import { useVmStore } from "@/stores/vm.store";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { useIntersectionObserver } from "@vueuse/core";
import { computed, ref } from "vue";
const props = defineProps<{
vmOpaqueRef: string;
vmOpaqueRef: XenApiVm["$ref"];
}>();
const { getByOpaqueRef } = useVmStore().subscribe();

View File

@@ -11,18 +11,21 @@
<script lang="ts" setup>
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
import InfraVmItem from "@/components/infra/InfraVmItem.vue";
import type { XenApiHost } from "@/libs/xen-api";
import { useVmStore } from "@/stores/vm.store";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
const props = defineProps<{
hostOpaqueRef?: string;
hostOpaqueRef?: XenApiHost["$ref"];
}>();
const { isReady, recordsByHostRef, hasError } = useVmStore().subscribe();
const vms = computed(() =>
recordsByHostRef.value.get(props.hostOpaqueRef ?? "OpaqueRef:NULL")
recordsByHostRef.value.get(
props.hostOpaqueRef ?? ("OpaqueRef:NULL" as XenApiHost["$ref"])
)
);
</script>

View File

@@ -21,7 +21,7 @@ import { faCopy } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
const props = defineProps<{
selectedRefs: string[];
selectedRefs: XenApiVm["$ref"][];
}>();
const { getByOpaqueRef } = useVmStore().subscribe();

View File

@@ -47,7 +47,7 @@ import { vTooltip } from "@/directives/tooltip.directive";
import type { XenApiVm } from "@/libs/xen-api";
const props = defineProps<{
vmRefs: string[];
vmRefs: XenApiVm["$ref"][];
}>();
const xenApi = useXenApiStore().getXapi();

View File

@@ -118,7 +118,7 @@ import {
import { computed } from "vue";
const props = defineProps<{
vmRefs: string[];
vmRefs: XenApiVm["$ref"][];
}>();
const { getByOpaqueRef: getVm } = useVmStore().subscribe();

View File

@@ -22,6 +22,7 @@ import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { useVmStore } from "@/stores/vm.store";
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
import type { XenApiVm } from "@/libs/xen-api";
import {
faAngleDown,
faDisplay,
@@ -34,7 +35,7 @@ const { getByUuid: getVmByUuid } = useVmStore().subscribe();
const { currentRoute } = useRouter();
const vm = computed(() =>
getVmByUuid(currentRoute.value.params.uuid as string)
getVmByUuid(currentRoute.value.params.uuid as XenApiVm["uuid"])
);
const name = computed(() => vm.value?.name_label);

View File

@@ -56,10 +56,12 @@
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { useUiStore } from "@/stores/ui.store";
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
import VmActionCopyItem from "@/components/vm/VmActionItems/VmActionCopyItem.vue";
import VmActionDeleteItem from "@/components/vm/VmActionItems/VmActionDeleteItem.vue";
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
import { vTooltip } from "@/directives/tooltip.directive";
import type { XenApiVm } from "@/libs/xen-api";
import { useUiStore } from "@/stores/ui.store";
import {
faCamera,
faCode,
@@ -72,11 +74,10 @@ import {
faRoute,
} from "@fortawesome/free-solid-svg-icons";
import { storeToRefs } from "pinia";
import { vTooltip } from "@/directives/tooltip.directive";
defineProps<{
disabled?: boolean;
selectedRefs: string[];
selectedRefs: XenApiVm["$ref"][];
}>();
const { isMobile } = storeToRefs(useUiStore());

View File

@@ -16,10 +16,13 @@ export type Stat<T> = {
pausable: Pausable;
};
type GetStats<T extends HostStats | VmStats> = (
uuid: string,
type GetStats<
T extends XenApiHost | XenApiVm,
S extends HostStats | VmStats
> = (
uuid: T["uuid"],
granularity: GRANULARITY
) => Promise<XapiStatsResponse<T>> | undefined;
) => Promise<XapiStatsResponse<S>> | undefined;
export type FetchedStats<
T extends XenApiHost | XenApiVm,
@@ -35,7 +38,7 @@ export type FetchedStats<
export default function useFetchStats<
T extends XenApiHost | XenApiVm,
S extends HostStats | VmStats
>(getStats: GetStats<S>, granularity: GRANULARITY): FetchedStats<T, S> {
>(getStats: GetStats<T, S>, granularity: GRANULARITY): FetchedStats<T, S> {
const stats = ref<Map<string, Stat<S>>>(new Map());
const timestamp = ref<number[]>([0, 0]);

View File

@@ -136,9 +136,9 @@ export function getHostMemory(
}
}
export const buildXoObject = <T extends XenApiRecord>(
export const buildXoObject = <T extends XenApiRecord<string>>(
record: RawXenApiRecord<T>,
params: { opaqueRef: string }
params: { opaqueRef: T["$ref"] }
) => {
return {
...record,

View File

@@ -88,78 +88,80 @@ export enum VM_OPERATION {
SUSPEND = "suspend",
}
export interface XenApiRecord {
$ref: string;
uuid: string;
declare const __brand: unique symbol;
export interface XenApiRecord<Name extends string> {
$ref: string & { [__brand]: `${Name}Ref` };
uuid: string & { [__brand]: `${Name}Uuid` };
}
export type RawXenApiRecord<T extends XenApiRecord> = Omit<T, "$ref">;
export type RawXenApiRecord<T extends XenApiRecord<string>> = Omit<T, "$ref">;
export interface XenApiPool extends XenApiRecord {
export interface XenApiPool extends XenApiRecord<"Pool"> {
cpu_info: {
cpu_count: string;
};
master: string;
master: XenApiHost["$ref"];
name_label: string;
}
export interface XenApiHost extends XenApiRecord {
export interface XenApiHost extends XenApiRecord<"Host"> {
address: string;
name_label: string;
metrics: string;
resident_VMs: string[];
metrics: XenApiHostMetrics["$ref"];
resident_VMs: XenApiVm["$ref"][];
cpu_info: { cpu_count: string };
software_version: { product_version: string };
}
export interface XenApiSr extends XenApiRecord {
export interface XenApiSr extends XenApiRecord<"Sr"> {
name_label: string;
physical_size: number;
physical_utilisation: number;
}
export interface XenApiVm extends XenApiRecord {
export interface XenApiVm extends XenApiRecord<"Vm"> {
current_operations: Record<string, VM_OPERATION>;
guest_metrics: string;
metrics: string;
metrics: XenApiVmMetrics["$ref"];
name_label: string;
name_description: string;
power_state: POWER_STATE;
resident_on: string;
consoles: string[];
resident_on: XenApiHost["$ref"];
consoles: XenApiConsole["$ref"][];
is_control_domain: boolean;
is_a_snapshot: boolean;
is_a_template: boolean;
VCPUs_at_startup: number;
}
export interface XenApiConsole extends XenApiRecord {
export interface XenApiConsole extends XenApiRecord<"Console"> {
protocol: string;
location: string;
}
export interface XenApiHostMetrics extends XenApiRecord {
export interface XenApiHostMetrics extends XenApiRecord<"HostMetrics"> {
live: boolean;
memory_free: number;
memory_total: number;
}
export interface XenApiVmMetrics extends XenApiRecord {
export interface XenApiVmMetrics extends XenApiRecord<"VmMetrics"> {
VCPUs_number: number;
}
export type XenApiVmGuestMetrics = XenApiRecord;
export type XenApiVmGuestMetrics = XenApiRecord<"VmGuestMetrics">;
export interface XenApiTask extends XenApiRecord {
export interface XenApiTask extends XenApiRecord<"Task"> {
name_label: string;
resident_on: string;
resident_on: XenApiHost["$ref"];
created: string;
finished: string;
status: string;
progress: number;
}
export interface XenApiMessage extends XenApiRecord {
export interface XenApiMessage extends XenApiRecord<"Message"> {
name: string;
cls: RawObjectType;
}
@@ -168,8 +170,8 @@ type WatchCallbackResult = {
id: string;
class: ObjectType;
operation: "add" | "mod" | "del";
ref: string;
snapshot: RawXenApiRecord<XenApiRecord>;
ref: XenApiRecord<string>["$ref"];
snapshot: RawXenApiRecord<XenApiRecord<string>>;
};
type WatchCallback = (results: WatchCallbackResult[]) => void;
@@ -278,14 +280,16 @@ export default class XenApi {
return fetch(url);
}
async loadRecords<T extends XenApiRecord>(type: RawObjectType): Promise<T[]> {
async loadRecords<T extends XenApiRecord<string>>(
type: RawObjectType
): Promise<T[]> {
const result = await this.#call<{ [key: string]: RawXenApiRecord<T> }>(
`${type}.get_all_records`,
[this.sessionId]
);
return Object.entries(result).map(([opaqueRef, record]) =>
buildXoObject(record, { opaqueRef })
buildXoObject(record, { opaqueRef: opaqueRef as T["$ref"] })
);
}
@@ -324,7 +328,7 @@ export default class XenApi {
this.#watchCallBack = callback;
}
async injectWatchEvent(poolRef: string) {
async injectWatchEvent(poolRef: XenApiPool["$ref"]) {
this.#fromToken = await this.#call("event.inject", [
this.sessionId,
"pool",
@@ -367,7 +371,7 @@ export default class XenApi {
);
},
resume: (vmRefsWithPowerState: VmRefsWithPowerState) => {
const vmRefs = Object.keys(vmRefsWithPowerState);
const vmRefs = Object.keys(vmRefsWithPowerState) as XenApiVm["$ref"][];
return Promise.all(
vmRefs.map((vmRef) => {
@@ -394,7 +398,7 @@ export default class XenApi {
);
},
clone: (vmRefsToClone: VmRefsToClone) => {
const vmRefs = Object.keys(vmRefsToClone);
const vmRefs = Object.keys(vmRefsToClone) as XenApiVm["$ref"][];
return Promise.all(
vmRefs.map((vmRef) =>

View File

@@ -10,7 +10,7 @@ import { computed, type ComputedRef } from "vue";
type GetStatsExtension = {
getStats: (
hostUuid: string,
hostUuid: XenApiHost["uuid"],
granularity: GRANULARITY
) => Promise<XapiStatsResponse<any>> | undefined;
};
@@ -31,7 +31,10 @@ export const useHostStore = defineStore("host", () => {
const subscribe = createSubscribe<XenApiHost, Extensions>((options) => {
const originalSubscription = hostCollection.subscribe(options);
const getStats = (hostUuid: string, granularity: GRANULARITY) => {
const getStats = (
hostUuid: XenApiHost["uuid"],
granularity: GRANULARITY
) => {
const host = originalSubscription.getByUuid(hostUuid);
if (host === undefined) {

View File

@@ -1,7 +1,7 @@
import { sortRecordsByNameLabel } from "@/libs/utils";
import type { GRANULARITY, XapiStatsResponse } from "@/libs/xapi-stats";
import { POWER_STATE } from "@/libs/xen-api";
import type { XenApiHost, XenApiVm } from "@/libs/xen-api";
import { POWER_STATE } from "@/libs/xen-api";
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { useXenApiStore } from "@/stores/xen-api.store";
import { createSubscribe, type Subscription } from "@/types/xapi-collection";
@@ -9,14 +9,14 @@ import { defineStore } from "pinia";
import { computed, type ComputedRef } from "vue";
type DefaultExtension = {
recordsByHostRef: ComputedRef<Map<string, XenApiVm[]>>;
recordsByHostRef: ComputedRef<Map<XenApiHost["$ref"], XenApiVm[]>>;
runningVms: ComputedRef<XenApiVm[]>;
};
type GetStatsExtension = [
{
getStats: (
id: string,
id: XenApiVm["uuid"],
granularity: GRANULARITY
) => Promise<XapiStatsResponse<any>>;
},
@@ -39,7 +39,7 @@ export const useVmStore = defineStore("vm", () => {
const extendedSubscription = {
recordsByHostRef: computed(() => {
const vmsByHostOpaqueRef = new Map<string, XenApiVm[]>();
const vmsByHostOpaqueRef = new Map<XenApiHost["$ref"], XenApiVm[]>();
originalSubscription.records.value.forEach((vm) => {
if (!vmsByHostOpaqueRef.has(vm.resident_on)) {
@@ -61,23 +61,23 @@ export const useVmStore = defineStore("vm", () => {
const hostSubscription = options?.hostSubscription;
const getStatsSubscription = hostSubscription !== undefined && {
getStats: (id: string, granularity: GRANULARITY) => {
getStats: (vmUuid: XenApiVm["uuid"], granularity: GRANULARITY) => {
const xenApiStore = useXenApiStore();
if (!xenApiStore.isConnected) {
return undefined;
}
const vm = originalSubscription.getByUuid(id);
const vm = originalSubscription.getByUuid(vmUuid);
if (vm === undefined) {
throw new Error(`VM ${id} could not be found.`);
throw new Error(`VM ${vmUuid} could not be found.`);
}
const host = hostSubscription.getByOpaqueRef(vm.resident_on);
if (host === undefined) {
throw new Error(`VM ${id} is halted or host could not be found.`);
throw new Error(`VM ${vmUuid} is halted or host could not be found.`);
}
return xenApiStore.getXapiStats()._getAndUpdateStats({

View File

@@ -16,7 +16,7 @@ export const useXapiCollectionStore = defineStore("xapiCollection", () => {
function get<
T extends RawObjectType,
S extends XenApiRecord = RawTypeToObject[T]
S extends XenApiRecord<string> = RawTypeToObject[T]
>(type: T): ReturnType<typeof createXapiCollection<S>> {
if (!collections.value.has(type)) {
collections.value.set(type, createXapiCollection<S>(type));
@@ -28,15 +28,17 @@ export const useXapiCollectionStore = defineStore("xapiCollection", () => {
return { get };
});
const createXapiCollection = <T extends XenApiRecord>(type: RawObjectType) => {
const createXapiCollection = <T extends XenApiRecord<string>>(
type: RawObjectType
) => {
const isReady = ref(false);
const isFetching = ref(false);
const isReloading = computed(() => isReady.value && isFetching.value);
const lastError = ref<string>();
const hasError = computed(() => lastError.value !== undefined);
const subscriptions = ref(new Set<symbol>());
const recordsByOpaqueRef = ref(new Map<string, T>());
const recordsByUuid = ref(new Map<string, T>());
const recordsByOpaqueRef = ref(new Map<T["$ref"], T>());
const recordsByUuid = ref(new Map<T["uuid"], T>());
const filter = ref<(record: T) => boolean>();
const sort = ref<(record1: T, record2: T) => 1 | 0 | -1>();
const xenApiStore = useXenApiStore();
@@ -54,12 +56,12 @@ const createXapiCollection = <T extends XenApiRecord>(type: RawObjectType) => {
return filter.value !== undefined ? records.filter(filter.value) : records;
});
const getByOpaqueRef = (opaqueRef: string) =>
const getByOpaqueRef = (opaqueRef: T["$ref"]) =>
recordsByOpaqueRef.value.get(opaqueRef);
const getByUuid = (uuid: string) => recordsByUuid.value.get(uuid);
const getByUuid = (uuid: T["uuid"]) => recordsByUuid.value.get(uuid);
const hasUuid = (uuid: string) => recordsByUuid.value.has(uuid);
const hasUuid = (uuid: T["uuid"]) => recordsByUuid.value.has(uuid);
const hasSubscriptions = computed(() => subscriptions.value.size > 0);
@@ -89,7 +91,7 @@ const createXapiCollection = <T extends XenApiRecord>(type: RawObjectType) => {
recordsByUuid.value.set(record.uuid, record);
};
const remove = (opaqueRef: string) => {
const remove = (opaqueRef: T["$ref"]) => {
if (!recordsByOpaqueRef.value.has(opaqueRef)) {
return;
}
@@ -129,7 +131,7 @@ const createXapiCollection = <T extends XenApiRecord>(type: RawObjectType) => {
if (options?.immediate !== false) {
start();
return subscription as Subscription<T, O>;
return subscription as unknown as Subscription<T, O>;
}
return {

View File

@@ -39,17 +39,16 @@ export const useXenApiStore = defineStore("xen-api", () => {
return;
}
const buildObject = () =>
buildXoObject(result.snapshot, { opaqueRef: result.ref }) as any;
switch (result.operation) {
case "add":
return collection.add(
buildXoObject(result.snapshot, { opaqueRef: result.ref })
);
return collection.add(buildObject());
case "mod":
return collection.update(
buildXoObject(result.snapshot, { opaqueRef: result.ref })
);
return collection.update(buildObject());
case "del":
return collection.remove(result.ref);
return collection.remove(result.ref as any);
}
});
});

View File

@@ -13,11 +13,11 @@ import type {
} from "@/libs/xen-api";
import type { ComputedRef, Ref } from "vue";
type DefaultExtension<T extends XenApiRecord> = {
type DefaultExtension<T extends XenApiRecord<string>> = {
records: ComputedRef<T[]>;
getByOpaqueRef: (opaqueRef: string) => T | undefined;
getByUuid: (uuid: string) => T | undefined;
hasUuid: (uuid: string) => boolean;
getByOpaqueRef: (opaqueRef: T["$ref"]) => T | undefined;
getByUuid: (uuid: T["uuid"]) => T | undefined;
hasUuid: (uuid: T["uuid"]) => boolean;
isReady: Readonly<Ref<boolean>>;
isFetching: Readonly<Ref<boolean>>;
isReloading: ComputedRef<boolean>;
@@ -33,7 +33,7 @@ type DeferExtension = [
{ immediate: false }
];
type DefaultExtensions<T extends XenApiRecord> = [
type DefaultExtensions<T extends XenApiRecord<string>> = [
DefaultExtension<T>,
DeferExtension
];
@@ -64,14 +64,14 @@ type GenerateSubscription<
: object;
export type Subscription<
T extends XenApiRecord,
T extends XenApiRecord<string>,
Options extends object,
Extensions extends any[] = []
> = GenerateSubscription<Options, Extensions> &
GenerateSubscription<Options, DefaultExtensions<T>>;
export function createSubscribe<
T extends XenApiRecord,
T extends XenApiRecord<string>,
Extensions extends any[],
Options extends object = SubscribeOptions<Extensions>
>(builder: (options?: Options) => Subscription<T, Options, Extensions>) {

View File

@@ -6,6 +6,7 @@
<script lang="ts" setup>
import ObjectNotFoundWrapper from "@/components/ObjectNotFoundWrapper.vue";
import type { XenApiHost } from "@/libs/xen-api";
import { useHostStore } from "@/stores/host.store";
import { useUiStore } from "@/stores/ui.store";
import { watchEffect } from "vue";
@@ -16,6 +17,8 @@ const route = useRoute();
const uiStore = useUiStore();
watchEffect(() => {
uiStore.currentHostOpaqueRef = getByUuid(route.params.uuid as string)?.$ref;
uiStore.currentHostOpaqueRef = getByUuid(
route.params.uuid as XenApiHost["uuid"]
)?.$ref;
});
</script>

View File

@@ -9,7 +9,7 @@
</template>
<script lang="ts" setup>
import { POWER_STATE, VM_OPERATION } from "@/libs/xen-api";
import { POWER_STATE, VM_OPERATION, type XenApiVm } from "@/libs/xen-api";
import { computed } from "vue";
import { useRoute } from "vue-router";
import RemoteConsole from "@/components/RemoteConsole.vue";
@@ -36,7 +36,7 @@ const { isReady: isConsoleReady, getByOpaqueRef: getConsoleByOpaqueRef } =
const isReady = computed(() => isVmReady.value && isConsoleReady.value);
const vm = computed(() => getVmByUuid(route.params.uuid as string));
const vm = computed(() => getVmByUuid(route.params.uuid as XenApiVm["uuid"]));
const isVmRunning = computed(
() => vm.value?.power_state === POWER_STATE.RUNNING

View File

@@ -10,6 +10,7 @@
import ObjectNotFoundWrapper from "@/components/ObjectNotFoundWrapper.vue";
import VmHeader from "@/components/vm/VmHeader.vue";
import VmTabBar from "@/components/vm/VmTabBar.vue";
import type { XenApiVm } from "@/libs/xen-api";
import { useUiStore } from "@/stores/ui.store";
import { useVmStore } from "@/stores/vm.store";
import { whenever } from "@vueuse/core";
@@ -19,6 +20,6 @@ import { useRoute } from "vue-router";
const route = useRoute();
const { getByUuid, hasUuid, isReady } = useVmStore().subscribe();
const uiStore = useUiStore();
const vm = computed(() => getByUuid(route.params.uuid as string));
const vm = computed(() => getByUuid(route.params.uuid as XenApiVm["uuid"]));
whenever(vm, (vm) => (uiStore.currentHostOpaqueRef = vm.resident_on));
</script>