feat: add Deployment Jobs, Template Categories, Virtual Machine details and management pages

- 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.
This commit is contained in:
Torsten Brendgen
2026-05-16 16:44:38 +02:00
parent 7080d659ef
commit 08bcd60746
19 changed files with 3265 additions and 208 deletions

View File

@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Checkbox,
Combobox,
Dialog,
DialogActions,
DialogBody,
@@ -11,6 +11,9 @@ import {
Field,
Input,
makeStyles,
Option,
Tab,
TabList,
shorthands,
Table,
TableBody,
@@ -22,18 +25,29 @@ import {
Tooltip,
} from "@fluentui/react-components";
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons";
import { useState } from "react";
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 { Template } from "../types/portal";
import type { ServiceItem, Template, TemplateCategory } from "../types/portal";
const useStyles = makeStyles({
toolbar: {
display: "flex",
justifyContent: "flex-start",
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",
@@ -54,6 +68,25 @@ const useStyles = makeStyles({
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;
@@ -62,6 +95,57 @@ 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();
@@ -69,24 +153,45 @@ export function TemplatesPage() {
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const [templateCategoryId, setTemplateCategoryId] = useState("");
const [name, setName] = useState("");
const [cloudTemplate, setCloudTemplate] = useState(false);
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("");
setCloudTemplate(false);
setVersion("");
setDescription("");
setJsonData("");
setJsonParameters("{}");
setJsonVariables("{}");
setJsonResources("[]");
setEditorTab("parameters");
};
const openAddDialog = () => {
@@ -98,10 +203,21 @@ export function TemplatesPage() {
setSelectedTemplate(template);
setTemplateCategoryId(template.templateCategoryId ?? "");
setName(template.name);
setCloudTemplate(Boolean(template.cloudTemplate));
setVersion(template.version ?? "");
setDescription(template.description ?? "");
setJsonData(getTemplateJsonData(template));
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);
};
@@ -122,7 +238,6 @@ export function TemplatesPage() {
template: {
templateCategoryId: string;
name: string;
cloudTemplate: boolean;
version: string;
description: string;
jsonData: string;
@@ -143,7 +258,29 @@ export function TemplatesPage() {
const submitTemplate = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const template = { cloudTemplate, description, jsonData, name, templateCategoryId, version };
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 });
@@ -156,14 +293,80 @@ export function TemplatesPage() {
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}>
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
Template hinzufuegen
</Button>
<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()}>
@@ -189,20 +392,21 @@ export function TemplatesPage() {
onChange={(_, data) => setVersion(data.value)}
/>
</Field>
<Field className={styles.wide} label="TemplateCategoryId" required validationMessage={formError}>
<Input
<Field className={styles.wide} label="Template Category" required validationMessage={formError}>
<Combobox
disabled={isDetails}
value={templateCategoryId}
onChange={(_, data) => setTemplateCategoryId(data.value)}
/>
</Field>
<Field className={styles.wide}>
<Checkbox
checked={cloudTemplate}
disabled={isDetails}
label="Cloud Template"
onChange={(_, data) => setCloudTemplate(Boolean(data.checked))}
/>
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
@@ -213,12 +417,47 @@ export function TemplatesPage() {
/>
</Field>
<Field className={styles.wide} label="JSONData" required>
<Textarea
disabled={isDetails}
resize="vertical"
value={jsonData}
onChange={(_, data) => setJsonData(data.value)}
/>
<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">
@@ -246,61 +485,96 @@ export function TemplatesPage() {
</DialogSurface>
</Dialog>
<DataState isLoading={isLoading} error={error ?? deleteTemplate.error} />
<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>Cloud</TableHeaderCell>
<TableHeaderCell>TemplateCategoryId</TableHeaderCell>
<TableHeaderCell>Template Category</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
<TableHeaderCell>Aktionen</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{data.map((template) => (
<TableRow key={template.id}>
<TableCell>{template.name}</TableCell>
<TableCell>{template.version}</TableCell>
<TableCell>{template.cloudTemplate ? "Ja" : "Nein"}</TableCell>
<TableCell>{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>
{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>