Files
Microsoft.SelfService.Porta…/src/pages/TemplatesPage.tsx
Torsten Brendgen 08bcd60746 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.
2026-05-16 16:44:38 +02:00

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