- Implemented DeploymentJobsPage for managing deployment jobs with filtering and retry functionality. - Created TemplateCategoriesPage for managing template categories with add, edit, and delete capabilities. - Developed VirtualMachineDetailsPage to display detailed information about a specific virtual machine. - Added VirtualMachinesPage for listing, adding, editing, and deleting virtual machines, including linking and unlinking to domains.
585 lines
21 KiB
TypeScript
585 lines
21 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Button,
|
|
Combobox,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogBody,
|
|
DialogContent,
|
|
DialogSurface,
|
|
DialogTitle,
|
|
Field,
|
|
Input,
|
|
makeStyles,
|
|
Option,
|
|
Tab,
|
|
TabList,
|
|
shorthands,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHeader,
|
|
TableHeaderCell,
|
|
TableRow,
|
|
Textarea,
|
|
Tooltip,
|
|
} from "@fluentui/react-components";
|
|
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons";
|
|
import { ChevronDownRegular, ChevronRightRegular } from "@fluentui/react-icons";
|
|
import { Fragment, useState } from "react";
|
|
import { portalApi } from "../api/portalApi";
|
|
import { DataState } from "../components/DataState";
|
|
import { PageHeader } from "../components/PageHeader";
|
|
import type { ServiceItem, Template, TemplateCategory } from "../types/portal";
|
|
|
|
const useStyles = makeStyles({
|
|
toolbar: {
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "flex-end",
|
|
gap: "12px",
|
|
marginBottom: "18px",
|
|
},
|
|
toolbarActions: {
|
|
display: "flex",
|
|
gap: "8px",
|
|
alignItems: "center",
|
|
},
|
|
filter: {
|
|
minWidth: "280px",
|
|
},
|
|
form: {
|
|
display: "grid",
|
|
gap: "14px",
|
|
},
|
|
grid: {
|
|
display: "grid",
|
|
gap: "14px",
|
|
gridTemplateColumns: "repeat(2, minmax(180px, 1fr))",
|
|
},
|
|
wide: {
|
|
gridColumn: "1 / -1",
|
|
},
|
|
actions: {
|
|
display: "flex",
|
|
gap: "4px",
|
|
...shorthands.padding("2px", "0"),
|
|
},
|
|
value: {
|
|
overflowWrap: "anywhere",
|
|
},
|
|
groupRow: {
|
|
cursor: "pointer",
|
|
},
|
|
groupHeader: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "8px",
|
|
},
|
|
nestedLevel1Cell: {
|
|
paddingLeft: "28px",
|
|
},
|
|
nestedLevel2Cell: {
|
|
paddingLeft: "48px",
|
|
},
|
|
categoryHeaderCell: {
|
|
paddingTop: "10px",
|
|
paddingBottom: "6px",
|
|
paddingLeft: "28px",
|
|
},
|
|
});
|
|
|
|
type DialogMode = "add" | "edit" | "details" | null;
|
|
|
|
function getTemplateJsonData(template: Template) {
|
|
return template.jsonData ?? template.jSONData ?? "";
|
|
}
|
|
|
|
type TemplateEditorTab = "parameters" | "variables" | "resources" | "raw";
|
|
|
|
function tryFormatJson(value: string) {
|
|
try {
|
|
if (!value.trim()) {
|
|
return "";
|
|
}
|
|
return JSON.stringify(JSON.parse(value), null, 2);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function getCaseInsensitiveProperty(source: Record<string, unknown>, propertyName: string) {
|
|
const key = Object.keys(source).find((entry) => entry.toLowerCase() === propertyName.toLowerCase());
|
|
return key ? source[key] : undefined;
|
|
}
|
|
|
|
function normalizeEditorJsonParts(rawJson: string) {
|
|
if (!rawJson.trim()) {
|
|
return {
|
|
raw: "",
|
|
parameters: "{}",
|
|
variables: "{}",
|
|
resources: "[]",
|
|
};
|
|
}
|
|
|
|
const parsed = JSON.parse(rawJson) as unknown;
|
|
const root = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
|
|
|
const parameters = getCaseInsensitiveProperty(root, "parameters");
|
|
const variables = getCaseInsensitiveProperty(root, "variables");
|
|
const resources = getCaseInsensitiveProperty(root, "resources");
|
|
|
|
return {
|
|
raw: JSON.stringify(root, null, 2),
|
|
parameters: JSON.stringify(
|
|
parameters && typeof parameters === "object" && !Array.isArray(parameters) ? parameters : {},
|
|
null,
|
|
2,
|
|
),
|
|
variables: JSON.stringify(
|
|
variables && typeof variables === "object" && !Array.isArray(variables) ? variables : {},
|
|
null,
|
|
2,
|
|
),
|
|
resources: JSON.stringify(Array.isArray(resources) ? resources : [], null, 2),
|
|
};
|
|
}
|
|
|
|
export function TemplatesPage() {
|
|
const styles = useStyles();
|
|
const queryClient = useQueryClient();
|
|
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
|
|
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
|
const [templateCategoryId, setTemplateCategoryId] = useState("");
|
|
const [name, setName] = useState("");
|
|
const [version, setVersion] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [jsonData, setJsonData] = useState("");
|
|
const [jsonParameters, setJsonParameters] = useState("{}");
|
|
const [jsonVariables, setJsonVariables] = useState("{}");
|
|
const [jsonResources, setJsonResources] = useState("[]");
|
|
const [editorTab, setEditorTab] = useState<TemplateEditorTab>("parameters");
|
|
const [serviceFilterId, setServiceFilterId] = useState("all");
|
|
const [expandedServiceGroups, setExpandedServiceGroups] = useState<Record<string, boolean>>({});
|
|
const [expandedCategoryGroups, setExpandedCategoryGroups] = useState<Record<string, boolean>>({});
|
|
const { data, error, isLoading } = useQuery({
|
|
queryKey: ["templates"],
|
|
queryFn: ({ signal }) => portalApi.getTemplates(signal),
|
|
});
|
|
const {
|
|
data: templateCategories,
|
|
error: templateCategoriesError,
|
|
isLoading: templateCategoriesLoading,
|
|
} = useQuery({
|
|
queryKey: ["template-categories"],
|
|
queryFn: ({ signal }) => portalApi.getTemplateCategories(signal),
|
|
});
|
|
const { data: services, error: servicesError, isLoading: servicesLoading } = useQuery({
|
|
queryKey: ["services"],
|
|
queryFn: ({ signal }) => portalApi.getServices(signal),
|
|
});
|
|
|
|
const closeDialog = () => {
|
|
setDialogMode(null);
|
|
setSelectedTemplate(null);
|
|
setTemplateCategoryId("");
|
|
setName("");
|
|
setVersion("");
|
|
setDescription("");
|
|
setJsonData("");
|
|
setJsonParameters("{}");
|
|
setJsonVariables("{}");
|
|
setJsonResources("[]");
|
|
setEditorTab("parameters");
|
|
};
|
|
|
|
const openAddDialog = () => {
|
|
closeDialog();
|
|
setDialogMode("add");
|
|
};
|
|
|
|
const openTemplateDialog = (mode: "edit" | "details", template: Template) => {
|
|
setSelectedTemplate(template);
|
|
setTemplateCategoryId(template.templateCategoryId ?? "");
|
|
setName(template.name);
|
|
setVersion(template.version ?? "");
|
|
setDescription(template.description ?? "");
|
|
const raw = getTemplateJsonData(template);
|
|
try {
|
|
const normalized = normalizeEditorJsonParts(raw);
|
|
setJsonData(normalized.raw);
|
|
setJsonParameters(normalized.parameters);
|
|
setJsonVariables(normalized.variables);
|
|
setJsonResources(normalized.resources);
|
|
} catch {
|
|
setJsonParameters("{}");
|
|
setJsonVariables("{}");
|
|
setJsonResources("[]");
|
|
setJsonData(tryFormatJson(raw));
|
|
}
|
|
setDialogMode(mode);
|
|
};
|
|
|
|
const addTemplate = useMutation({
|
|
mutationFn: portalApi.addTemplate,
|
|
onSuccess: async () => {
|
|
closeDialog();
|
|
await queryClient.invalidateQueries({ queryKey: ["templates"] });
|
|
},
|
|
});
|
|
|
|
const updateTemplate = useMutation({
|
|
mutationFn: ({
|
|
id,
|
|
template,
|
|
}: {
|
|
id: string;
|
|
template: {
|
|
templateCategoryId: string;
|
|
name: string;
|
|
version: string;
|
|
description: string;
|
|
jsonData: string;
|
|
};
|
|
}) => portalApi.updateTemplate(id, template),
|
|
onSuccess: async () => {
|
|
closeDialog();
|
|
await queryClient.invalidateQueries({ queryKey: ["templates"] });
|
|
},
|
|
});
|
|
|
|
const deleteTemplate = useMutation({
|
|
mutationFn: portalApi.deleteTemplate,
|
|
onSuccess: async () => {
|
|
await queryClient.invalidateQueries({ queryKey: ["templates"] });
|
|
},
|
|
});
|
|
|
|
const submitTemplate = (event: React.FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
let composedJson = jsonData;
|
|
try {
|
|
const parsedRaw = jsonData.trim() ? JSON.parse(jsonData) : {};
|
|
const parsedParameters = jsonParameters.trim() ? JSON.parse(jsonParameters) : {};
|
|
const parsedVariables = jsonVariables.trim() ? JSON.parse(jsonVariables) : {};
|
|
const parsedResources = jsonResources.trim() ? JSON.parse(jsonResources) : [];
|
|
|
|
composedJson = JSON.stringify(
|
|
{
|
|
...parsedRaw,
|
|
parameters: parsedParameters,
|
|
variables: parsedVariables,
|
|
resources: parsedResources,
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
setJsonData(composedJson);
|
|
} catch {
|
|
// keep current raw JSON and let backend validation respond
|
|
}
|
|
|
|
const template = { description, jsonData: composedJson, name, templateCategoryId, version };
|
|
|
|
if (dialogMode === "edit" && selectedTemplate) {
|
|
updateTemplate.mutate({ id: selectedTemplate.id, template });
|
|
return;
|
|
}
|
|
|
|
addTemplate.mutate(template);
|
|
};
|
|
|
|
const formError = addTemplate.error?.message ?? updateTemplate.error?.message;
|
|
const isSaving = addTemplate.isPending || updateTemplate.isPending;
|
|
const isDetails = dialogMode === "details";
|
|
const categoryNameById = new Map(
|
|
(templateCategories ?? []).map((category: TemplateCategory) => [category.id, category.name]),
|
|
);
|
|
const categoryServiceIdById = new Map(
|
|
(templateCategories ?? []).map((category: TemplateCategory) => [category.id, category.serviceId]),
|
|
);
|
|
const serviceNameById = new Map((services ?? []).map((service: ServiceItem) => [service.id, service.name]));
|
|
const filteredTemplates =
|
|
serviceFilterId === "all"
|
|
? (data ?? [])
|
|
: (data ?? []).filter((template) => {
|
|
const templateServiceId = template.templateCategoryId
|
|
? categoryServiceIdById.get(template.templateCategoryId)
|
|
: undefined;
|
|
return templateServiceId === serviceFilterId;
|
|
});
|
|
const groupedTemplates = (services ?? [])
|
|
.map((service) => ({
|
|
serviceId: service.id,
|
|
serviceName: service.name,
|
|
categories: (templateCategories ?? [])
|
|
.filter((category) => category.serviceId === service.id)
|
|
.map((category) => ({
|
|
categoryId: category.id,
|
|
categoryName: category.name,
|
|
items: filteredTemplates
|
|
.filter((template) => template.templateCategoryId === category.id)
|
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
}))
|
|
.filter((categoryGroup) => categoryGroup.items.length > 0)
|
|
.sort((a, b) => a.categoryName.localeCompare(b.categoryName)),
|
|
}))
|
|
.filter((group) => group.categories.length > 0);
|
|
|
|
const toggleServiceGroup = (serviceId: string) => {
|
|
setExpandedServiceGroups((current) => ({
|
|
...current,
|
|
[serviceId]: !current[serviceId],
|
|
}));
|
|
};
|
|
|
|
const toggleCategoryGroup = (serviceId: string, categoryId: string) => {
|
|
const key = `${serviceId}:${categoryId}`;
|
|
setExpandedCategoryGroups((current) => ({
|
|
...current,
|
|
[key]: !current[key],
|
|
}));
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<PageHeader title="Templates" description="Vorlagen fuer Portal-Bereitstellungen." />
|
|
<div className={styles.toolbar}>
|
|
<div className={styles.toolbarActions}>
|
|
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
|
Template hinzufuegen
|
|
</Button>
|
|
</div>
|
|
<Field className={styles.filter} label="Service Filter">
|
|
<Combobox
|
|
placeholder="Alle Services"
|
|
value={serviceFilterId === "all" ? "Alle Services" : serviceNameById.get(serviceFilterId) ?? ""}
|
|
onOptionSelect={(_, selectData) => setServiceFilterId(selectData.optionValue ?? "all")}
|
|
>
|
|
<Option text="Alle Services" value="all">
|
|
Alle Services
|
|
</Option>
|
|
{(services ?? []).map((service) => (
|
|
<Option key={service.id} text={service.name} value={service.id}>
|
|
{service.name}
|
|
</Option>
|
|
))}
|
|
</Combobox>
|
|
</Field>
|
|
</div>
|
|
|
|
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
|
<DialogSurface>
|
|
<form onSubmit={submitTemplate}>
|
|
<DialogBody>
|
|
<DialogTitle>
|
|
{dialogMode === "edit"
|
|
? "Template aendern"
|
|
: dialogMode === "details"
|
|
? "Template Details"
|
|
: "Template hinzufuegen"}
|
|
</DialogTitle>
|
|
<DialogContent className={styles.form}>
|
|
<div className={styles.grid}>
|
|
<Field label="Name" required>
|
|
<Input disabled={isDetails} value={name} onChange={(_, data) => setName(data.value)} />
|
|
</Field>
|
|
<Field label="Version" required>
|
|
<Input
|
|
disabled={isDetails}
|
|
value={version}
|
|
onChange={(_, data) => setVersion(data.value)}
|
|
/>
|
|
</Field>
|
|
<Field className={styles.wide} label="Template Category" required validationMessage={formError}>
|
|
<Combobox
|
|
disabled={isDetails}
|
|
placeholder="Template Category waehlen"
|
|
value={categoryNameById.get(templateCategoryId) ?? ""}
|
|
onOptionSelect={(_, optionData) =>
|
|
setTemplateCategoryId(optionData.optionValue ?? "")
|
|
}
|
|
>
|
|
{(templateCategories ?? []).map((category) => (
|
|
<Option key={category.id} text={category.name} value={category.id}>
|
|
{category.name}
|
|
</Option>
|
|
))}
|
|
</Combobox>
|
|
</Field>
|
|
<Field className={styles.wide} label="Description">
|
|
<Textarea
|
|
disabled={isDetails}
|
|
resize="vertical"
|
|
value={description}
|
|
onChange={(_, data) => setDescription(data.value)}
|
|
/>
|
|
</Field>
|
|
<Field className={styles.wide} label="JSONData" required>
|
|
<TabList
|
|
selectedValue={editorTab}
|
|
onTabSelect={(_, tabData) => setEditorTab(tabData.value as TemplateEditorTab)}
|
|
>
|
|
<Tab value="parameters">Parameters</Tab>
|
|
<Tab value="variables">Variables</Tab>
|
|
<Tab value="resources">Resources</Tab>
|
|
<Tab value="raw">Raw JSON</Tab>
|
|
</TabList>
|
|
{editorTab === "parameters" && (
|
|
<Textarea
|
|
disabled={isDetails}
|
|
resize="vertical"
|
|
value={jsonParameters}
|
|
onChange={(_, data) => setJsonParameters(data.value)}
|
|
/>
|
|
)}
|
|
{editorTab === "variables" && (
|
|
<Textarea
|
|
disabled={isDetails}
|
|
resize="vertical"
|
|
value={jsonVariables}
|
|
onChange={(_, data) => setJsonVariables(data.value)}
|
|
/>
|
|
)}
|
|
{editorTab === "resources" && (
|
|
<Textarea
|
|
disabled={isDetails}
|
|
resize="vertical"
|
|
value={jsonResources}
|
|
onChange={(_, data) => setJsonResources(data.value)}
|
|
/>
|
|
)}
|
|
{editorTab === "raw" && (
|
|
<Textarea
|
|
disabled={isDetails}
|
|
resize="vertical"
|
|
value={jsonData}
|
|
onChange={(_, data) => setJsonData(data.value)}
|
|
/>
|
|
)}
|
|
</Field>
|
|
{selectedTemplate && (
|
|
<Field className={styles.wide} label="Id">
|
|
<div className={styles.value}>{selectedTemplate.id}</div>
|
|
</Field>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button appearance="secondary" onClick={closeDialog}>
|
|
{isDetails ? "Schliessen" : "Abbrechen"}
|
|
</Button>
|
|
{!isDetails && (
|
|
<Button
|
|
appearance="primary"
|
|
disabled={!name || !templateCategoryId || !version || !jsonData || isSaving}
|
|
type="submit"
|
|
>
|
|
Speichern
|
|
</Button>
|
|
)}
|
|
</DialogActions>
|
|
</DialogBody>
|
|
</form>
|
|
</DialogSurface>
|
|
</Dialog>
|
|
|
|
<DataState
|
|
isLoading={isLoading || templateCategoriesLoading || servicesLoading}
|
|
error={error ?? templateCategoriesError ?? servicesError ?? deleteTemplate.error}
|
|
/>
|
|
{data && (
|
|
<Table aria-label="Templates">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHeaderCell>Name</TableHeaderCell>
|
|
<TableHeaderCell>Version</TableHeaderCell>
|
|
<TableHeaderCell>Template Category</TableHeaderCell>
|
|
<TableHeaderCell>Id</TableHeaderCell>
|
|
<TableHeaderCell>Aktionen</TableHeaderCell>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{groupedTemplates.map((group) => (
|
|
<Fragment key={`group-${group.serviceId}`}>
|
|
<TableRow className={styles.groupRow} onClick={() => toggleServiceGroup(group.serviceId)}>
|
|
<TableCell colSpan={5}>
|
|
<div className={styles.groupHeader}>
|
|
{expandedServiceGroups[group.serviceId] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
|
<strong>{group.serviceName}</strong>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
{expandedServiceGroups[group.serviceId] !== false && group.categories.map((categoryGroup) => (
|
|
<Fragment key={`group-${group.serviceId}-${categoryGroup.categoryId}`}>
|
|
<TableRow
|
|
className={styles.groupRow}
|
|
onClick={() => toggleCategoryGroup(group.serviceId, categoryGroup.categoryId)}
|
|
>
|
|
<TableCell className={styles.categoryHeaderCell} colSpan={5}>
|
|
<div className={styles.groupHeader}>
|
|
{expandedCategoryGroups[`${group.serviceId}:${categoryGroup.categoryId}`] !== false ? (
|
|
<ChevronDownRegular />
|
|
) : (
|
|
<ChevronRightRegular />
|
|
)}
|
|
<strong>{categoryGroup.categoryName}</strong>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
{expandedCategoryGroups[`${group.serviceId}:${categoryGroup.categoryId}`] !== false &&
|
|
categoryGroup.items.map((template) => (
|
|
<TableRow key={template.id}>
|
|
<TableCell className={styles.nestedLevel2Cell}>{template.name}</TableCell>
|
|
<TableCell>{template.version}</TableCell>
|
|
<TableCell>
|
|
{template.templateCategoryId ? categoryNameById.get(template.templateCategoryId) ?? template.templateCategoryId : ""}
|
|
</TableCell>
|
|
<TableCell>{template.id}</TableCell>
|
|
<TableCell>
|
|
<div className={styles.actions}>
|
|
<Tooltip content="Details" relationship="label">
|
|
<Button
|
|
appearance="subtle"
|
|
aria-label="Details"
|
|
icon={<OpenRegular />}
|
|
onClick={() => openTemplateDialog("details", template)}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip content="Aendern" relationship="label">
|
|
<Button
|
|
appearance="subtle"
|
|
aria-label="Aendern"
|
|
icon={<EditRegular />}
|
|
onClick={() => openTemplateDialog("edit", template)}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip content="Loeschen" relationship="label">
|
|
<Button
|
|
appearance="subtle"
|
|
aria-label="Loeschen"
|
|
disabled={deleteTemplate.isPending}
|
|
icon={<DeleteRegular />}
|
|
onClick={() => {
|
|
if (window.confirm(`Template "${template.name}" wirklich loeschen?`)) {
|
|
deleteTemplate.mutate(template.id);
|
|
}
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</Fragment>
|
|
))}
|
|
</Fragment>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</>
|
|
);
|
|
}
|