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,30 +1,53 @@
import { deleteJson, getJson, postJson, putJson } from "./httpClient"; import { deleteJson, getJson, postJson, putJson } from "./httpClient";
import type { import type {
AddDeployment, AddDeploymentExecution,
AddDeploymentGroup, AddDeploymentRequest,
AddDeploymentBatch,
AddDomain, AddDomain,
AddEnvironment, AddEnvironment,
AddRunbook, AddRunbook,
AddService, AddService,
AddServiceRoleDefinition,
AddTemplateCategory,
AddTemplate, AddTemplate,
Deployment, DeploymentExecution,
DeploymentGroup, DeploymentBatch,
DeploymentBatchDetails,
Domain, Domain,
DomainWithEnvironments, DomainWithEnvironments,
DomainWithVirtualMachines,
EnvironmentItem, EnvironmentItem,
EnvironmentWithDomains, EnvironmentWithDomains,
Runbook, Runbook,
ServiceItem, ServiceItem,
ServiceRoleDefinition,
TemplateCategory,
Template, Template,
VirtualMachine,
AddVirtualMachine,
DeploymentJob,
DeploymentJobDetails,
} from "../types/portal"; } from "../types/portal";
export const portalApi = { export const portalApi = {
getDeployments: (signal?: AbortSignal) => getJson<Deployment[]>("/Deployment", signal), getDeployments: (signal?: AbortSignal) => getJson<DeploymentExecution[]>("/Deployment", signal),
addDeployment: (deployment: AddDeployment) => postJson<AddDeployment, string>("/Deployment", deployment), addDeployment: (deployment: AddDeploymentExecution) => postJson<AddDeploymentExecution, string>("/Deployment", deployment),
getDeploymentGroups: (signal?: AbortSignal) => getJson<DeploymentGroup[]>("/DeploymentGroup", signal), addDeploymentRequest: (request: AddDeploymentRequest) => postJson<AddDeploymentRequest, string>("/Deployment/Request", request),
addDeploymentGroup: (deploymentGroup: AddDeploymentGroup) => postJson<AddDeploymentGroup, string>("/DeploymentGroup", deploymentGroup), getDeploymentJobs: (signal?: AbortSignal) => getJson<DeploymentJob[]>("/Deployment/QueueJobs", signal),
getDeploymentJobById: (deploymentJobId: string, signal?: AbortSignal) =>
getJson<DeploymentJobDetails>(`/Deployment/QueueJobs/${deploymentJobId}`, signal),
retryDeploymentJob: (deploymentJobId: string) => postJson<undefined, void>(`/Deployment/QueueJobs/${deploymentJobId}/Retry`, undefined),
approveDeploymentJobStep: (stepId: string, comment?: string) =>
postJson<{ comment?: string }, void>(`/Deployment/QueueJobs/Steps/${stepId}/Approve`, { comment }),
rejectDeploymentJobStep: (stepId: string, comment?: string) =>
postJson<{ comment?: string }, void>(`/Deployment/QueueJobs/Steps/${stepId}/Reject`, { comment }),
getDeploymentBatches: (signal?: AbortSignal) => getJson<DeploymentBatch[]>("/DeploymentGroup", signal),
getDeploymentBatchById: (deploymentBatchId: string, signal?: AbortSignal) =>
getJson<DeploymentBatchDetails>(`/DeploymentGroup/${deploymentBatchId}`, signal),
addDeploymentBatch: (deploymentBatch: AddDeploymentBatch) => postJson<AddDeploymentBatch, string>("/DeploymentGroup", deploymentBatch),
getDomains: (signal?: AbortSignal) => getJson<Domain[]>("/Domain", signal), getDomains: (signal?: AbortSignal) => getJson<Domain[]>("/Domain", signal),
getDomainEnvironments: (domainId: string, signal?: AbortSignal) => getJson<DomainWithEnvironments>(`/Domain/${domainId}/Environments`, signal), getDomainEnvironments: (domainId: string, signal?: AbortSignal) => getJson<DomainWithEnvironments>(`/Domain/${domainId}/Environments`, signal),
getDomainVirtualMachines: (domainId: string, signal?: AbortSignal) => getJson<DomainWithVirtualMachines>(`/Domain/${domainId}/VirtualMachines`, signal),
addDomain: (domain: AddDomain) => postJson<AddDomain, string>("/Domain", domain), addDomain: (domain: AddDomain) => postJson<AddDomain, string>("/Domain", domain),
updateDomain: (domainId: string, domain: AddDomain) => putJson<AddDomain, void>(`/Domain/${domainId}`, domain), updateDomain: (domainId: string, domain: AddDomain) => putJson<AddDomain, void>(`/Domain/${domainId}`, domain),
deleteDomain: (domainId: string) => deleteJson<void>(`/Domain/${domainId}`), deleteDomain: (domainId: string) => deleteJson<void>(`/Domain/${domainId}`),
@@ -44,4 +67,26 @@ export const portalApi = {
addService: (service: AddService) => postJson<AddService, string>("/Service", service), addService: (service: AddService) => postJson<AddService, string>("/Service", service),
updateService: (serviceId: string, service: AddService) => putJson<AddService, void>(`/Service/${serviceId}`, service), updateService: (serviceId: string, service: AddService) => putJson<AddService, void>(`/Service/${serviceId}`, service),
deleteService: (serviceId: string) => deleteJson<void>(`/Service/${serviceId}`), deleteService: (serviceId: string) => deleteJson<void>(`/Service/${serviceId}`),
getServiceRoleDefinitions: (serviceId: string, signal?: AbortSignal) =>
getJson<ServiceRoleDefinition[]>(`/Service/${serviceId}/RoleDefinitions`, signal),
addServiceRoleDefinition: (serviceId: string, roleDefinition: AddServiceRoleDefinition) =>
postJson<AddServiceRoleDefinition, string>(`/Service/${serviceId}/RoleDefinitions`, roleDefinition),
updateServiceRoleDefinition: (serviceId: string, roleDefinitionId: string, roleDefinition: AddServiceRoleDefinition) =>
putJson<AddServiceRoleDefinition, void>(`/Service/${serviceId}/RoleDefinitions/${roleDefinitionId}`, roleDefinition),
deleteServiceRoleDefinition: (serviceId: string, roleDefinitionId: string) =>
deleteJson<void>(`/Service/${serviceId}/RoleDefinitions/${roleDefinitionId}`),
getTemplateCategories: (signal?: AbortSignal) => getJson<TemplateCategory[]>("/TemplateCategory", signal),
addTemplateCategory: (templateCategory: AddTemplateCategory) => postJson<AddTemplateCategory, string>("/TemplateCategory", templateCategory),
updateTemplateCategory: (templateCategoryId: string, templateCategory: AddTemplateCategory) =>
putJson<AddTemplateCategory, void>(`/TemplateCategory/${templateCategoryId}`, templateCategory),
deleteTemplateCategory: (templateCategoryId: string) => deleteJson<void>(`/TemplateCategory/${templateCategoryId}`),
getVirtualMachines: (signal?: AbortSignal) => getJson<VirtualMachine[]>("/VirtualMachine", signal),
getVirtualMachineById: (virtualMachineId: string, signal?: AbortSignal) => getJson<VirtualMachine>(`/VirtualMachine/${virtualMachineId}`, signal),
addVirtualMachine: (virtualMachine: AddVirtualMachine) => postJson<AddVirtualMachine, string>("/VirtualMachine", virtualMachine),
updateVirtualMachine: (virtualMachineId: string, virtualMachine: AddVirtualMachine) =>
putJson<AddVirtualMachine, void>(`/VirtualMachine/${virtualMachineId}`, virtualMachine),
deleteVirtualMachine: (virtualMachineId: string) => deleteJson<void>(`/VirtualMachine/${virtualMachineId}`),
linkVirtualMachineToDomain: (virtualMachineId: string, domainId: string) =>
postJson<undefined, void>(`/VirtualMachine/${virtualMachineId}/Domain/${domainId}`, undefined),
unlinkVirtualMachineFromDomain: (virtualMachineId: string) => deleteJson<void>(`/VirtualMachine/${virtualMachineId}/Domain`),
}; };

View File

@@ -10,13 +10,11 @@ import {
import { import {
AppsListDetail24Regular, AppsListDetail24Regular,
BoxMultiple24Regular, BoxMultiple24Regular,
CloudFlow24Regular,
DatabasePlugConnectedRegular, DatabasePlugConnectedRegular,
LinkMultiple24Regular,
Globe24Regular, Globe24Regular,
Home24Regular, Home24Regular,
PlayCircle24Regular, TagMultiple24Regular,
ServerMultipleRegular, Desktop24Regular,
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
const useStyles = makeStyles({ const useStyles = makeStyles({
@@ -72,14 +70,15 @@ const useStyles = makeStyles({
const links = [ const links = [
{ to: "/", label: "Dashboard", icon: <Home24Regular /> }, { to: "/", label: "Dashboard", icon: <Home24Regular /> },
//{ to: "/deployments", label: "Deployments", icon: <CloudFlow24Regular /> }, { to: "/deployments", label: "Deployments", icon: <BoxMultiple24Regular /> },
//{ to: "/deployment-groups", label: "Deployment Groups", icon: <ServerMultipleRegular /> }, { to: "/worker-jobs", label: "Worker Jobs", icon: <AppsListDetail24Regular /> },
{ to: "/domains", label: "Domains", icon: <Globe24Regular /> }, { to: "/domains", label: "Domains", icon: <Globe24Regular /> },
{ to: "/environment-domains", label: "Environment Domains", icon: <LinkMultiple24Regular /> },
{ to: "/environments", label: "Environments", icon: <DatabasePlugConnectedRegular /> }, { to: "/environments", label: "Environments", icon: <DatabasePlugConnectedRegular /> },
//{ to: "/runbooks", label: "Runbooks", icon: <PlayCircle24Regular /> }, //{ to: "/runbooks", label: "Runbooks", icon: <PlayCircle24Regular /> },
{ to: "/templates", label: "Templates", icon: <BoxMultiple24Regular /> }, { to: "/templates", label: "Templates", icon: <BoxMultiple24Regular /> },
{ to: "/template-categories", label: "Template Categories", icon: <TagMultiple24Regular /> },
{ to: "/services", label: "Services", icon: <AppsListDetail24Regular /> }, { to: "/services", label: "Services", icon: <AppsListDetail24Regular /> },
{ to: "/virtual-machines", label: "Virtual Machines", icon: <Desktop24Regular /> },
]; ];
export function AppShell() { export function AppShell() {

View File

@@ -5,16 +5,19 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { AppShell } from "./layout/AppShell"; import { AppShell } from "./layout/AppShell";
import { DashboardPage } from "./pages/DashboardPage"; import { DashboardPage } from "./pages/DashboardPage";
import { DeploymentsPage } from "./pages/DeploymentsPage"; import { DeploymentBatchDetailsPage } from "./pages/DeploymentBatchDetailsPage";
import { DeploymentJobDetailsPage } from "./pages/DeploymentJobDetailsPage";
import { DeploymentJobsPage } from "./pages/DeploymentJobsPage";
import { DeploymentGroupsPage } from "./pages/DeploymentGroupsPage"; import { DeploymentGroupsPage } from "./pages/DeploymentGroupsPage";
import { DomainDetailsPage } from "./pages/DomainDetailsPage"; import { DomainDetailsPage } from "./pages/DomainDetailsPage";
import { DomainsPage } from "./pages/DomainsPage"; import { DomainsPage } from "./pages/DomainsPage";
import { EnvironmentDomainsPage } from "./pages/EnvironmentDomainsPage";
import { EnvironmentDetailsPage } from "./pages/EnvironmentDetailsPage"; import { EnvironmentDetailsPage } from "./pages/EnvironmentDetailsPage";
import { EnvironmentsPage } from "./pages/EnvironmentsPage"; import { EnvironmentsPage } from "./pages/EnvironmentsPage";
import { RunbooksPage } from "./pages/RunbooksPage";
import { TemplatesPage } from "./pages/TemplatesPage"; import { TemplatesPage } from "./pages/TemplatesPage";
import { ServicesPage } from "./pages/ServicesPage"; import { ServicesPage } from "./pages/ServicesPage";
import { TemplateCategoriesPage } from "./pages/TemplateCategoriesPage";
import { VirtualMachineDetailsPage } from "./pages/VirtualMachineDetailsPage";
import { VirtualMachinesPage } from "./pages/VirtualMachinesPage";
import "./styles/global.css"; import "./styles/global.css";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -32,16 +35,19 @@ const router = createBrowserRouter([
element: <AppShell />, element: <AppShell />,
children: [ children: [
{ index: true, element: <DashboardPage /> }, { index: true, element: <DashboardPage /> },
{ path: "deployments", element: <DeploymentsPage /> }, { path: "deployments", element: <DeploymentGroupsPage /> },
{ path: "deployment-groups", element: <DeploymentGroupsPage /> }, { path: "deployments/:id", element: <DeploymentBatchDetailsPage /> },
{ path: "worker-jobs", element: <DeploymentJobsPage /> },
{ path: "worker-jobs/:id", element: <DeploymentJobDetailsPage /> },
{ path: "domains", element: <DomainsPage /> }, { path: "domains", element: <DomainsPage /> },
{ path: "domains/:id", element: <DomainDetailsPage /> }, { path: "domains/:id", element: <DomainDetailsPage /> },
{ path: "environment-domains", element: <EnvironmentDomainsPage /> },
{ path: "environments", element: <EnvironmentsPage /> }, { path: "environments", element: <EnvironmentsPage /> },
{ path: "environments/:id", element: <EnvironmentDetailsPage /> }, { path: "environments/:id", element: <EnvironmentDetailsPage /> },
{ path: "runbooks", element: <RunbooksPage /> },
{ path: "templates", element: <TemplatesPage /> }, { path: "templates", element: <TemplatesPage /> },
{ path: "template-categories", element: <TemplateCategoriesPage /> },
{ path: "services", element: <ServicesPage /> }, { path: "services", element: <ServicesPage /> },
{ path: "virtual-machines", element: <VirtualMachinesPage /> },
{ path: "virtual-machines/:id", element: <VirtualMachineDetailsPage /> },
], ],
}, },
]); ]);

View File

@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { import {
Card, Card,
CardHeader, CardHeader,
Divider,
makeStyles, makeStyles,
Text, Text,
tokens, tokens,
@@ -16,11 +17,43 @@ const useStyles = makeStyles({
gridTemplateColumns: "repeat(4, minmax(160px, 1fr))", gridTemplateColumns: "repeat(4, minmax(160px, 1fr))",
gap: "16px", gap: "16px",
}, },
chartGrid: {
display: "grid",
gridTemplateColumns: "repeat(2, minmax(280px, 1fr))",
gap: "16px",
marginTop: "16px",
},
metric: { metric: {
fontSize: "32px", fontSize: "32px",
fontWeight: tokens.fontWeightSemibold, fontWeight: tokens.fontWeightSemibold,
lineHeight: "40px", lineHeight: "40px",
}, },
donutWrap: {
display: "flex",
alignItems: "center",
gap: "16px",
},
donut: {
width: "120px",
height: "120px",
borderRadius: "50%",
flexShrink: 0,
},
legend: {
display: "grid",
gap: "8px",
},
legendRow: {
display: "flex",
alignItems: "center",
gap: "8px",
},
swatch: {
width: "10px",
height: "10px",
borderRadius: "50%",
display: "inline-block",
},
}); });
export function DashboardPage() { export function DashboardPage() {
@@ -41,10 +74,46 @@ export function DashboardPage() {
queryKey: ["services"], queryKey: ["services"],
queryFn: ({ signal }) => portalApi.getServices(signal), queryFn: ({ signal }) => portalApi.getServices(signal),
}); });
const queueJobs = useQuery({
queryKey: ["queue-jobs"],
queryFn: ({ signal }) => portalApi.getDeploymentJobs(signal),
});
const virtualMachines = useQuery({
queryKey: ["virtual-machines"],
queryFn: ({ signal }) => portalApi.getVirtualMachines(signal),
});
const error = domains.error ?? environments.error ?? templates.error ?? services.error; const error =
domains.error ??
environments.error ??
templates.error ??
services.error ??
queueJobs.error ??
virtualMachines.error;
const isLoading = const isLoading =
domains.isLoading || environments.isLoading || templates.isLoading || services.isLoading; domains.isLoading ||
environments.isLoading ||
templates.isLoading ||
services.isLoading ||
queueJobs.isLoading ||
virtualMachines.isLoading;
const queueByStatus = (queueJobs.data ?? []).reduce<Record<string, number>>((acc, job) => {
const key = job.status || "Unknown";
acc[key] = (acc[key] ?? 0) + 1;
return acc;
}, {});
const vmCompliance = (virtualMachines.data ?? []).reduce(
(acc, vm) => {
const compliance = getVmCompliance(vm.metadataJson);
if (compliance === true) acc.compliant += 1;
else if (compliance === false) acc.nonCompliant += 1;
else acc.unknown += 1;
return acc;
},
{ compliant: 0, nonCompliant: 0, unknown: 0 },
);
return ( return (
<> <>
@@ -58,6 +127,26 @@ export function DashboardPage() {
<MetricCard label="Services" value={services.data?.length ?? 0} /> <MetricCard label="Services" value={services.data?.length ?? 0} />
</div> </div>
)} )}
{!isLoading && !error && (
<div className={styles.chartGrid}>
<DonutCard
title="Jobs"
items={Object.entries(queueByStatus).map(([label, value], index) => ({
label,
value,
color: chartColors[index % chartColors.length],
}))}
/>
<DonutCard
title="VM DSC Compliance"
items={[
{ label: "Compliant", value: vmCompliance.compliant, color: "#107C10" },
{ label: "Non-Compliant", value: vmCompliance.nonCompliant, color: "#D13438" },
{ label: "Unknown", value: vmCompliance.unknown, color: "#8A8886" },
]}
/>
</div>
)}
</> </>
); );
} }
@@ -72,3 +161,64 @@ function MetricCard({ label, value }: { label: string; value: number }) {
</Card> </Card>
); );
} }
const chartColors = ["#0F6CBD", "#107C10", "#D13438", "#8764B8", "#CA5010", "#605E5C"];
function DonutCard({
title,
items,
}: {
title: string;
items: Array<{ label: string; value: number; color: string }>;
}) {
const styles = useStyles();
const total = items.reduce((sum, item) => sum + item.value, 0);
const segments = total
? items
.map((item) => `${item.color} ${(item.value / total) * 100}%`)
.join(", ")
: "#E1DFDD 100%";
return (
<Card>
<CardHeader header={<Text weight="semibold">{title}</Text>} />
<Divider />
<div className={styles.donutWrap}>
<div className={styles.donut} style={{ background: `conic-gradient(${segments})` }} />
<div className={styles.legend}>
{items.map((item) => (
<div key={item.label} className={styles.legendRow}>
<span className={styles.swatch} style={{ backgroundColor: item.color }} />
<Text>
{item.label}: {item.value}
</Text>
</div>
))}
<Text>Total: {total}</Text>
</div>
</div>
</Card>
);
}
function getVmCompliance(metadataJson?: string): boolean | undefined {
if (!metadataJson) {
return undefined;
}
try {
const parsed = JSON.parse(metadataJson) as Record<string, unknown>;
const keys = ["dscCompliant", "isDscCompliant", "compliant"];
for (const key of keys) {
const value = parsed[key];
if (typeof value === "boolean") {
return value;
}
}
} catch {
return undefined;
}
return undefined;
}

View File

@@ -0,0 +1,118 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Checkbox,
Field,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
Textarea,
} from "@fluentui/react-components";
import { ArrowLeftRegular } from "@fluentui/react-icons";
import { useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { FormActions, FormGrid, FormSection, FormWide } from "../components/FormSection";
import { PageHeader } from "../components/PageHeader";
export function DeploymentBatchDetailsPage() {
const queryClient = useQueryClient();
const { id } = useParams();
const [selectedVirtualMachineIds, setSelectedVirtualMachineIds] = useState<string[]>([]);
const [jsonData, setJsonData] = useState("{}");
const { data: deploymentBatch, error, isLoading } = useQuery({
queryKey: ["deployment-batches", id],
queryFn: ({ signal }) => portalApi.getDeploymentBatchById(id!, signal),
enabled: Boolean(id),
});
const { data: virtualMachines } = useQuery({
queryKey: ["virtual-machines"],
queryFn: ({ signal }) => portalApi.getVirtualMachines(signal),
});
const addDeploymentRequest = useMutation({
mutationFn: portalApi.addDeploymentRequest,
onSuccess: async () => {
setSelectedVirtualMachineIds([]);
setJsonData("{}");
await queryClient.invalidateQueries({ queryKey: ["deployments"] });
await queryClient.invalidateQueries({ queryKey: ["deployment-batches", id] });
await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] });
},
});
const filteredExecutions = useMemo(() => deploymentBatch?.deployments ?? [], [deploymentBatch?.deployments]);
return (
<>
<Link to="/deployments">
<Button appearance="subtle" icon={<ArrowLeftRegular />}>
Deployments
</Button>
</Link>
<PageHeader title={id ? `Deployment Batch ${id}` : "Deployment Batch"} description="Executions und Start neuer Deployments fuer diesen Batch." />
<FormSection
onSubmit={(event) => {
event.preventDefault();
if (!id) return;
addDeploymentRequest.mutate({ deploymentBatchId: id, jsonData, virtualMachineIds: selectedVirtualMachineIds });
}}
>
<FormGrid>
<Field label="Targets (Virtual Machines)" required>
<div>
{(virtualMachines ?? []).map((virtualMachine) => (
<Checkbox
key={virtualMachine.id}
label={virtualMachine.name}
checked={selectedVirtualMachineIds.includes(virtualMachine.id)}
onChange={(_, data) => {
if (data.checked) {
setSelectedVirtualMachineIds((previous) => [...previous, virtualMachine.id]);
} else {
setSelectedVirtualMachineIds((previous) => previous.filter((vmId) => vmId !== virtualMachine.id));
}
}}
/>
))}
</div>
</Field>
<FormWide>
<Field label="JSON data" required validationMessage={addDeploymentRequest.error?.message}>
<Textarea value={jsonData} onChange={(_, data) => setJsonData(data.value)} />
</Field>
</FormWide>
</FormGrid>
<FormActions>
<Button appearance="primary" disabled={!id || selectedVirtualMachineIds.length === 0 || addDeploymentRequest.isPending} type="submit">
Start deployment
</Button>
</FormActions>
</FormSection>
<DataState isLoading={isLoading} error={error} />
<Table aria-label="Deployment executions">
<TableHeader>
<TableRow>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Virtual Machine</TableHeaderCell>
<TableHeaderCell>Execution Id</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredExecutions.map((execution) => (
<TableRow key={execution.id}>
<TableCell>{execution.status ?? "Unknown"}</TableCell>
<TableCell>{execution.virtualMachineId ?? "-"}</TableCell>
<TableCell>{execution.id}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
);
}

View File

@@ -1,79 +1,191 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Checkbox,
Combobox,
Field, Field,
Input, makeStyles,
Option,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableHeader, TableHeader,
TableHeaderCell, TableHeaderCell,
TableRow, TableRow,
Tooltip,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { OpenRegular } from "@fluentui/react-icons";
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom";
import { portalApi } from "../api/portalApi"; import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState"; import { DataState } from "../components/DataState";
import { FormActions, FormGrid, FormSection } from "../components/FormSection"; import { FormActions, FormGrid, FormSection } from "../components/FormSection";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
export function DeploymentGroupsPage() { const useStyles = makeStyles({
const queryClient = useQueryClient(); actions: {
const [templateId, setTemplateId] = useState(""); display: "flex",
const [status, setStatus] = useState("New"); gap: "6px",
const { data, error, isLoading } = useQuery({
queryKey: ["deploymentGroups"],
queryFn: ({ signal }) => portalApi.getDeploymentGroups(signal),
});
const addDeploymentGroup = useMutation({
mutationFn: portalApi.addDeploymentGroup,
onSuccess: async () => {
setTemplateId("");
setStatus("New");
await queryClient.invalidateQueries({ queryKey: ["deploymentGroups"] });
}, },
}); });
export function DeploymentGroupsPage() {
const styles = useStyles();
const queryClient = useQueryClient();
const [serviceId, setServiceId] = useState("");
const [templateId, setTemplateId] = useState("");
const [virtualMachineIds, setVirtualMachineIds] = useState<string[]>([]);
const [status, setStatus] = useState("New");
const { data, error, isLoading } = useQuery({
queryKey: ["deployment-batches"],
queryFn: ({ signal }) => portalApi.getDeploymentBatches(signal),
});
const { data: services } = useQuery({
queryKey: ["services"],
queryFn: ({ signal }) => portalApi.getServices(signal),
});
const { data: templates } = useQuery({
queryKey: ["templates"],
queryFn: ({ signal }) => portalApi.getTemplates(signal),
});
const { data: templateCategories } = useQuery({
queryKey: ["template-categories"],
queryFn: ({ signal }) => portalApi.getTemplateCategories(signal),
});
const { data: virtualMachines } = useQuery({
queryKey: ["virtual-machines"],
queryFn: ({ signal }) => portalApi.getVirtualMachines(signal),
});
const addDeploymentBatch = useMutation({
mutationFn: portalApi.addDeploymentBatch,
onSuccess: async () => {
setServiceId("");
setTemplateId("");
setVirtualMachineIds([]);
setStatus("New");
await queryClient.invalidateQueries({ queryKey: ["deployment-batches"] });
await queryClient.invalidateQueries({ queryKey: ["deployments"] });
},
});
const selectedService = (services ?? []).find((service) => service.id === serviceId);
const isOnPremService = selectedService ? !selectedService.isCloudService : false;
return ( return (
<> <>
<PageHeader title="Deployment Groups" description="Gruppen von Bereitstellungen je Template." /> <PageHeader title="Deployments" description="Deployment Batches und deren Ausfuehrungen." />
<FormSection <FormSection
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
addDeploymentGroup.mutate({ status, templateId }); addDeploymentBatch.mutate({
status,
templateId,
virtualMachineIds: isOnPremService ? virtualMachineIds : undefined,
});
}} }}
> >
<FormGrid> <FormGrid>
<Field label="Template Id" required> <Field label="Service" required>
<Input value={templateId} onChange={(_, data) => setTemplateId(data.value)} /> <Combobox
placeholder="Service waehlen"
value={services?.find((service) => service.id === serviceId)?.name ?? ""}
onOptionSelect={(_, data) => {
const nextServiceId = data.optionValue ?? "";
setServiceId(nextServiceId);
setTemplateId("");
setVirtualMachineIds([]);
}}
>
{(services ?? []).map((service) => (
<Option key={service.id} text={service.name} value={service.id}>
{service.name}
</Option>
))}
</Combobox>
</Field> </Field>
<Field label="Status" required validationMessage={addDeploymentGroup.error?.message}> <Field label="Template" required>
<Input value={status} onChange={(_, data) => setStatus(data.value)} /> <Combobox
placeholder={serviceId ? "Template waehlen" : "Erst Service waehlen"}
disabled={!serviceId}
value={templates?.find((template) => template.id === templateId)?.name ?? ""}
onOptionSelect={(_, data) => setTemplateId(data.optionValue ?? "")}
>
{(templates ?? [])
.filter((template) => {
const category = (templateCategories ?? []).find((entry) => entry.id === template.templateCategoryId);
return category?.serviceId === serviceId;
})
.map((template) => (
<Option key={template.id} text={template.name} value={template.id}>
{template.name}
</Option>
))}
</Combobox>
</Field> </Field>
<Field label="Status" required validationMessage={addDeploymentBatch.error?.message}>
<Combobox value={status} onOptionSelect={(_, data) => setStatus(data.optionValue ?? "New")}>
<Option value="New">New</Option>
<Option value="Pending">Pending</Option>
<Option value="Running">Running</Option>
<Option value="Succeeded">Succeeded</Option>
<Option value="Failed">Failed</Option>
</Combobox>
</Field>
{isOnPremService && (
<Field label="Virtual Machines" required>
<div>
{(virtualMachines ?? []).map((virtualMachine) => (
<Checkbox
key={virtualMachine.id}
label={virtualMachine.name}
checked={virtualMachineIds.includes(virtualMachine.id)}
onChange={(_, data) => {
if (data.checked) {
setVirtualMachineIds((prev) => [...prev, virtualMachine.id]);
} else {
setVirtualMachineIds((prev) => prev.filter((id) => id !== virtualMachine.id));
}
}}
/>
))}
</div>
</Field>
)}
</FormGrid> </FormGrid>
<FormActions> <FormActions>
<Button <Button
appearance="primary" appearance="primary"
disabled={!templateId || !status || addDeploymentGroup.isPending} disabled={!templateId || !status || (isOnPremService && virtualMachineIds.length === 0) || addDeploymentBatch.isPending}
type="submit" type="submit"
> >
Add deployment group Add deployment batch
</Button> </Button>
</FormActions> </FormActions>
</FormSection> </FormSection>
<DataState isLoading={isLoading} error={error} /> <DataState isLoading={isLoading} error={error} />
{data && ( {data && (
<Table aria-label="Deployment groups"> <Table aria-label="Deployment batches">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>Status</TableHeaderCell> <TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell> <TableHeaderCell>Id</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.map((deploymentGroup) => ( {data.map((deploymentBatch) => (
<TableRow key={deploymentGroup.id}> <TableRow key={deploymentBatch.id}>
<TableCell>{deploymentGroup.status ?? "Unknown"}</TableCell> <TableCell>{deploymentBatch.status ?? "Unknown"}</TableCell>
<TableCell>{deploymentGroup.id}</TableCell> <TableCell>{deploymentBatch.id}</TableCell>
<TableCell>
<div className={styles.actions}>
<Tooltip content="Details" relationship="label">
<Link to={`/deployments/${deploymentBatch.id}`}>
<Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} />
</Link>
</Tooltip>
</div>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@@ -0,0 +1,229 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Badge,
Button,
Field,
Input,
makeStyles,
shorthands,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
Text,
tokens,
} from "@fluentui/react-components";
import { ArrowLeftRegular } from "@fluentui/react-icons";
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { PageHeader } from "../components/PageHeader";
const useStyles = makeStyles({
backLink: {
display: "inline-flex",
marginBottom: "18px",
textDecorationLine: "none",
},
details: {
display: "grid",
gap: "12px",
gridTemplateColumns: "repeat(2, minmax(220px, 1fr))",
marginBottom: "18px",
...shorthands.border("1px", "solid", tokens.colorNeutralStroke2),
...shorthands.borderRadius("8px"),
...shorthands.padding("16px"),
},
field: {
display: "grid",
gap: "4px",
minWidth: 0,
},
wide: {
gridColumn: "1 / -1",
},
value: {
overflowWrap: "anywhere",
},
});
export function DeploymentJobDetailsPage() {
const styles = useStyles();
const queryClient = useQueryClient();
const { id } = useParams();
const [approvalComment, setApprovalComment] = useState("");
const { data, error, isLoading } = useQuery({
enabled: Boolean(id),
queryKey: ["deployment-job", id],
queryFn: ({ signal }) => portalApi.getDeploymentJobById(id!, signal),
refetchInterval: 5000,
});
const retryDeploymentJob = useMutation({
mutationFn: portalApi.retryDeploymentJob,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["deployment-job", id] });
await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] });
},
});
const approveStep = useMutation({
mutationFn: (stepId: string) => portalApi.approveDeploymentJobStep(stepId, approvalComment || undefined),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["deployment-job", id] });
await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] });
setApprovalComment("");
},
});
const rejectStep = useMutation({
mutationFn: (stepId: string) => portalApi.rejectDeploymentJobStep(stepId, approvalComment || undefined),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["deployment-job", id] });
await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] });
setApprovalComment("");
},
});
return (
<>
<Link className={styles.backLink} to="/worker-jobs">
<Button appearance="subtle" icon={<ArrowLeftRegular />}>
Worker Jobs
</Button>
</Link>
<PageHeader title={data ? `Worker Job ${data.id}` : "Worker Job Details"} description="Detailansicht inklusive Pipeline-Steps und Targets." />
<DataState isLoading={isLoading} error={error ?? retryDeploymentJob.error ?? approveStep.error ?? rejectStep.error} />
{data && (
<>
<section className={styles.details}>
<div className={styles.field}>
<Text size={200}>Status</Text>
<Badge
appearance="filled"
color={
data.status === "Succeeded"
? "success"
: data.status === "Failed"
? "danger"
: data.status === "Running"
? "warning"
: "informative"
}
>
{data.status}
</Badge>
</div>
<div className={styles.field}>
<Text size={200}>Type</Text>
<Text className={styles.value} weight="semibold">
{data.type}
</Text>
</div>
<div className={styles.field}>
<Text size={200}>Attempts</Text>
<Text className={styles.value} weight="semibold">
{data.attempts}/{data.maxAttempts}
</Text>
</div>
<div className={styles.field}>
<Text size={200}>Targets</Text>
<Text className={styles.value} weight="semibold">
{data.targets.length}
</Text>
</div>
<div className={`${styles.field} ${styles.wide}`}>
<Text size={200}>Error</Text>
<Text className={styles.value} weight="semibold">
{data.errorMessage ?? "-"}
</Text>
</div>
<div className={`${styles.field} ${styles.wide}`}>
<Text size={200}>Payload JSON</Text>
<Text className={styles.value} weight="semibold">
{data.payloadJson}
</Text>
</div>
</section>
<Button
appearance="secondary"
disabled={(data.status !== "Failed" && data.status !== "Cancelled") || retryDeploymentJob.isPending}
onClick={() => retryDeploymentJob.mutate(data.id)}
style={{ marginBottom: "12px" }}
>
Retry Worker Job
</Button>
<Field className={styles.wide} label="Approval Comment">
<Input value={approvalComment} onChange={(_, d) => setApprovalComment(d.value)} />
</Field>
<Table aria-label="Deployment job steps">
<TableHeader>
<TableRow>
<TableHeaderCell>Order</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Type</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Depends On</TableHeaderCell>
<TableHeaderCell>Approval</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{(data.steps ?? []).map((step) => (
<TableRow key={step.id}>
<TableCell>{step.sortOrder}</TableCell>
<TableCell>{step.name}</TableCell>
<TableCell>{step.stepType}</TableCell>
<TableCell>{step.status}</TableCell>
<TableCell>{step.dependsOnDeploymentJobStepId ?? "-"}</TableCell>
<TableCell>
{step.stepType === "Approval" && step.status === "WaitingForApproval" ? (
<>
<Button appearance="primary" size="small" onClick={() => approveStep.mutate(step.id)} disabled={approveStep.isPending || rejectStep.isPending}>
Approve
</Button>
<Button appearance="secondary" size="small" onClick={() => rejectStep.mutate(step.id)} disabled={approveStep.isPending || rejectStep.isPending}>
Reject
</Button>
</>
) : (
"-"
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Table aria-label="Deployment job targets">
<TableHeader>
<TableRow>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Virtual Machine Id</TableHeaderCell>
<TableHeaderCell>Deployment Batch Id</TableHeaderCell>
<TableHeaderCell>Template Id</TableHeaderCell>
<TableHeaderCell>Attempts</TableHeaderCell>
<TableHeaderCell>Error</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{data.targets.map((target) => (
<TableRow key={target.id}>
<TableCell>{target.status}</TableCell>
<TableCell>{target.virtualMachineId}</TableCell>
<TableCell>{target.deploymentBatchId}</TableCell>
<TableCell>{target.templateId}</TableCell>
<TableCell>{target.attempts}</TableCell>
<TableCell>{target.errorMessage ?? "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)}
</>
);
}

View File

@@ -0,0 +1,229 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Badge,
Button,
Combobox,
Field,
makeStyles,
Option,
shorthands,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
Tooltip,
} from "@fluentui/react-components";
import { ArrowClockwiseRegular, ChevronDownRegular, ChevronRightRegular, OpenRegular } from "@fluentui/react-icons";
import { Fragment, useState } from "react";
import { Link } from "react-router-dom";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { PageHeader } from "../components/PageHeader";
const useStyles = makeStyles({
toolbar: {
display: "flex",
justifyContent: "space-between",
alignItems: "flex-end",
gap: "12px",
marginBottom: "18px",
},
filterRow: {
display: "flex",
gap: "8px",
alignItems: "center",
},
filter: {
minWidth: "220px",
},
actions: {
display: "flex",
gap: "8px",
...shorthands.padding("2px", "0"),
},
groupRow: {
cursor: "pointer",
},
groupHeader: {
display: "flex",
alignItems: "center",
gap: "8px",
},
nestedCell: {
paddingLeft: "28px",
},
});
export function DeploymentJobsPage() {
const styles = useStyles();
const queryClient = useQueryClient();
const [statusFilter, setStatusFilter] = useState("all");
const [typeFilter, setTypeFilter] = useState("all");
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
const { data, error, isLoading } = useQuery({
queryKey: ["deployment-jobs"],
queryFn: ({ signal }) => portalApi.getDeploymentJobs(signal),
refetchInterval: 5000,
});
const retryDeploymentJob = useMutation({
mutationFn: portalApi.retryDeploymentJob,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] });
},
});
const filteredJobs = (data ?? []).filter((job) => {
const statusMatches = statusFilter === "all" || job.status === statusFilter;
const typeMatches = typeFilter === "all" || job.type === typeFilter;
return statusMatches && typeMatches;
});
const groupedJobs = Object.entries(
filteredJobs.reduce<Record<string, typeof filteredJobs>>((acc, job) => {
const key = job.status || "Unknown";
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(job);
return acc;
}, {}),
)
.map(([status, items]) => ({
status,
items: [...items].sort((a, b) => a.type.localeCompare(b.type)),
}))
.sort((a, b) => a.status.localeCompare(b.status));
const availableStatuses = [...new Set((data ?? []).map((job) => job.status))].sort();
const availableTypes = [...new Set((data ?? []).map((job) => job.type))].sort();
const toggleGroup = (groupKey: string) => {
setExpandedGroups((current) => ({
...current,
[groupKey]: !current[groupKey],
}));
};
return (
<>
<PageHeader title="Worker Jobs" description="Hintergrundjobs aus der Worker Queue mit Status und Fortschritt." />
<div className={styles.toolbar}>
<div className={styles.filterRow}>
<Field className={styles.filter} label="Status Filter">
<Combobox
value={statusFilter === "all" ? "Alle Status" : statusFilter}
onOptionSelect={(_, optionData) => setStatusFilter(optionData.optionValue ?? "all")}
>
<Option text="Alle Status" value="all">
Alle Status
</Option>
{availableStatuses.map((status) => (
<Option key={status} text={status} value={status}>
{status}
</Option>
))}
</Combobox>
</Field>
<Field className={styles.filter} label="Type Filter">
<Combobox
value={typeFilter === "all" ? "Alle Typen" : typeFilter}
onOptionSelect={(_, optionData) => setTypeFilter(optionData.optionValue ?? "all")}
>
<Option text="Alle Typen" value="all">
Alle Typen
</Option>
{availableTypes.map((type) => (
<Option key={type} text={type} value={type}>
{type}
</Option>
))}
</Combobox>
</Field>
</div>
</div>
<DataState isLoading={isLoading} error={error ?? retryDeploymentJob.error} />
{data && (
<Table aria-label="Deployment jobs">
<TableHeader>
<TableRow>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Type</TableHeaderCell>
<TableHeaderCell>Progress</TableHeaderCell>
<TableHeaderCell>Attempts</TableHeaderCell>
<TableHeaderCell>Error</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{groupedJobs.map((group) => (
<Fragment key={group.status}>
<TableRow className={styles.groupRow} onClick={() => toggleGroup(group.status)}>
<TableCell colSpan={6}>
<div className={styles.groupHeader}>
{expandedGroups[group.status] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />}
<strong>
{group.status} ({group.items.length})
</strong>
</div>
</TableCell>
</TableRow>
{expandedGroups[group.status] !== false &&
group.items.map((job) => (
<TableRow key={job.id}>
<TableCell className={styles.nestedCell}>
<Badge
appearance="filled"
color={
job.status === "Succeeded"
? "success"
: job.status === "Failed"
? "danger"
: job.status === "Running"
? "warning"
: "informative"
}
>
{job.status}
</Badge>
</TableCell>
<TableCell>{job.type}</TableCell>
<TableCell>
{job.succeededTargetCount}/{job.targetCount}
</TableCell>
<TableCell>
{job.attempts}/{job.maxAttempts}
</TableCell>
<TableCell>{job.errorMessage ?? "-"}</TableCell>
<TableCell>
<div className={styles.actions}>
<Tooltip content="Details" relationship="label">
<Link to={`/worker-jobs/${job.id}`}>
<Button appearance="subtle" icon={<OpenRegular />} aria-label="Details" />
</Link>
</Tooltip>
<Tooltip content="Retry" relationship="label">
<Button
appearance="subtle"
icon={<ArrowClockwiseRegular />}
aria-label="Retry"
disabled={
(job.status !== "Failed" && job.status !== "Cancelled") || retryDeploymentJob.isPending
}
onClick={() => retryDeploymentJob.mutate(job.id)}
/>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</Fragment>
))}
</TableBody>
</Table>
)}
</>
);
}

View File

@@ -1,8 +1,10 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Button, Button,
Checkbox,
Combobox,
Field, Field,
Input, Option,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@@ -20,43 +22,83 @@ import { PageHeader } from "../components/PageHeader";
export function DeploymentsPage() { export function DeploymentsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [deploymentGroupId, setDeploymentGroupId] = useState(""); const [deploymentBatchId, setDeploymentBatchId] = useState("");
const [virtualMachineId, setVirtualMachineId] = useState(""); const [selectedVirtualMachineIds, setSelectedVirtualMachineIds] = useState<string[]>([]);
const [status, setStatus] = useState("New");
const [jsonData, setJsonData] = useState("{}"); const [jsonData, setJsonData] = useState("{}");
const { data, error, isLoading } = useQuery({ const { data, error, isLoading } = useQuery({
queryKey: ["deployments"], queryKey: ["deployments"],
queryFn: ({ signal }) => portalApi.getDeployments(signal), queryFn: ({ signal }) => portalApi.getDeployments(signal),
}); });
const addDeployment = useMutation({ const { data: deploymentBatches } = useQuery({
mutationFn: portalApi.addDeployment, queryKey: ["deployment-batches"],
queryFn: ({ signal }) => portalApi.getDeploymentBatches(signal),
});
const { data: virtualMachines } = useQuery({
queryKey: ["virtual-machines"],
queryFn: ({ signal }) => portalApi.getVirtualMachines(signal),
});
const { data: deploymentJobs, error: deploymentJobsError, isLoading: deploymentJobsLoading } = useQuery({
queryKey: ["deployment-jobs"],
queryFn: ({ signal }) => portalApi.getDeploymentJobs(signal),
refetchInterval: 5000,
});
const addDeploymentRequest = useMutation({
mutationFn: portalApi.addDeploymentRequest,
onSuccess: async () => { onSuccess: async () => {
setDeploymentGroupId(""); setDeploymentBatchId("");
setVirtualMachineId(""); setSelectedVirtualMachineIds([]);
setStatus("New");
setJsonData("{}"); setJsonData("{}");
await queryClient.invalidateQueries({ queryKey: ["deployments"] }); await queryClient.invalidateQueries({ queryKey: ["deployments"] });
await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] });
},
});
const retryDeploymentJob = useMutation({
mutationFn: portalApi.retryDeploymentJob,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] });
}, },
}); });
return ( return (
<> <>
<PageHeader title="Deployments" description="Aktuelle Bereitstellungen aus der Core API." /> <PageHeader title="Deployment Executions" description="Aktuelle Ausfuehrungen aus der Core API." />
<FormSection <FormSection
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
addDeployment.mutate({ deploymentGroupId, jsonData, status, virtualMachineId }); addDeploymentRequest.mutate({ deploymentBatchId, jsonData, virtualMachineIds: selectedVirtualMachineIds });
}} }}
> >
<FormGrid> <FormGrid>
<Field label="Deployment Group Id" required> <Field label="Deployment Batch" required validationMessage={addDeploymentRequest.error?.message}>
<Input value={deploymentGroupId} onChange={(_, data) => setDeploymentGroupId(data.value)} /> <Combobox
placeholder="Deployment Batch waehlen"
value={deploymentBatches?.find((batch) => batch.id === deploymentBatchId)?.id ?? ""}
onOptionSelect={(_, data) => setDeploymentBatchId(data.optionValue ?? "")}
>
{(deploymentBatches ?? []).map((batch) => (
<Option key={batch.id} text={batch.id} value={batch.id}>
{batch.id}
</Option>
))}
</Combobox>
</Field> </Field>
<Field label="Virtual Machine Id" required> <Field label="Targets (Virtual Machines)" required>
<Input value={virtualMachineId} onChange={(_, data) => setVirtualMachineId(data.value)} /> <div>
</Field> {(virtualMachines ?? []).map((virtualMachine) => (
<Field label="Status" required validationMessage={addDeployment.error?.message}> <Checkbox
<Input value={status} onChange={(_, data) => setStatus(data.value)} /> key={virtualMachine.id}
label={virtualMachine.name}
checked={selectedVirtualMachineIds.includes(virtualMachine.id)}
onChange={(_, data) => {
if (data.checked) {
setSelectedVirtualMachineIds((previous) => [...previous, virtualMachine.id]);
} else {
setSelectedVirtualMachineIds((previous) => previous.filter((id) => id !== virtualMachine.id));
}
}}
/>
))}
</div>
</Field> </Field>
<FormWide> <FormWide>
<Field label="JSON data" required> <Field label="JSON data" required>
@@ -67,26 +109,65 @@ export function DeploymentsPage() {
<FormActions> <FormActions>
<Button <Button
appearance="primary" appearance="primary"
disabled={!deploymentGroupId || !virtualMachineId || !status || addDeployment.isPending} disabled={!deploymentBatchId || selectedVirtualMachineIds.length === 0 || addDeploymentRequest.isPending}
type="submit" type="submit"
> >
Add deployment Start deployment
</Button> </Button>
</FormActions> </FormActions>
</FormSection> </FormSection>
<DataState isLoading={isLoading} error={error} /> <DataState isLoading={isLoading || deploymentJobsLoading} error={error ?? deploymentJobsError} />
{data && ( {deploymentJobs && (
<Table aria-label="Deployments"> <Table aria-label="Worker Jobs">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>Status</TableHeaderCell> <TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Deployment Id</TableHeaderCell> <TableHeaderCell>Type</TableHeaderCell>
<TableHeaderCell>Progress</TableHeaderCell>
<TableHeaderCell>Attempts</TableHeaderCell>
<TableHeaderCell>Error</TableHeaderCell>
<TableHeaderCell>Action</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{deploymentJobs.map((job) => (
<TableRow key={job.id}>
<TableCell>{job.status}</TableCell>
<TableCell>{job.type}</TableCell>
<TableCell>{job.succeededTargetCount}/{job.targetCount}</TableCell>
<TableCell>{job.attempts}/{job.maxAttempts}</TableCell>
<TableCell>{job.errorMessage ?? "-"}</TableCell>
<TableCell>
<Button
appearance="secondary"
size="small"
disabled={(job.status !== "Failed" && job.status !== "Cancelled") || retryDeploymentJob.isPending}
onClick={() => retryDeploymentJob.mutate(job.id)}
>
Retry
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{data && (
<Table aria-label="Deployment executions">
<TableHeader>
<TableRow>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Deployment Batch</TableHeaderCell>
<TableHeaderCell>Virtual Machine</TableHeaderCell>
<TableHeaderCell>Execution Id</TableHeaderCell>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.map((deployment) => ( {data.map((deployment) => (
<TableRow key={deployment.id}> <TableRow key={deployment.id}>
<TableCell>{deployment.status ?? "Unknown"}</TableCell> <TableCell>{deployment.status ?? "Unknown"}</TableCell>
<TableCell>{deployment.deploymentBatchId ?? "-"}</TableCell>
<TableCell>{deployment.virtualMachineId ?? "-"}</TableCell>
<TableCell>{deployment.id}</TableCell> <TableCell>{deployment.id}</TableCell>
</TableRow> </TableRow>
))} ))}

View File

@@ -2,6 +2,8 @@ import { useQuery } from "@tanstack/react-query";
import { import {
Badge, Badge,
Button, Button,
Field,
Input,
makeStyles, makeStyles,
MessageBar, MessageBar,
MessageBarBody, MessageBarBody,
@@ -15,8 +17,10 @@ import {
Text, Text,
Title3, Title3,
tokens, tokens,
Tooltip,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { ArrowLeftRegular } from "@fluentui/react-icons"; import { ArrowLeftRegular, OpenRegular } from "@fluentui/react-icons";
import { useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { portalApi } from "../api/portalApi"; import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState"; import { DataState } from "../components/DataState";
@@ -47,19 +51,72 @@ const useStyles = makeStyles({
overflowWrap: "anywhere", overflowWrap: "anywhere",
}, },
sectionTitle: { sectionTitle: {
marginTop: "20px",
marginBottom: "12px", marginBottom: "12px",
}, },
actions: {
display: "flex",
gap: "4px",
...shorthands.padding("2px", "0"),
},
}); });
export function DomainDetailsPage() { export function DomainDetailsPage() {
const styles = useStyles(); const styles = useStyles();
const { id } = useParams(); const { id } = useParams();
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState("name");
const [sortOrder, setSortOrder] = useState("asc");
const { data, error, isLoading } = useQuery({ const { data, error, isLoading } = useQuery({
enabled: Boolean(id), enabled: Boolean(id),
queryKey: ["domain", id, "environments"], queryKey: ["domain", id, "environments"],
queryFn: ({ signal }) => portalApi.getDomainEnvironments(id!, signal), queryFn: ({ signal }) => portalApi.getDomainEnvironments(id!, signal),
}); });
const {
data: virtualMachineData,
error: virtualMachinesError,
isLoading: virtualMachinesLoading,
} = useQuery({
enabled: Boolean(id),
queryKey: ["domain", id, "virtual-machines"],
queryFn: ({ signal }) => portalApi.getDomainVirtualMachines(id!, signal),
});
const links = data?.environmentDomains?.filter((link) => link.environment) ?? []; const links = data?.environmentDomains?.filter((link) => link.environment) ?? [];
const filteredVirtualMachines = useMemo(() => {
const items = [...(virtualMachineData?.virtualMachines ?? [])];
const searchValue = search.trim().toLowerCase();
const filteredItems = searchValue.length
? items.filter((virtualMachine) =>
[virtualMachine.name, virtualMachine.externalId, virtualMachine.id]
.filter(Boolean)
.some((value) => value!.toLowerCase().includes(searchValue)),
)
: items;
filteredItems.sort((a, b) => {
const aValue = ((sortBy === "externalId" ? a.externalId : sortBy === "id" ? a.id : a.name) ?? "").toLowerCase();
const bValue = ((sortBy === "externalId" ? b.externalId : sortBy === "id" ? b.id : b.name) ?? "").toLowerCase();
const comparison = aValue.localeCompare(bValue);
return sortOrder === "asc" ? comparison : -comparison;
});
return filteredItems;
}, [virtualMachineData?.virtualMachines, search, sortBy, sortOrder]);
const toggleSort = (column: "name" | "externalId" | "id") => {
if (sortBy === column) {
setSortOrder((previous) => (previous === "asc" ? "desc" : "asc"));
return;
}
setSortBy(column);
setSortOrder("asc");
};
const sortIndicator = (column: "name" | "externalId" | "id") => {
if (sortBy !== column) return "";
return sortOrder === "asc" ? " ↑" : " ↓";
};
return ( return (
<> <>
@@ -69,7 +126,7 @@ export function DomainDetailsPage() {
</Button> </Button>
</Link> </Link>
<PageHeader title={data?.name ?? "Domain"} description="Details und verknuepfte Environments." /> <PageHeader title={data?.name ?? "Domain"} description="Details und verknuepfte Environments." />
<DataState isLoading={isLoading} error={error} /> <DataState isLoading={isLoading || virtualMachinesLoading} error={error ?? virtualMachinesError} />
{data && ( {data && (
<> <>
<section className={styles.details} aria-label="Domain details"> <section className={styles.details} aria-label="Domain details">
@@ -125,13 +182,62 @@ export function DomainDetailsPage() {
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Link to={`/environments/${link.environment!.id}`}>Oeffnen</Link> <div className={styles.actions}>
<Tooltip content="Details" relationship="label">
<Link to={`/environments/${link.environment!.id}`}>
<Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} />
</Link>
</Tooltip>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
)} )}
<Title3 className={styles.sectionTitle}>Virtual Machines</Title3>
<div style={{ display: "grid", gap: "12px", gridTemplateColumns: "minmax(220px, 1fr)", marginBottom: "12px" }}>
<Field label="Search">
<Input
placeholder="Name, External Id oder Id"
value={search}
onChange={(_, inputData) => setSearch(inputData.value)}
/>
</Field>
</div>
{filteredVirtualMachines.length === 0 ? (
<MessageBar>
<MessageBarBody>Keine Virtual Machines fuer diese Domain gefunden.</MessageBarBody>
</MessageBar>
) : (
<Table aria-label="Domain virtual machines">
<TableHeader>
<TableRow>
<TableHeaderCell onClick={() => toggleSort("name")} style={{ cursor: "pointer" }}>
Name{sortIndicator("name")}
</TableHeaderCell>
<TableHeaderCell onClick={() => toggleSort("externalId")} style={{ cursor: "pointer" }}>
External Id{sortIndicator("externalId")}
</TableHeaderCell>
<TableHeaderCell onClick={() => toggleSort("id")} style={{ cursor: "pointer" }}>
Id{sortIndicator("id")}
</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredVirtualMachines.map((virtualMachine) => (
<TableRow key={virtualMachine.id}>
<TableCell>
<Link to={`/virtual-machines/${virtualMachine.id}`}>{virtualMachine.name}</Link>
</TableCell>
<TableCell>{virtualMachine.externalId ?? "-"}</TableCell>
<TableCell>{virtualMachine.id}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</> </>
)} )}
</> </>

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Combobox,
Dialog, Dialog,
DialogActions, DialogActions,
DialogBody, DialogBody,
@@ -10,6 +11,7 @@ import {
Field, Field,
Input, Input,
makeStyles, makeStyles,
Option,
shorthands, shorthands,
Table, Table,
TableBody, TableBody,
@@ -19,7 +21,7 @@ import {
TableRow, TableRow,
Tooltip, Tooltip,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons"; import { AddRegular, DeleteRegular, EditRegular, OpenRegular, LinkMultiple24Regular } from "@fluentui/react-icons";
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { portalApi } from "../api/portalApi"; import { portalApi } from "../api/portalApi";
@@ -31,6 +33,7 @@ const useStyles = makeStyles({
toolbar: { toolbar: {
display: "flex", display: "flex",
justifyContent: "flex-start", justifyContent: "flex-start",
gap: "10px",
marginBottom: "18px", marginBottom: "18px",
}, },
form: { form: {
@@ -54,10 +57,16 @@ export function DomainsPage() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [fqdn, setFqdn] = useState(""); const [fqdn, setFqdn] = useState("");
const [netBIOS, setNetBIOS] = useState(""); const [netBIOS, setNetBIOS] = useState("");
const [linkDomainId, setLinkDomainId] = useState("");
const [environmentId, setEnvironmentId] = useState("");
const { data, error, isLoading } = useQuery({ const { data, error, isLoading } = useQuery({
queryKey: ["domains"], queryKey: ["domains"],
queryFn: ({ signal }) => portalApi.getDomains(signal), queryFn: ({ signal }) => portalApi.getDomains(signal),
}); });
const { data: environments, error: environmentsError, isLoading: environmentsLoading } = useQuery({
queryKey: ["environments"],
queryFn: ({ signal }) => portalApi.getEnvironments(signal),
});
const closeDialog = () => { const closeDialog = () => {
setDialogMode(null); setDialogMode(null);
@@ -83,6 +92,11 @@ export function DomainsPage() {
setDialogMode("edit"); setDialogMode("edit");
}; };
const openLinkDialog = () => {
setLinkDomainId(data?.[0]?.id ?? "");
setEnvironmentId(environments?.[0]?.id ?? "");
};
const addDomain = useMutation({ const addDomain = useMutation({
mutationFn: portalApi.addDomain, mutationFn: portalApi.addDomain,
onSuccess: async () => { onSuccess: async () => {
@@ -106,6 +120,16 @@ export function DomainsPage() {
await queryClient.invalidateQueries({ queryKey: ["domains"] }); await queryClient.invalidateQueries({ queryKey: ["domains"] });
}, },
}); });
const linkDomainToEnvironment = useMutation({
mutationFn: ({ domainId, environmentId: targetEnvironmentId }: { domainId: string; environmentId: string }) =>
portalApi.linkDomainToEnvironment(domainId, targetEnvironmentId),
onSuccess: async () => {
setLinkDomainId("");
setEnvironmentId("");
await queryClient.invalidateQueries({ queryKey: ["domains"] });
await queryClient.invalidateQueries({ queryKey: ["environments"] });
},
});
const submitDomain = (event: React.FormEvent<HTMLFormElement>) => { const submitDomain = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@@ -129,6 +153,9 @@ export function DomainsPage() {
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}> <Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
Domain hinzufuegen Domain hinzufuegen
</Button> </Button>
<Button appearance="secondary" icon={<LinkMultiple24Regular />} onClick={openLinkDialog}>
Link to Environment
</Button>
</div> </div>
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}> <Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
@@ -159,8 +186,72 @@ export function DomainsPage() {
</form> </form>
</DialogSurface> </DialogSurface>
</Dialog> </Dialog>
<Dialog
open={Boolean(linkDomainId || environmentId)}
onOpenChange={(_, dialogData) => {
if (!dialogData.open) {
setLinkDomainId("");
setEnvironmentId("");
}
}}
>
<DialogSurface>
<DialogBody>
<DialogTitle>Link to Environment</DialogTitle>
<DialogContent className={styles.form}>
<Field label="Domain" required>
<Combobox
placeholder="Domain waehlen"
value={data?.find((domain) => domain.id === linkDomainId)?.name ?? ""}
onOptionSelect={(_, optionData) => setLinkDomainId(optionData.optionValue ?? "")}
>
{(data ?? []).map((domain) => (
<Option key={domain.id} text={domain.name} value={domain.id}>
{domain.name}
</Option>
))}
</Combobox>
</Field>
<Field label="Environment" required validationMessage={linkDomainToEnvironment.error?.message}>
<Combobox
disabled={environmentsLoading}
placeholder="Environment waehlen"
value={environments?.find((environment) => environment.id === environmentId)?.name ?? ""}
onOptionSelect={(_, data) => setEnvironmentId(data.optionValue ?? "")}
>
{(environments ?? []).map((environment) => (
<Option key={environment.id} text={environment.name} value={environment.id}>
{environment.name}
</Option>
))}
</Combobox>
</Field>
</DialogContent>
<DialogActions>
<Button
appearance="secondary"
onClick={() => {
setLinkDomainId("");
setEnvironmentId("");
}}
>
Abbrechen
</Button>
<Button
appearance="primary"
disabled={!linkDomainId || !environmentId || linkDomainToEnvironment.isPending}
onClick={() => {
linkDomainToEnvironment.mutate({ domainId: linkDomainId, environmentId });
}}
>
Link
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
<DataState isLoading={isLoading} error={error ?? deleteDomain.error} /> <DataState isLoading={isLoading || environmentsLoading} error={error ?? environmentsError ?? deleteDomain.error ?? linkDomainToEnvironment.error} />
{data && ( {data && (
<Table aria-label="Domains"> <Table aria-label="Domains">
<TableHeader> <TableHeader>

View File

@@ -15,8 +15,9 @@ import {
Text, Text,
Title3, Title3,
tokens, tokens,
Tooltip,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { ArrowLeftRegular } from "@fluentui/react-icons"; import { ArrowLeftRegular, OpenRegular } from "@fluentui/react-icons";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { portalApi } from "../api/portalApi"; import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState"; import { DataState } from "../components/DataState";
@@ -49,6 +50,11 @@ const useStyles = makeStyles({
sectionTitle: { sectionTitle: {
marginBottom: "12px", marginBottom: "12px",
}, },
actions: {
display: "flex",
gap: "4px",
...shorthands.padding("2px", "0"),
},
}); });
export function EnvironmentDetailsPage() { export function EnvironmentDetailsPage() {
@@ -85,6 +91,40 @@ export function EnvironmentDetailsPage() {
{data.id} {data.id}
</Text> </Text>
</div> </div>
<div className={styles.field}>
<Text size={200}>Environment Type</Text>
<Text className={styles.value} weight="semibold">
{data.environmentType}
</Text>
</div>
<div className={styles.field}>
<Text size={200}>Cloud Enabled</Text>
<Text className={styles.value} weight="semibold">
{data.environmentType === "OnPrem" ? "Nein" : "Ja"}
</Text>
</div>
<div className={styles.field}>
<Text size={200}>Provider Type</Text>
<Text className={styles.value} weight="semibold">
{data.environmentType === "OnPrem" ? data.providerType : ""}
</Text>
</div>
{data.environmentType !== "OnPrem" && (
<div className={styles.field}>
<Text size={200}>Tenant Id</Text>
<Text className={styles.value} weight="semibold">
{data.tenantId}
</Text>
</div>
)}
{data.environmentType === "AzureTenant" && (
<div className={styles.field}>
<Text size={200}>Subscription Id</Text>
<Text className={styles.value} weight="semibold">
{data.subscriptionId}
</Text>
</div>
)}
</section> </section>
<Title3 className={styles.sectionTitle}>Linked Domains</Title3> <Title3 className={styles.sectionTitle}>Linked Domains</Title3>
@@ -115,7 +155,13 @@ export function EnvironmentDetailsPage() {
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Link to={`/domains/${link.domain!.id}`}>Oeffnen</Link> <div className={styles.actions}>
<Tooltip content="Details" relationship="label">
<Link to={`/domains/${link.domain!.id}`}>
<Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} />
</Link>
</Tooltip>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Combobox,
Dialog, Dialog,
DialogActions, DialogActions,
DialogBody, DialogBody,
@@ -10,6 +11,8 @@ import {
Field, Field,
Input, Input,
makeStyles, makeStyles,
Option,
Textarea,
shorthands, shorthands,
Table, Table,
TableBody, TableBody,
@@ -19,7 +22,7 @@ import {
TableRow, TableRow,
Tooltip, Tooltip,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons"; import { AddRegular, DeleteRegular, EditRegular, OpenRegular, LinkMultiple24Regular } from "@fluentui/react-icons";
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { portalApi } from "../api/portalApi"; import { portalApi } from "../api/portalApi";
@@ -31,6 +34,7 @@ const useStyles = makeStyles({
toolbar: { toolbar: {
display: "flex", display: "flex",
justifyContent: "flex-start", justifyContent: "flex-start",
gap: "10px",
marginBottom: "18px", marginBottom: "18px",
}, },
form: { form: {
@@ -52,28 +56,58 @@ export function EnvironmentsPage() {
const [dialogMode, setDialogMode] = useState<DialogMode>(null); const [dialogMode, setDialogMode] = useState<DialogMode>(null);
const [selectedEnvironment, setSelectedEnvironment] = useState<EnvironmentItem | null>(null); const [selectedEnvironment, setSelectedEnvironment] = useState<EnvironmentItem | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [environmentType, setEnvironmentType] = useState("OnPrem");
const [providerType, setProviderType] = useState("");
const [tenantId, setTenantId] = useState("");
const [subscriptionId, setSubscriptionId] = useState("");
const [metadataJson, setMetadataJson] = useState("");
const [linkEnvironmentId, setLinkEnvironmentId] = useState("");
const [domainId, setDomainId] = useState("");
const { data, error, isLoading } = useQuery({ const { data, error, isLoading } = useQuery({
queryKey: ["environments"], queryKey: ["environments"],
queryFn: ({ signal }) => portalApi.getEnvironments(signal), queryFn: ({ signal }) => portalApi.getEnvironments(signal),
}); });
const { data: domains, error: domainsError, isLoading: domainsLoading } = useQuery({
queryKey: ["domains"],
queryFn: ({ signal }) => portalApi.getDomains(signal),
});
const closeDialog = () => { const closeDialog = () => {
setDialogMode(null); setDialogMode(null);
setSelectedEnvironment(null); setSelectedEnvironment(null);
setName(""); setName("");
setEnvironmentType("OnPrem");
setProviderType("");
setTenantId("");
setSubscriptionId("");
setMetadataJson("");
}; };
const openAddDialog = () => { const openAddDialog = () => {
setSelectedEnvironment(null); setSelectedEnvironment(null);
setName(""); setName("");
setEnvironmentType("OnPrem");
setProviderType("");
setTenantId("");
setSubscriptionId("");
setMetadataJson("");
setDialogMode("add"); setDialogMode("add");
}; };
const openEditDialog = (environment: EnvironmentItem) => { const openEditDialog = (environment: EnvironmentItem) => {
setSelectedEnvironment(environment); setSelectedEnvironment(environment);
setName(environment.name); setName(environment.name);
setEnvironmentType(environment.environmentType ?? "OnPrem");
setProviderType(environment.providerType ?? "");
setTenantId(environment.tenantId ?? "");
setSubscriptionId(environment.subscriptionId ?? "");
setMetadataJson(environment.metadataJson ?? "");
setDialogMode("edit"); setDialogMode("edit");
}; };
const openLinkDialog = () => {
setLinkEnvironmentId(data?.[0]?.id ?? "");
setDomainId(domains?.[0]?.id ?? "");
};
const addEnvironment = useMutation({ const addEnvironment = useMutation({
mutationFn: portalApi.addEnvironment, mutationFn: portalApi.addEnvironment,
@@ -84,7 +118,14 @@ export function EnvironmentsPage() {
}); });
const updateEnvironment = useMutation({ const updateEnvironment = useMutation({
mutationFn: ({ id, environment }: { id: string; environment: { name: string } }) => mutationFn: ({ id, environment }: { id: string; environment: {
name: string;
environmentType: string;
providerType?: string;
tenantId?: string;
subscriptionId?: string;
metadataJson?: string;
} }) =>
portalApi.updateEnvironment(id, environment), portalApi.updateEnvironment(id, environment),
onSuccess: async () => { onSuccess: async () => {
closeDialog(); closeDialog();
@@ -98,10 +139,27 @@ export function EnvironmentsPage() {
await queryClient.invalidateQueries({ queryKey: ["environments"] }); await queryClient.invalidateQueries({ queryKey: ["environments"] });
}, },
}); });
const linkDomainToEnvironment = useMutation({
mutationFn: ({ targetDomainId, environmentId }: { targetDomainId: string; environmentId: string }) =>
portalApi.linkDomainToEnvironment(targetDomainId, environmentId),
onSuccess: async () => {
setLinkEnvironmentId("");
setDomainId("");
await queryClient.invalidateQueries({ queryKey: ["domains"] });
await queryClient.invalidateQueries({ queryKey: ["environments"] });
},
});
const submitEnvironment = (event: React.FormEvent<HTMLFormElement>) => { const submitEnvironment = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
const environment = { name }; const environment = {
name,
environmentType,
providerType,
tenantId,
subscriptionId,
metadataJson,
};
if (dialogMode === "edit" && selectedEnvironment) { if (dialogMode === "edit" && selectedEnvironment) {
updateEnvironment.mutate({ id: selectedEnvironment.id, environment }); updateEnvironment.mutate({ id: selectedEnvironment.id, environment });
@@ -113,6 +171,9 @@ export function EnvironmentsPage() {
const formError = addEnvironment.error?.message ?? updateEnvironment.error?.message; const formError = addEnvironment.error?.message ?? updateEnvironment.error?.message;
const isSaving = addEnvironment.isPending || updateEnvironment.isPending; const isSaving = addEnvironment.isPending || updateEnvironment.isPending;
const isOnPrem = environmentType === "OnPrem";
const isAzureTenant = environmentType === "AzureTenant";
const isM365Tenant = environmentType === "M365Tenant";
return ( return (
<> <>
@@ -121,6 +182,9 @@ export function EnvironmentsPage() {
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}> <Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
Environment hinzufuegen Environment hinzufuegen
</Button> </Button>
<Button appearance="secondary" icon={<LinkMultiple24Regular />} onClick={openLinkDialog}>
Link to Domain
</Button>
</div> </div>
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}> <Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
@@ -134,6 +198,73 @@ export function EnvironmentsPage() {
<Field label="Name" required validationMessage={formError}> <Field label="Name" required validationMessage={formError}>
<Input value={name} onChange={(_, data) => setName(data.value)} /> <Input value={name} onChange={(_, data) => setName(data.value)} />
</Field> </Field>
<Field label="Environment Type" required>
<Combobox
value={environmentType}
onOptionSelect={(_, data) => {
const nextType = data.optionValue ?? "OnPrem";
setEnvironmentType(nextType);
if (nextType === "OnPrem") {
setTenantId("");
setSubscriptionId("");
setMetadataJson("");
} else if (nextType === "M365Tenant") {
setProviderType("");
setSubscriptionId("");
} else {
setProviderType("");
}
}}
>
<Option text="OnPrem" value="OnPrem">
OnPrem
</Option>
<Option text="AzureTenant" value="AzureTenant">
AzureTenant
</Option>
<Option text="M365Tenant" value="M365Tenant">
M365Tenant
</Option>
</Combobox>
</Field>
{isOnPrem && (
<Field label="Provider Type">
<Combobox
placeholder="Provider waehlen"
value={providerType}
onOptionSelect={(_, data) => setProviderType(data.optionValue ?? "")}
>
<Option text="Hyper-V" value="Hyper-V">
Hyper-V
</Option>
<Option text="VMware" value="VMware">
VMware
</Option>
</Combobox>
</Field>
)}
{!isOnPrem && (
<>
<Field label="Tenant Id">
<Input value={tenantId} onChange={(_, data) => setTenantId(data.value)} />
</Field>
{isAzureTenant && (
<Field label="Subscription Id">
<Input value={subscriptionId} onChange={(_, data) => setSubscriptionId(data.value)} />
</Field>
)}
{(isAzureTenant || isM365Tenant) && (
<Field label="Metadata JSON">
<Textarea
resize="vertical"
value={metadataJson}
onChange={(_, data) => setMetadataJson(data.value)}
/>
</Field>
)}
</>
)}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button appearance="secondary" onClick={closeDialog}> <Button appearance="secondary" onClick={closeDialog}>
@@ -147,13 +278,85 @@ export function EnvironmentsPage() {
</form> </form>
</DialogSurface> </DialogSurface>
</Dialog> </Dialog>
<Dialog
open={Boolean(linkEnvironmentId || domainId)}
onOpenChange={(_, dialogData) => {
if (!dialogData.open) {
setLinkEnvironmentId("");
setDomainId("");
}
}}
>
<DialogSurface>
<DialogBody>
<DialogTitle>Link to Domain</DialogTitle>
<DialogContent className={styles.form}>
<Field label="Environment" required>
<Combobox
placeholder="Environment waehlen"
value={data?.find((environment) => environment.id === linkEnvironmentId)?.name ?? ""}
onOptionSelect={(_, optionData) => setLinkEnvironmentId(optionData.optionValue ?? "")}
>
{(data ?? []).map((environment) => (
<Option key={environment.id} text={environment.name} value={environment.id}>
{environment.name}
</Option>
))}
</Combobox>
</Field>
<Field label="Domain" required validationMessage={linkDomainToEnvironment.error?.message}>
<Combobox
disabled={domainsLoading}
placeholder="Domain waehlen"
value={domains?.find((domain) => domain.id === domainId)?.name ?? ""}
onOptionSelect={(_, data) => setDomainId(data.optionValue ?? "")}
>
{(domains ?? []).map((domain) => (
<Option key={domain.id} text={domain.name} value={domain.id}>
{domain.name}
</Option>
))}
</Combobox>
</Field>
</DialogContent>
<DialogActions>
<Button
appearance="secondary"
onClick={() => {
setLinkEnvironmentId("");
setDomainId("");
}}
>
Abbrechen
</Button>
<Button
appearance="primary"
disabled={!linkEnvironmentId || !domainId || linkDomainToEnvironment.isPending}
onClick={() => {
linkDomainToEnvironment.mutate({
targetDomainId: domainId,
environmentId: linkEnvironmentId,
});
}}
>
Link
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
<DataState isLoading={isLoading} error={error ?? deleteEnvironment.error} /> <DataState isLoading={isLoading || domainsLoading} error={error ?? domainsError ?? deleteEnvironment.error ?? linkDomainToEnvironment.error} />
{data && ( {data && (
<Table aria-label="Environments"> <Table aria-label="Environments">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>Name</TableHeaderCell> <TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Type</TableHeaderCell>
<TableHeaderCell>Cloud</TableHeaderCell>
<TableHeaderCell>Provider</TableHeaderCell>
<TableHeaderCell>Tenant</TableHeaderCell>
<TableHeaderCell>Subscription</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell> <TableHeaderCell>Id</TableHeaderCell>
<TableHeaderCell>Aktionen</TableHeaderCell> <TableHeaderCell>Aktionen</TableHeaderCell>
</TableRow> </TableRow>
@@ -162,6 +365,11 @@ export function EnvironmentsPage() {
{data.map((environment) => ( {data.map((environment) => (
<TableRow key={environment.id}> <TableRow key={environment.id}>
<TableCell>{environment.name}</TableCell> <TableCell>{environment.name}</TableCell>
<TableCell>{environment.environmentType}</TableCell>
<TableCell>{environment.environmentType === "OnPrem" ? "Nein" : "Ja"}</TableCell>
<TableCell>{environment.environmentType === "OnPrem" ? environment.providerType : ""}</TableCell>
<TableCell>{environment.environmentType !== "OnPrem" ? environment.tenantId : ""}</TableCell>
<TableCell>{environment.environmentType === "AzureTenant" ? environment.subscriptionId : ""}</TableCell>
<TableCell>{environment.id}</TableCell> <TableCell>{environment.id}</TableCell>
<TableCell> <TableCell>
<div className={styles.actions}> <div className={styles.actions}>

View File

@@ -1,6 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Checkbox,
Combobox,
Dialog, Dialog,
DialogActions, DialogActions,
DialogBody, DialogBody,
@@ -10,6 +12,7 @@ import {
Field, Field,
Input, Input,
makeStyles, makeStyles,
Option,
shorthands, shorthands,
Table, Table,
TableBody, TableBody,
@@ -20,23 +23,51 @@ import {
Textarea, Textarea,
Tooltip, Tooltip,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons"; import {
import { useState } from "react"; AddRegular,
ChevronDownRegular,
ChevronRightRegular,
CloudRegular,
DataPieRegular,
DeleteRegular,
DocumentRegular,
EditRegular,
OpenRegular,
PeopleRegular,
ShareRegular,
ShieldRegular,
} from "@fluentui/react-icons";
import { Fragment, useState } from "react";
import { portalApi } from "../api/portalApi"; import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState"; import { DataState } from "../components/DataState";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
import type { ServiceItem } from "../types/portal"; import type { AddServiceRoleDefinition, ServiceItem, ServiceRoleDefinition } from "../types/portal";
const useStyles = makeStyles({ const useStyles = makeStyles({
toolbar: { toolbar: {
display: "flex", display: "flex",
justifyContent: "flex-start", justifyContent: "space-between",
alignItems: "flex-end",
gap: "12px",
marginBottom: "18px", marginBottom: "18px",
}, },
toolbarActions: {
display: "flex",
gap: "8px",
alignItems: "center",
},
filter: {
minWidth: "280px",
},
form: { form: {
display: "grid", display: "grid",
gap: "14px", gap: "14px",
}, },
iconOption: {
display: "flex",
alignItems: "center",
gap: "8px",
},
actions: { actions: {
display: "flex", display: "flex",
gap: "4px", gap: "4px",
@@ -45,10 +76,40 @@ const useStyles = makeStyles({
value: { value: {
overflowWrap: "anywhere", overflowWrap: "anywhere",
}, },
nameWithIcon: {
display: "flex",
alignItems: "center",
gap: "8px",
},
groupRow: {
cursor: "pointer",
},
groupHeader: {
display: "flex",
alignItems: "center",
gap: "8px",
},
nestedCell: {
paddingLeft: "28px",
},
}); });
type DialogMode = "add" | "edit" | "details" | null; type DialogMode = "add" | "edit" | "details" | null;
const serviceIconOptions = [
{ key: "", label: "Kein Icon", icon: <DocumentRegular /> },
{ key: "SharePoint", label: "SharePoint", icon: <ShareRegular /> },
{ key: "Teams", label: "Teams", icon: <PeopleRegular /> },
{ key: "Exchange", label: "Exchange", icon: <DocumentRegular /> },
{ key: "Azure", label: "Azure", icon: <CloudRegular /> },
{ key: "SQL", label: "SQL", icon: <DataPieRegular /> },
{ key: "Security", label: "Security", icon: <ShieldRegular /> },
];
function getServiceIcon(iconKey?: string) {
return serviceIconOptions.find((entry) => entry.key === (iconKey ?? ""))?.icon ?? <DocumentRegular />;
}
export function ServicesPage() { export function ServicesPage() {
const styles = useStyles(); const styles = useStyles();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -56,21 +117,45 @@ export function ServicesPage() {
const [selectedService, setSelectedService] = useState<ServiceItem | null>(null); const [selectedService, setSelectedService] = useState<ServiceItem | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [isCloudService, setIsCloudService] = useState(false);
const [iconKey, setIconKey] = useState("");
const [roleDialogOpen, setRoleDialogOpen] = useState(false);
const [selectedRoleDefinition, setSelectedRoleDefinition] = useState<ServiceRoleDefinition | null>(null);
const [roleKey, setRoleKey] = useState("");
const [roleName, setRoleName] = useState("");
const [roleDescription, setRoleDescription] = useState("");
const [serviceFilter, setServiceFilter] = useState<"all" | "cloud" | "onprem">("all");
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({
onprem: true,
cloud: true,
});
const { data, error, isLoading } = useQuery({ const { data, error, isLoading } = useQuery({
queryKey: ["services"], queryKey: ["services"],
queryFn: ({ signal }) => portalApi.getServices(signal), queryFn: ({ signal }) => portalApi.getServices(signal),
}); });
const { data: roleDefinitions, error: roleDefinitionsError, isLoading: roleDefinitionsLoading } = useQuery({
enabled: Boolean(selectedService?.id) && !isCloudService,
queryKey: ["service-role-definitions", selectedService?.id],
queryFn: ({ signal }) => portalApi.getServiceRoleDefinitions(selectedService!.id, signal),
});
const closeDialog = () => { const closeDialog = () => {
setDialogMode(null); setDialogMode(null);
setSelectedService(null); setSelectedService(null);
setName(""); setName("");
setDescription(""); setDescription("");
setIsCloudService(false);
setIconKey("");
setRoleDialogOpen(false);
setSelectedRoleDefinition(null);
}; };
const openAddDialog = () => { const openAddDialog = () => {
setName(""); setName("");
setDescription(""); setDescription("");
setIsCloudService(false);
setIconKey("");
setSelectedService(null); setSelectedService(null);
setDialogMode("add"); setDialogMode("add");
}; };
@@ -79,6 +164,8 @@ export function ServicesPage() {
setSelectedService(service); setSelectedService(service);
setName(service.name); setName(service.name);
setDescription(service.description ?? ""); setDescription(service.description ?? "");
setIsCloudService(Boolean(service.isCloudService));
setIconKey(service.iconKey ?? "");
setDialogMode(mode); setDialogMode(mode);
}; };
@@ -91,7 +178,7 @@ export function ServicesPage() {
}); });
const updateService = useMutation({ const updateService = useMutation({
mutationFn: ({ id, service }: { id: string; service: { name: string; description: string } }) => mutationFn: ({ id, service }: { id: string; service: { name: string; description: string; isCloudService: boolean; iconKey?: string } }) =>
portalApi.updateService(id, service), portalApi.updateService(id, service),
onSuccess: async () => { onSuccess: async () => {
closeDialog(); closeDialog();
@@ -106,9 +193,37 @@ export function ServicesPage() {
}, },
}); });
const addRoleDefinition = useMutation({
mutationFn: ({ serviceId, roleDefinition }: { serviceId: string; roleDefinition: AddServiceRoleDefinition }) =>
portalApi.addServiceRoleDefinition(serviceId, roleDefinition),
onSuccess: async () => {
setRoleDialogOpen(false);
setSelectedRoleDefinition(null);
await queryClient.invalidateQueries({ queryKey: ["service-role-definitions", selectedService?.id] });
},
});
const updateRoleDefinition = useMutation({
mutationFn: ({ serviceId, roleDefinitionId, roleDefinition }: { serviceId: string; roleDefinitionId: string; roleDefinition: AddServiceRoleDefinition }) =>
portalApi.updateServiceRoleDefinition(serviceId, roleDefinitionId, roleDefinition),
onSuccess: async () => {
setRoleDialogOpen(false);
setSelectedRoleDefinition(null);
await queryClient.invalidateQueries({ queryKey: ["service-role-definitions", selectedService?.id] });
},
});
const deleteRoleDefinition = useMutation({
mutationFn: ({ serviceId, roleDefinitionId }: { serviceId: string; roleDefinitionId: string }) =>
portalApi.deleteServiceRoleDefinition(serviceId, roleDefinitionId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["service-role-definitions", selectedService?.id] });
},
});
const submitService = (event: React.FormEvent<HTMLFormElement>) => { const submitService = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
const service = { description, name }; const service = { description, name, isCloudService, iconKey: iconKey || undefined };
if (dialogMode === "edit" && selectedService) { if (dialogMode === "edit" && selectedService) {
updateService.mutate({ id: selectedService.id, service }); updateService.mutate({ id: selectedService.id, service });
@@ -119,17 +234,106 @@ export function ServicesPage() {
}; };
const formError = addService.error?.message ?? updateService.error?.message; const formError = addService.error?.message ?? updateService.error?.message;
const roleError = addRoleDefinition.error?.message ?? updateRoleDefinition.error?.message ?? deleteRoleDefinition.error?.message;
const isSaving = addService.isPending || updateService.isPending; const isSaving = addService.isPending || updateService.isPending;
const isDetails = dialogMode === "details"; const isDetails = dialogMode === "details";
const openAddRoleDialog = () => {
setSelectedRoleDefinition(null);
setRoleKey("");
setRoleName("");
setRoleDescription("");
setRoleDialogOpen(true);
};
const openEditRoleDialog = (roleDefinition: ServiceRoleDefinition) => {
setSelectedRoleDefinition(roleDefinition);
setRoleKey(roleDefinition.key);
setRoleName(roleDefinition.name);
setRoleDescription(roleDefinition.description ?? "");
setRoleDialogOpen(true);
};
const submitRoleDefinition = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedService) return;
const roleDefinition: AddServiceRoleDefinition = {
key: roleKey,
name: roleName,
description: roleDescription,
};
if (selectedRoleDefinition) {
updateRoleDefinition.mutate({
serviceId: selectedService.id,
roleDefinitionId: selectedRoleDefinition.id,
roleDefinition,
});
return;
}
addRoleDefinition.mutate({ serviceId: selectedService.id, roleDefinition });
};
const filteredServices =
serviceFilter === "all"
? (data ?? [])
: (data ?? []).filter((service) =>
serviceFilter === "cloud" ? Boolean(service.isCloudService) : !service.isCloudService,
);
const groupedServices = [
{
key: "onprem",
label: "On-Prem Services",
items: filteredServices.filter((service) => !service.isCloudService),
},
{
key: "cloud",
label: "Cloud Services",
items: filteredServices.filter((service) => Boolean(service.isCloudService)),
},
].filter((group) => group.items.length > 0);
const toggleGroup = (groupKey: string) => {
setExpandedGroups((current) => ({
...current,
[groupKey]: !current[groupKey],
}));
};
return ( return (
<> <>
<PageHeader title="Services" description="Service-Katalog aus der Core API." /> <PageHeader title="Services" description="Service-Katalog aus der Core API." />
<div className={styles.toolbar}> <div className={styles.toolbar}>
<div className={styles.toolbarActions}>
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}> <Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
Service hinzufuegen Service hinzufuegen
</Button> </Button>
</div> </div>
<Field className={styles.filter} label="Service Filter">
<Combobox
value={
serviceFilter === "all"
? "Alle Services"
: serviceFilter === "cloud"
? "Cloud Services"
: "On-Prem Services"
}
onOptionSelect={(_, data) => setServiceFilter((data.optionValue as "all" | "cloud" | "onprem") ?? "all")}
>
<Option text="Alle Services" value="all">
Alle Services
</Option>
<Option text="On-Prem Services" value="onprem">
On-Prem Services
</Option>
<Option text="Cloud Services" value="cloud">
Cloud Services
</Option>
</Combobox>
</Field>
</div>
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}> <Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
<DialogSurface> <DialogSurface>
@@ -154,6 +358,92 @@ export function ServicesPage() {
onChange={(_, data) => setDescription(data.value)} onChange={(_, data) => setDescription(data.value)}
/> />
</Field> </Field>
<Field label="Cloud Service">
<Checkbox
checked={isCloudService}
disabled={isDetails}
label={isCloudService ? "Ja" : "Nein"}
onChange={(_, data) => setIsCloudService(Boolean(data.checked))}
/>
</Field>
<Field label="Produkt Icon">
<Combobox
disabled={isDetails}
value={iconKey || "Kein Icon"}
onOptionSelect={(_, data) => setIconKey(data.optionValue ?? "")}
>
{serviceIconOptions.map((entry) => (
<Option key={entry.key || "none"} text={entry.label} value={entry.key}>
<div className={styles.iconOption}>
{entry.icon}
<span>{entry.label}</span>
</div>
</Option>
))}
</Combobox>
</Field>
{!isCloudService && (
<Field label="Roles">
{selectedService ? (
<>
<Button appearance="secondary" icon={<AddRegular />} onClick={openAddRoleDialog} disabled={isDetails}>
Role Definition hinzufuegen
</Button>
<Table aria-label="Service role definitions">
<TableHeader>
<TableRow>
<TableHeaderCell>Key</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Description</TableHeaderCell>
<TableHeaderCell>Aktionen</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{(roleDefinitions ?? []).map((roleDefinition) => (
<TableRow key={roleDefinition.id}>
<TableCell>{roleDefinition.key}</TableCell>
<TableCell>{roleDefinition.name}</TableCell>
<TableCell>{roleDefinition.description}</TableCell>
<TableCell>
<div className={styles.actions}>
<Tooltip content="Aendern" relationship="label">
<Button
appearance="subtle"
aria-label="Aendern"
icon={<EditRegular />}
disabled={isDetails}
onClick={() => openEditRoleDialog(roleDefinition)}
/>
</Tooltip>
<Tooltip content="Loeschen" relationship="label">
<Button
appearance="subtle"
aria-label="Loeschen"
icon={<DeleteRegular />}
disabled={isDetails || deleteRoleDefinition.isPending}
onClick={() => {
if (window.confirm(`Role "${roleDefinition.name}" wirklich loeschen?`)) {
deleteRoleDefinition.mutate({
serviceId: selectedService.id,
roleDefinitionId: roleDefinition.id,
});
}
}}
/>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<DataState isLoading={roleDefinitionsLoading} error={roleDefinitionsError ?? (roleError ? new Error(roleError) : undefined)} />
</>
) : (
<div className={styles.value}>Service zuerst speichern, dann Rollen konfigurieren.</div>
)}
</Field>
)}
{selectedService && ( {selectedService && (
<Field label="Id"> <Field label="Id">
<div className={styles.value}>{selectedService.id}</div> <div className={styles.value}>{selectedService.id}</div>
@@ -175,6 +465,39 @@ export function ServicesPage() {
</DialogSurface> </DialogSurface>
</Dialog> </Dialog>
<Dialog open={roleDialogOpen} onOpenChange={(_, data) => !data.open && setRoleDialogOpen(false)}>
<DialogSurface>
<form onSubmit={submitRoleDefinition}>
<DialogBody>
<DialogTitle>{selectedRoleDefinition ? "Role Definition aendern" : "Role Definition hinzufuegen"}</DialogTitle>
<DialogContent className={styles.form}>
<Field label="Key" required validationMessage={roleError}>
<Input value={roleKey} onChange={(_, data) => setRoleKey(data.value)} />
</Field>
<Field label="Name" required>
<Input value={roleName} onChange={(_, data) => setRoleName(data.value)} />
</Field>
<Field label="Description">
<Textarea resize="vertical" value={roleDescription} onChange={(_, data) => setRoleDescription(data.value)} />
</Field>
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={() => setRoleDialogOpen(false)}>
Abbrechen
</Button>
<Button
appearance="primary"
type="submit"
disabled={!roleKey || !roleName || addRoleDefinition.isPending || updateRoleDefinition.isPending}
>
Speichern
</Button>
</DialogActions>
</DialogBody>
</form>
</DialogSurface>
</Dialog>
<DataState isLoading={isLoading} error={error ?? deleteService.error} /> <DataState isLoading={isLoading} error={error ?? deleteService.error} />
{data && ( {data && (
<Table aria-label="Services"> <Table aria-label="Services">
@@ -187,9 +510,24 @@ export function ServicesPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.map((service) => ( {groupedServices.map((group) => (
<Fragment key={group.key}>
<TableRow className={styles.groupRow} onClick={() => toggleGroup(group.key)}>
<TableCell colSpan={4}>
<div className={styles.groupHeader}>
{expandedGroups[group.key] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />}
<strong>{group.label}</strong>
</div>
</TableCell>
</TableRow>
{expandedGroups[group.key] !== false && group.items.map((service) => (
<TableRow key={service.id}> <TableRow key={service.id}>
<TableCell>{service.name}</TableCell> <TableCell className={styles.nestedCell}>
<div className={styles.nameWithIcon}>
{getServiceIcon(service.iconKey)}
<span>{service.name}</span>
</div>
</TableCell>
<TableCell>{service.description}</TableCell> <TableCell>{service.description}</TableCell>
<TableCell>{service.id}</TableCell> <TableCell>{service.id}</TableCell>
<TableCell> <TableCell>
@@ -227,6 +565,8 @@ export function ServicesPage() {
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</Fragment>
))}
</TableBody> </TableBody>
</Table> </Table>
)} )}

View File

@@ -0,0 +1,392 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Checkbox,
Combobox,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field,
Input,
makeStyles,
Option,
shorthands,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
Textarea,
Tooltip,
} from "@fluentui/react-components";
import {
AddRegular,
ChevronDownRegular,
ChevronRightRegular,
DeleteRegular,
EditRegular,
OpenRegular,
} 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, 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))",
},
colorRow: {
display: "flex",
alignItems: "center",
gap: "10px",
},
colorPreview: {
width: "24px",
height: "24px",
borderRadius: "4px",
border: "1px solid #d0d0d0",
},
actions: {
display: "flex",
gap: "4px",
...shorthands.padding("2px", "0"),
},
value: {
overflowWrap: "anywhere",
},
groupRow: {
cursor: "pointer",
},
groupHeader: {
display: "flex",
alignItems: "center",
gap: "8px",
},
nestedCell: {
paddingLeft: "28px",
},
});
type DialogMode = "add" | "edit" | "details" | null;
export function TemplateCategoriesPage() {
const styles = useStyles();
const queryClient = useQueryClient();
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
const [selectedTemplateCategory, setSelectedTemplateCategory] = useState<TemplateCategory | null>(null);
const [serviceId, setServiceId] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isActive, setIsActive] = useState(true);
const [color, setColor] = useState("");
const [serviceFilterId, setServiceFilterId] = useState("all");
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
const { data, error, isLoading } = 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);
setSelectedTemplateCategory(null);
setServiceId("");
setName("");
setDescription("");
setIsActive(true);
setColor("");
};
const openAddDialog = () => {
setSelectedTemplateCategory(null);
setServiceId(services?.[0]?.id ?? "");
setName("");
setDescription("");
setIsActive(true);
setColor("");
setDialogMode("add");
};
const openTemplateCategoryDialog = (mode: "edit" | "details", templateCategory: TemplateCategory) => {
setSelectedTemplateCategory(templateCategory);
setServiceId(templateCategory.serviceId);
setName(templateCategory.name);
setDescription(templateCategory.description ?? "");
setIsActive(templateCategory.isActive);
setColor(templateCategory.color ?? "");
setDialogMode(mode);
};
const addTemplateCategory = useMutation({
mutationFn: portalApi.addTemplateCategory,
onSuccess: async () => {
closeDialog();
await queryClient.invalidateQueries({ queryKey: ["template-categories"] });
},
});
const updateTemplateCategory = useMutation({
mutationFn: ({
id,
templateCategory,
}: {
id: string;
templateCategory: { serviceId: string; name: string; description?: string; isActive: boolean; color?: string };
}) => portalApi.updateTemplateCategory(id, templateCategory),
onSuccess: async () => {
closeDialog();
await queryClient.invalidateQueries({ queryKey: ["template-categories"] });
},
});
const deleteTemplateCategory = useMutation({
mutationFn: portalApi.deleteTemplateCategory,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["template-categories"] });
},
});
const submitTemplateCategory = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const templateCategory = {
serviceId,
name,
description,
isActive,
color: color || undefined,
};
if (dialogMode === "edit" && selectedTemplateCategory) {
updateTemplateCategory.mutate({ id: selectedTemplateCategory.id, templateCategory });
return;
}
addTemplateCategory.mutate(templateCategory);
};
const formError = addTemplateCategory.error?.message ?? updateTemplateCategory.error?.message;
const isSaving = addTemplateCategory.isPending || updateTemplateCategory.isPending;
const isDetails = dialogMode === "details";
const serviceNameById = new Map((services ?? []).map((service: ServiceItem) => [service.id, service.name]));
const filteredTemplateCategories =
serviceFilterId === "all"
? (data ?? [])
: (data ?? []).filter((templateCategory) => templateCategory.serviceId === serviceFilterId);
const groupedTemplateCategories = (services ?? [])
.map((service) => ({
serviceId: service.id,
serviceName: service.name,
items: filteredTemplateCategories
.filter((templateCategory) => templateCategory.serviceId === service.id)
.sort((a, b) => a.name.localeCompare(b.name)),
}))
.filter((group) => group.items.length > 0);
const toggleGroup = (groupId: string) => {
setExpandedGroups((current) => ({
...current,
[groupId]: !current[groupId],
}));
};
return (
<>
<PageHeader title="Template Categories" description="Kategorien fuer Templates verwalten." />
<div className={styles.toolbar}>
<div className={styles.toolbarActions}>
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
Template Category 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={(_, dialogData) => !dialogData.open && closeDialog()}>
<DialogSurface>
<form onSubmit={submitTemplateCategory}>
<DialogBody>
<DialogTitle>
{dialogMode === "edit"
? "Template Category aendern"
: dialogMode === "details"
? "Template Category Details"
: "Template Category hinzufuegen"}
</DialogTitle>
<DialogContent className={styles.form}>
<div className={styles.grid}>
<Field label="Service" required validationMessage={formError}>
<Combobox
disabled={isDetails || servicesLoading}
placeholder="Service waehlen"
value={serviceNameById.get(serviceId) ?? ""}
onOptionSelect={(_, selectData) => setServiceId(selectData.optionValue ?? "")}
>
{(services ?? []).map((service) => (
<Option key={service.id} text={service.name} value={service.id}>
{service.name}
</Option>
))}
</Combobox>
</Field>
<Field label="Name" required>
<Input disabled={isDetails} value={name} onChange={(_, inputData) => setName(inputData.value)} />
</Field>
<Field label="Description">
<Textarea disabled={isDetails} resize="vertical" value={description} onChange={(_, d) => setDescription(d.value)} />
</Field>
<Field label="Color">
<div className={styles.colorRow}>
<input
disabled={isDetails}
type="color"
value={color || "#4f6bed"}
onChange={(event) => setColor(event.target.value)}
/>
<div className={styles.colorPreview} style={{ backgroundColor: color || "#4f6bed" }} />
</div>
</Field>
<Field label="Active">
<Checkbox checked={isActive} disabled={isDetails} onChange={(_, d) => setIsActive(Boolean(d.checked))} />
</Field>
</div>
{selectedTemplateCategory && (
<Field label="Id">
<div className={styles.value}>{selectedTemplateCategory.id}</div>
</Field>
)}
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={closeDialog}>
{isDetails ? "Schliessen" : "Abbrechen"}
</Button>
{!isDetails && (
<Button appearance="primary" disabled={!serviceId || !name || isSaving} type="submit">
Speichern
</Button>
)}
</DialogActions>
</DialogBody>
</form>
</DialogSurface>
</Dialog>
<DataState isLoading={isLoading || servicesLoading} error={error ?? servicesError ?? deleteTemplateCategory.error} />
{data && (
<Table aria-label="Template Categories">
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Active</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
<TableHeaderCell>Aktionen</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{groupedTemplateCategories.map((group) => (
<Fragment key={`group-${group.serviceId}`}>
<TableRow
className={styles.groupRow}
key={`group-${group.serviceId}`}
onClick={() => toggleGroup(group.serviceId)}
>
<TableCell colSpan={4}>
<div className={styles.groupHeader}>
{expandedGroups[group.serviceId] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />}
<strong>{group.serviceName}</strong>
</div>
</TableCell>
</TableRow>
{expandedGroups[group.serviceId] !== false && group.items.map((templateCategory) => (
<TableRow key={templateCategory.id}>
<TableCell className={styles.nestedCell}>{templateCategory.name}</TableCell>
<TableCell>{templateCategory.isActive ? "Ja" : "Nein"}</TableCell>
<TableCell>{templateCategory.id}</TableCell>
<TableCell>
<div className={styles.actions}>
<Tooltip content="Details" relationship="label">
<Button
appearance="subtle"
aria-label="Details"
icon={<OpenRegular />}
onClick={() => openTemplateCategoryDialog("details", templateCategory)}
/>
</Tooltip>
<Tooltip content="Aendern" relationship="label">
<Button
appearance="subtle"
aria-label="Aendern"
icon={<EditRegular />}
onClick={() => openTemplateCategoryDialog("edit", templateCategory)}
/>
</Tooltip>
<Tooltip content="Loeschen" relationship="label">
<Button
appearance="subtle"
aria-label="Loeschen"
disabled={deleteTemplateCategory.isPending}
icon={<DeleteRegular />}
onClick={() => {
if (window.confirm(`Template Category "${templateCategory.name}" wirklich loeschen?`)) {
deleteTemplateCategory.mutate(templateCategory.id);
}
}}
/>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</Fragment>
))}
</TableBody>
</Table>
)}
</>
);
}

View File

@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Checkbox, Combobox,
Dialog, Dialog,
DialogActions, DialogActions,
DialogBody, DialogBody,
@@ -11,6 +11,9 @@ import {
Field, Field,
Input, Input,
makeStyles, makeStyles,
Option,
Tab,
TabList,
shorthands, shorthands,
Table, Table,
TableBody, TableBody,
@@ -22,18 +25,29 @@ import {
Tooltip, Tooltip,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons"; 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 { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState"; import { DataState } from "../components/DataState";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
import type { Template } from "../types/portal"; import type { ServiceItem, Template, TemplateCategory } from "../types/portal";
const useStyles = makeStyles({ const useStyles = makeStyles({
toolbar: { toolbar: {
display: "flex", display: "flex",
justifyContent: "flex-start", justifyContent: "space-between",
alignItems: "flex-end",
gap: "12px",
marginBottom: "18px", marginBottom: "18px",
}, },
toolbarActions: {
display: "flex",
gap: "8px",
alignItems: "center",
},
filter: {
minWidth: "280px",
},
form: { form: {
display: "grid", display: "grid",
gap: "14px", gap: "14px",
@@ -54,6 +68,25 @@ const useStyles = makeStyles({
value: { value: {
overflowWrap: "anywhere", 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; type DialogMode = "add" | "edit" | "details" | null;
@@ -62,6 +95,57 @@ function getTemplateJsonData(template: Template) {
return template.jsonData ?? template.jSONData ?? ""; 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() { export function TemplatesPage() {
const styles = useStyles(); const styles = useStyles();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -69,24 +153,45 @@ export function TemplatesPage() {
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null); const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const [templateCategoryId, setTemplateCategoryId] = useState(""); const [templateCategoryId, setTemplateCategoryId] = useState("");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [cloudTemplate, setCloudTemplate] = useState(false);
const [version, setVersion] = useState(""); const [version, setVersion] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [jsonData, setJsonData] = 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({ const { data, error, isLoading } = useQuery({
queryKey: ["templates"], queryKey: ["templates"],
queryFn: ({ signal }) => portalApi.getTemplates(signal), 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 = () => { const closeDialog = () => {
setDialogMode(null); setDialogMode(null);
setSelectedTemplate(null); setSelectedTemplate(null);
setTemplateCategoryId(""); setTemplateCategoryId("");
setName(""); setName("");
setCloudTemplate(false);
setVersion(""); setVersion("");
setDescription(""); setDescription("");
setJsonData(""); setJsonData("");
setJsonParameters("{}");
setJsonVariables("{}");
setJsonResources("[]");
setEditorTab("parameters");
}; };
const openAddDialog = () => { const openAddDialog = () => {
@@ -98,10 +203,21 @@ export function TemplatesPage() {
setSelectedTemplate(template); setSelectedTemplate(template);
setTemplateCategoryId(template.templateCategoryId ?? ""); setTemplateCategoryId(template.templateCategoryId ?? "");
setName(template.name); setName(template.name);
setCloudTemplate(Boolean(template.cloudTemplate));
setVersion(template.version ?? ""); setVersion(template.version ?? "");
setDescription(template.description ?? ""); 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); setDialogMode(mode);
}; };
@@ -122,7 +238,6 @@ export function TemplatesPage() {
template: { template: {
templateCategoryId: string; templateCategoryId: string;
name: string; name: string;
cloudTemplate: boolean;
version: string; version: string;
description: string; description: string;
jsonData: string; jsonData: string;
@@ -143,7 +258,29 @@ export function TemplatesPage() {
const submitTemplate = (event: React.FormEvent<HTMLFormElement>) => { const submitTemplate = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); 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) { if (dialogMode === "edit" && selectedTemplate) {
updateTemplate.mutate({ id: selectedTemplate.id, template }); updateTemplate.mutate({ id: selectedTemplate.id, template });
@@ -156,15 +293,81 @@ export function TemplatesPage() {
const formError = addTemplate.error?.message ?? updateTemplate.error?.message; const formError = addTemplate.error?.message ?? updateTemplate.error?.message;
const isSaving = addTemplate.isPending || updateTemplate.isPending; const isSaving = addTemplate.isPending || updateTemplate.isPending;
const isDetails = dialogMode === "details"; 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 ( return (
<> <>
<PageHeader title="Templates" description="Vorlagen fuer Portal-Bereitstellungen." /> <PageHeader title="Templates" description="Vorlagen fuer Portal-Bereitstellungen." />
<div className={styles.toolbar}> <div className={styles.toolbar}>
<div className={styles.toolbarActions}>
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}> <Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
Template hinzufuegen Template hinzufuegen
</Button> </Button>
</div> </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()}> <Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
<DialogSurface> <DialogSurface>
@@ -189,20 +392,21 @@ export function TemplatesPage() {
onChange={(_, data) => setVersion(data.value)} onChange={(_, data) => setVersion(data.value)}
/> />
</Field> </Field>
<Field className={styles.wide} label="TemplateCategoryId" required validationMessage={formError}> <Field className={styles.wide} label="Template Category" required validationMessage={formError}>
<Input <Combobox
disabled={isDetails} disabled={isDetails}
value={templateCategoryId} placeholder="Template Category waehlen"
onChange={(_, data) => setTemplateCategoryId(data.value)} value={categoryNameById.get(templateCategoryId) ?? ""}
/> onOptionSelect={(_, optionData) =>
</Field> setTemplateCategoryId(optionData.optionValue ?? "")
<Field className={styles.wide}> }
<Checkbox >
checked={cloudTemplate} {(templateCategories ?? []).map((category) => (
disabled={isDetails} <Option key={category.id} text={category.name} value={category.id}>
label="Cloud Template" {category.name}
onChange={(_, data) => setCloudTemplate(Boolean(data.checked))} </Option>
/> ))}
</Combobox>
</Field> </Field>
<Field className={styles.wide} label="Description"> <Field className={styles.wide} label="Description">
<Textarea <Textarea
@@ -213,12 +417,47 @@ export function TemplatesPage() {
/> />
</Field> </Field>
<Field className={styles.wide} label="JSONData" required> <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 <Textarea
disabled={isDetails} disabled={isDetails}
resize="vertical" resize="vertical"
value={jsonData} value={jsonData}
onChange={(_, data) => setJsonData(data.value)} onChange={(_, data) => setJsonData(data.value)}
/> />
)}
</Field> </Field>
{selectedTemplate && ( {selectedTemplate && (
<Field className={styles.wide} label="Id"> <Field className={styles.wide} label="Id">
@@ -246,26 +485,57 @@ export function TemplatesPage() {
</DialogSurface> </DialogSurface>
</Dialog> </Dialog>
<DataState isLoading={isLoading} error={error ?? deleteTemplate.error} /> <DataState
isLoading={isLoading || templateCategoriesLoading || servicesLoading}
error={error ?? templateCategoriesError ?? servicesError ?? deleteTemplate.error}
/>
{data && ( {data && (
<Table aria-label="Templates"> <Table aria-label="Templates">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>Name</TableHeaderCell> <TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell> <TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Cloud</TableHeaderCell> <TableHeaderCell>Template Category</TableHeaderCell>
<TableHeaderCell>TemplateCategoryId</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell> <TableHeaderCell>Id</TableHeaderCell>
<TableHeaderCell>Aktionen</TableHeaderCell> <TableHeaderCell>Aktionen</TableHeaderCell>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.map((template) => ( {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}> <TableRow key={template.id}>
<TableCell>{template.name}</TableCell> <TableCell className={styles.nestedLevel2Cell}>{template.name}</TableCell>
<TableCell>{template.version}</TableCell> <TableCell>{template.version}</TableCell>
<TableCell>{template.cloudTemplate ? "Ja" : "Nein"}</TableCell> <TableCell>
<TableCell>{template.templateCategoryId}</TableCell> {template.templateCategoryId ? categoryNameById.get(template.templateCategoryId) ?? template.templateCategoryId : ""}
</TableCell>
<TableCell>{template.id}</TableCell> <TableCell>{template.id}</TableCell>
<TableCell> <TableCell>
<div className={styles.actions}> <div className={styles.actions}>
@@ -302,6 +572,10 @@ export function TemplatesPage() {
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</Fragment>
))}
</Fragment>
))}
</TableBody> </TableBody>
</Table> </Table>
)} )}

View File

@@ -0,0 +1,102 @@
import { useQuery } from "@tanstack/react-query";
import {
Badge,
Button,
makeStyles,
shorthands,
Text,
tokens,
} from "@fluentui/react-components";
import { ArrowLeftRegular } from "@fluentui/react-icons";
import { Link, useParams } from "react-router-dom";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { PageHeader } from "../components/PageHeader";
const useStyles = makeStyles({
backLink: {
display: "inline-flex",
marginBottom: "18px",
textDecorationLine: "none",
},
details: {
display: "grid",
gap: "14px",
gridTemplateColumns: "repeat(2, minmax(200px, 1fr))",
marginBottom: "24px",
maxWidth: "860px",
...shorthands.border("1px", "solid", tokens.colorNeutralStroke2),
...shorthands.borderRadius("8px"),
...shorthands.padding("18px"),
},
field: {
display: "grid",
gap: "4px",
minWidth: 0,
},
value: {
overflowWrap: "anywhere",
},
});
export function VirtualMachineDetailsPage() {
const styles = useStyles();
const { id } = useParams();
const { data, error, isLoading } = useQuery({
enabled: Boolean(id),
queryKey: ["virtual-machine", id],
queryFn: ({ signal }) => portalApi.getVirtualMachineById(id!, signal),
});
return (
<>
<Link className={styles.backLink} to="/virtual-machines">
<Button appearance="subtle" icon={<ArrowLeftRegular />}>
Virtual Machines
</Button>
</Link>
<PageHeader title={data?.name ?? "Virtual Machine"} description="Details zur Virtual Machine." />
<DataState isLoading={isLoading} error={error} />
{data && (
<section className={styles.details} aria-label="Virtual machine details">
<div className={styles.field}>
<Text size={200}>Name</Text>
<Text className={styles.value} weight="semibold">
{data.name}
</Text>
</div>
<div className={styles.field}>
<Text size={200}>Domain Link Status</Text>
<Badge appearance={data.domainID ? "filled" : "tint"} color={data.domainID ? "success" : "informative"}>
{data.domainID ? "Linked" : "Unlinked"}
</Badge>
</div>
<div className={styles.field}>
<Text size={200}>Domain Id</Text>
<Text className={styles.value} weight="semibold">
{data.domainID ?? "-"}
</Text>
</div>
<div className={styles.field}>
<Text size={200}>External Id</Text>
<Text className={styles.value} weight="semibold">
{data.externalId ?? "-"}
</Text>
</div>
<div className={styles.field} style={{ gridColumn: "1 / -1" }}>
<Text size={200}>Metadata JSON</Text>
<Text className={styles.value} weight="semibold">
{data.metadataJson ?? "-"}
</Text>
</div>
<div className={styles.field} style={{ gridColumn: "1 / -1" }}>
<Text size={200}>Id</Text>
<Text className={styles.value} weight="semibold">
{data.id}
</Text>
</div>
</section>
)}
</>
);
}

View File

@@ -0,0 +1,401 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Combobox,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field,
Input,
makeStyles,
Option,
shorthands,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
Textarea,
Tooltip,
} from "@fluentui/react-components";
import { AddRegular, DeleteRegular, EditRegular, LinkDismissRegular, LinkRegular, OpenRegular } from "@fluentui/react-icons";
import { useState } from "react";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { PageHeader } from "../components/PageHeader";
import type { Domain, VirtualMachine } from "../types/portal";
import { Link } from "react-router-dom";
const useStyles = makeStyles({
toolbar: {
display: "flex",
justifyContent: "flex-start",
marginBottom: "18px",
},
form: {
display: "grid",
gap: "14px",
},
actions: {
display: "flex",
gap: "4px",
...shorthands.padding("2px", "0"),
},
value: {
overflowWrap: "anywhere",
},
});
type DialogMode = "add" | "edit" | null;
export function VirtualMachinesPage() {
const styles = useStyles();
const queryClient = useQueryClient();
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
const [selectedVirtualMachine, setSelectedVirtualMachine] = useState<VirtualMachine | null>(null);
const [domainID, setDomainID] = useState<string | undefined>(undefined);
const [linkVirtualMachineId, setLinkVirtualMachineId] = useState("");
const [linkDomainId, setLinkDomainId] = useState("");
const [unlinkVirtualMachineId, setUnlinkVirtualMachineId] = useState("");
const [name, setName] = useState("");
const [externalId, setExternalId] = useState("");
const [metadataJson, setMetadataJson] = useState("");
const { data, error, isLoading } = useQuery({
queryKey: ["virtual-machines"],
queryFn: ({ signal }) => portalApi.getVirtualMachines(signal),
});
const { data: domains, error: domainsError, isLoading: domainsLoading } = useQuery({
queryKey: ["domains"],
queryFn: ({ signal }) => portalApi.getDomains(signal),
});
const closeDialog = () => {
setDialogMode(null);
setSelectedVirtualMachine(null);
setDomainID(undefined);
setName("");
setExternalId("");
setMetadataJson("");
};
const openAddDialog = () => {
setSelectedVirtualMachine(null);
setDomainID(undefined);
setName("");
setExternalId("");
setMetadataJson("");
setDialogMode("add");
};
const openVirtualMachineDialog = (mode: "edit", virtualMachine: VirtualMachine) => {
setSelectedVirtualMachine(virtualMachine);
setDomainID(virtualMachine.domainID);
setName(virtualMachine.name);
setExternalId(virtualMachine.externalId ?? "");
setMetadataJson(virtualMachine.metadataJson ?? "");
setDialogMode(mode);
};
const addVirtualMachine = useMutation({
mutationFn: portalApi.addVirtualMachine,
onSuccess: async () => {
closeDialog();
await queryClient.invalidateQueries({ queryKey: ["virtual-machines"] });
},
});
const updateVirtualMachine = useMutation({
mutationFn: ({ id, virtualMachine }: { id: string; virtualMachine: { name: string; externalId?: string; metadataJson?: string } }) =>
portalApi.updateVirtualMachine(id, virtualMachine),
onSuccess: async () => {
closeDialog();
await queryClient.invalidateQueries({ queryKey: ["virtual-machines"] });
},
});
const deleteVirtualMachine = useMutation({
mutationFn: portalApi.deleteVirtualMachine,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["virtual-machines"] });
},
});
const linkVirtualMachineToDomain = useMutation({
mutationFn: ({ virtualMachineId, targetDomainId }: { virtualMachineId: string; targetDomainId: string }) =>
portalApi.linkVirtualMachineToDomain(virtualMachineId, targetDomainId),
onSuccess: async () => {
setLinkVirtualMachineId("");
setLinkDomainId("");
await queryClient.invalidateQueries({ queryKey: ["virtual-machines"] });
},
});
const unlinkVirtualMachineFromDomain = useMutation({
mutationFn: (virtualMachineId: string) => portalApi.unlinkVirtualMachineFromDomain(virtualMachineId),
onSuccess: async () => {
setUnlinkVirtualMachineId("");
await queryClient.invalidateQueries({ queryKey: ["virtual-machines"] });
},
});
const submitVirtualMachine = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const virtualMachine = { domainID, name, externalId, metadataJson };
if (dialogMode === "edit" && selectedVirtualMachine) {
updateVirtualMachine.mutate({ id: selectedVirtualMachine.id, virtualMachine: { name, externalId, metadataJson } });
return;
}
addVirtualMachine.mutate(virtualMachine);
};
const formError =
addVirtualMachine.error?.message ??
updateVirtualMachine.error?.message ??
linkVirtualMachineToDomain.error?.message ??
unlinkVirtualMachineFromDomain.error?.message;
const isSaving = addVirtualMachine.isPending || updateVirtualMachine.isPending;
const domainNameById = new Map((domains ?? []).map((domain: Domain) => [domain.id, domain.name]));
const linkedVirtualMachines = (data ?? []).filter((virtualMachine) => Boolean(virtualMachine.domainID));
const unlinkedVirtualMachines = (data ?? []).filter((virtualMachine) => !virtualMachine.domainID);
return (
<>
<PageHeader title="Virtual Machines" description="Ziele fuer Deployments verwalten." />
<div className={styles.toolbar}>
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
Virtual Machine hinzufuegen
</Button>
<Button
appearance="secondary"
icon={<LinkRegular />}
onClick={() => {
setLinkVirtualMachineId(unlinkedVirtualMachines[0]?.id ?? "");
setLinkDomainId(domains?.[0]?.id ?? "");
}}
disabled={!unlinkedVirtualMachines.length || !domains?.length}
style={{ marginLeft: "10px" }}
>
Link to Domain
</Button>
<Button
appearance="secondary"
icon={<LinkDismissRegular />}
onClick={() => {
setUnlinkVirtualMachineId(linkedVirtualMachines[0]?.id ?? "");
}}
disabled={!linkedVirtualMachines.length}
style={{ marginLeft: "10px" }}
>
Unlink Domain
</Button>
</div>
<Dialog open={dialogMode !== null} onOpenChange={(_, dialogData) => !dialogData.open && closeDialog()}>
<DialogSurface>
<form onSubmit={submitVirtualMachine}>
<DialogBody>
<DialogTitle>
{dialogMode === "edit"
? "Virtual Machine aendern"
: "Virtual Machine hinzufuegen"}
</DialogTitle>
<DialogContent className={styles.form}>
<Field label="Domain (optional)" validationMessage={formError}>
<Combobox
disabled={domainsLoading}
placeholder="Domain waehlen"
value={domainID ? (domainNameById.get(domainID) ?? "") : ""}
onOptionSelect={(_, optionData) => setDomainID(optionData.optionValue || undefined)}
>
{(domains ?? []).map((domain) => (
<Option key={domain.id} text={domain.name} value={domain.id}>
{domain.name}
</Option>
))}
</Combobox>
</Field>
<Field label="Name" required>
<Input value={name} onChange={(_, inputData) => setName(inputData.value)} />
</Field>
<Field label="External Id">
<Input
value={externalId}
onChange={(_, inputData) => setExternalId(inputData.value)}
/>
</Field>
<Field label="Metadata JSON">
<Textarea
resize="vertical"
value={metadataJson}
onChange={(_, inputData) => setMetadataJson(inputData.value)}
/>
</Field>
{selectedVirtualMachine && (
<Field label="Id">
<div className={styles.value}>{selectedVirtualMachine.id}</div>
</Field>
)}
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={closeDialog}>
Abbrechen
</Button>
<Button appearance="primary" disabled={!name || isSaving} type="submit">
Speichern
</Button>
</DialogActions>
</DialogBody>
</form>
</DialogSurface>
</Dialog>
<Dialog open={Boolean(linkVirtualMachineId)} onOpenChange={(_, dialogData) => !dialogData.open && setLinkVirtualMachineId("")}>
<DialogSurface>
<DialogBody>
<DialogTitle>Link Virtual Machine to Domain</DialogTitle>
<DialogContent className={styles.form}>
<Field label="Virtual Machine" required>
<Combobox
placeholder="Virtual Machine waehlen"
value={(data ?? []).find((virtualMachine) => virtualMachine.id === linkVirtualMachineId)?.name ?? ""}
onOptionSelect={(_, optionData) => setLinkVirtualMachineId(optionData.optionValue ?? "")}
>
{unlinkedVirtualMachines.map((virtualMachine) => (
<Option key={virtualMachine.id} text={virtualMachine.name} value={virtualMachine.id}>
{virtualMachine.name}
</Option>
))}
</Combobox>
</Field>
<Field label="Domain" required>
<Combobox
placeholder="Domain waehlen"
value={domains?.find((domain) => domain.id === linkDomainId)?.name ?? ""}
onOptionSelect={(_, optionData) => setLinkDomainId(optionData.optionValue ?? "")}
>
{(domains ?? []).map((domain) => (
<Option key={domain.id} text={domain.name} value={domain.id}>
{domain.name}
</Option>
))}
</Combobox>
</Field>
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={() => setLinkVirtualMachineId("")}>
Abbrechen
</Button>
<Button
appearance="primary"
disabled={!linkVirtualMachineId || !linkDomainId || linkVirtualMachineToDomain.isPending}
onClick={() => linkVirtualMachineToDomain.mutate({ virtualMachineId: linkVirtualMachineId, targetDomainId: linkDomainId })}
>
Link
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
<Dialog open={Boolean(unlinkVirtualMachineId)} onOpenChange={(_, dialogData) => !dialogData.open && setUnlinkVirtualMachineId("")}>
<DialogSurface>
<DialogBody>
<DialogTitle>Unlink Virtual Machine from Domain</DialogTitle>
<DialogContent className={styles.form}>
<Field label="Virtual Machine" required>
<Combobox
placeholder="Virtual Machine waehlen"
value={(data ?? []).find((virtualMachine) => virtualMachine.id === unlinkVirtualMachineId)?.name ?? ""}
onOptionSelect={(_, optionData) => setUnlinkVirtualMachineId(optionData.optionValue ?? "")}
>
{linkedVirtualMachines.map((virtualMachine) => (
<Option key={virtualMachine.id} text={virtualMachine.name} value={virtualMachine.id}>
{virtualMachine.name}
</Option>
))}
</Combobox>
</Field>
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={() => setUnlinkVirtualMachineId("")}>
Abbrechen
</Button>
<Button
appearance="primary"
disabled={!unlinkVirtualMachineId || unlinkVirtualMachineFromDomain.isPending}
onClick={() => unlinkVirtualMachineFromDomain.mutate(unlinkVirtualMachineId)}
>
Unlink
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
<DataState
isLoading={isLoading || domainsLoading}
error={error ?? domainsError ?? deleteVirtualMachine.error ?? linkVirtualMachineToDomain.error ?? unlinkVirtualMachineFromDomain.error}
/>
{data && (
<Table aria-label="Virtual Machines">
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Domain</TableHeaderCell>
<TableHeaderCell>External Id</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
<TableHeaderCell>Aktionen</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{data.map((virtualMachine) => (
<TableRow key={virtualMachine.id}>
<TableCell>{virtualMachine.name}</TableCell>
<TableCell>{virtualMachine.domainID ? (domainNameById.get(virtualMachine.domainID) ?? virtualMachine.domainID) : "-"}</TableCell>
<TableCell>{virtualMachine.externalId}</TableCell>
<TableCell>{virtualMachine.id}</TableCell>
<TableCell>
<div className={styles.actions}>
<Tooltip content="Details" relationship="label">
<Link to={`/virtual-machines/${virtualMachine.id}`}>
<Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} />
</Link>
</Tooltip>
<Tooltip content="Aendern" relationship="label">
<Button
appearance="subtle"
aria-label="Aendern"
icon={<EditRegular />}
onClick={() => openVirtualMachineDialog("edit", virtualMachine)}
/>
</Tooltip>
<Tooltip content="Loeschen" relationship="label">
<Button
appearance="subtle"
aria-label="Loeschen"
disabled={deleteVirtualMachine.isPending}
icon={<DeleteRegular />}
onClick={() => {
if (window.confirm(`Virtual Machine "${virtualMachine.name}" wirklich loeschen?`)) {
deleteVirtualMachine.mutate(virtualMachine.id);
}
}}
/>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
);
}

View File

@@ -1,23 +1,89 @@
export type Deployment = { export type DeploymentExecution = {
id: string; id: string;
deploymentGroupId?: string;
deploymentBatchId?: string;
virtualMachineId?: string;
status?: string; status?: string;
jsonData?: string;
}; };
export type AddDeployment = { export type AddDeploymentExecution = {
deploymentGroupId: string; deploymentBatchId: string;
virtualMachineId: string; virtualMachineId: string;
status: string; status: string;
jsonData: string; jsonData: string;
}; };
export type DeploymentGroup = { export type AddDeploymentRequest = {
deploymentBatchId: string;
virtualMachineIds: string[];
jsonData: string;
};
export type DeploymentJob = {
id: string;
type: string;
status: string;
attempts: number;
maxAttempts: number;
started?: string;
finished?: string;
errorMessage?: string;
targetCount: number;
succeededTargetCount: number;
failedTargetCount: number;
};
export type DeploymentJobTarget = {
id: string;
virtualMachineId: string;
deploymentBatchId: string;
templateId: string;
status: string;
attempts: number;
errorMessage?: string;
};
export type DeploymentJobStep = {
id: string;
dependsOnDeploymentJobStepId?: string;
sortOrder: number;
name: string;
stepType: string;
status: string;
metadataJson?: string;
approvedAt?: string;
approvedBy?: string;
approvalComment?: string;
};
export type DeploymentJobDetails = {
id: string;
type: string;
status: string;
payloadJson: string;
attempts: number;
maxAttempts: number;
started?: string;
finished?: string;
errorMessage?: string;
targets: DeploymentJobTarget[];
steps: DeploymentJobStep[];
};
export type DeploymentBatch = {
id: string; id: string;
status?: string; status?: string;
}; };
export type AddDeploymentGroup = { export type DeploymentBatchDetails = DeploymentBatch & {
deployments?: DeploymentExecution[];
};
export type AddDeploymentBatch = {
templateId: string; templateId: string;
status: string; status: string;
virtualMachineIds?: string[];
}; };
export type Domain = { export type Domain = {
@@ -33,6 +99,10 @@ export type DomainWithEnvironments = Domain & {
}>; }>;
}; };
export type DomainWithVirtualMachines = Domain & {
virtualMachines?: VirtualMachine[];
};
export type AddDomain = { export type AddDomain = {
name: string; name: string;
fqdn: string; fqdn: string;
@@ -42,6 +112,11 @@ export type AddDomain = {
export type EnvironmentItem = { export type EnvironmentItem = {
id: string; id: string;
name: string; name: string;
environmentType?: string;
providerType?: string;
tenantId?: string;
subscriptionId?: string;
metadataJson?: string;
}; };
export type EnvironmentWithDomains = EnvironmentItem & { export type EnvironmentWithDomains = EnvironmentItem & {
@@ -52,6 +127,11 @@ export type EnvironmentWithDomains = EnvironmentItem & {
export type AddEnvironment = { export type AddEnvironment = {
name: string; name: string;
environmentType: string;
providerType?: string;
tenantId?: string;
subscriptionId?: string;
metadataJson?: string;
}; };
export type Runbook = { export type Runbook = {
@@ -69,7 +149,6 @@ export type Template = {
id: string; id: string;
name: string; name: string;
templateCategoryId?: string; templateCategoryId?: string;
cloudTemplate?: boolean;
version?: string; version?: string;
description?: string; description?: string;
jsonData?: string; jsonData?: string;
@@ -79,7 +158,6 @@ export type Template = {
export type AddTemplate = { export type AddTemplate = {
templateCategoryId: string; templateCategoryId: string;
name: string; name: string;
cloudTemplate: boolean;
version: string; version: string;
description: string; description: string;
jsonData: string; jsonData: string;
@@ -89,9 +167,59 @@ export type ServiceItem = {
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
isCloudService?: boolean;
iconKey?: string;
}; };
export type AddService = { export type AddService = {
name: string; name: string;
description: string; description: string;
isCloudService: boolean;
iconKey?: string;
};
export type ServiceRoleDefinition = {
id: string;
serviceId: string;
key: string;
name: string;
description?: string;
};
export type AddServiceRoleDefinition = {
key: string;
name: string;
description?: string;
};
export type TemplateCategory = {
id: string;
serviceId: string;
name: string;
description?: string;
isActive: boolean;
color?: string;
};
export type AddTemplateCategory = {
serviceId: string;
name: string;
description?: string;
isActive: boolean;
color?: string;
};
export type VirtualMachine = {
id: string;
domainID?: string;
name: string;
externalId?: string;
metadataJson?: string;
};
export type AddVirtualMachine = {
domainID?: string;
name: string;
externalId?: string;
metadataJson?: string;
}; };