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:
@@ -1,30 +1,53 @@
|
||||
import { deleteJson, getJson, postJson, putJson } from "./httpClient";
|
||||
import type {
|
||||
AddDeployment,
|
||||
AddDeploymentGroup,
|
||||
AddDeploymentExecution,
|
||||
AddDeploymentRequest,
|
||||
AddDeploymentBatch,
|
||||
AddDomain,
|
||||
AddEnvironment,
|
||||
AddRunbook,
|
||||
AddService,
|
||||
AddServiceRoleDefinition,
|
||||
AddTemplateCategory,
|
||||
AddTemplate,
|
||||
Deployment,
|
||||
DeploymentGroup,
|
||||
DeploymentExecution,
|
||||
DeploymentBatch,
|
||||
DeploymentBatchDetails,
|
||||
Domain,
|
||||
DomainWithEnvironments,
|
||||
DomainWithVirtualMachines,
|
||||
EnvironmentItem,
|
||||
EnvironmentWithDomains,
|
||||
Runbook,
|
||||
ServiceItem,
|
||||
ServiceRoleDefinition,
|
||||
TemplateCategory,
|
||||
Template,
|
||||
VirtualMachine,
|
||||
AddVirtualMachine,
|
||||
DeploymentJob,
|
||||
DeploymentJobDetails,
|
||||
} from "../types/portal";
|
||||
|
||||
export const portalApi = {
|
||||
getDeployments: (signal?: AbortSignal) => getJson<Deployment[]>("/Deployment", signal),
|
||||
addDeployment: (deployment: AddDeployment) => postJson<AddDeployment, string>("/Deployment", deployment),
|
||||
getDeploymentGroups: (signal?: AbortSignal) => getJson<DeploymentGroup[]>("/DeploymentGroup", signal),
|
||||
addDeploymentGroup: (deploymentGroup: AddDeploymentGroup) => postJson<AddDeploymentGroup, string>("/DeploymentGroup", deploymentGroup),
|
||||
getDeployments: (signal?: AbortSignal) => getJson<DeploymentExecution[]>("/Deployment", signal),
|
||||
addDeployment: (deployment: AddDeploymentExecution) => postJson<AddDeploymentExecution, string>("/Deployment", deployment),
|
||||
addDeploymentRequest: (request: AddDeploymentRequest) => postJson<AddDeploymentRequest, string>("/Deployment/Request", request),
|
||||
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),
|
||||
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),
|
||||
updateDomain: (domainId: string, domain: AddDomain) => putJson<AddDomain, void>(`/Domain/${domainId}`, domain),
|
||||
deleteDomain: (domainId: string) => deleteJson<void>(`/Domain/${domainId}`),
|
||||
@@ -44,4 +67,26 @@ export const portalApi = {
|
||||
addService: (service: AddService) => postJson<AddService, string>("/Service", service),
|
||||
updateService: (serviceId: string, service: AddService) => putJson<AddService, void>(`/Service/${serviceId}`, service),
|
||||
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`),
|
||||
};
|
||||
|
||||
@@ -10,13 +10,11 @@ import {
|
||||
import {
|
||||
AppsListDetail24Regular,
|
||||
BoxMultiple24Regular,
|
||||
CloudFlow24Regular,
|
||||
DatabasePlugConnectedRegular,
|
||||
LinkMultiple24Regular,
|
||||
Globe24Regular,
|
||||
Home24Regular,
|
||||
PlayCircle24Regular,
|
||||
ServerMultipleRegular,
|
||||
TagMultiple24Regular,
|
||||
Desktop24Regular,
|
||||
} from "@fluentui/react-icons";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
@@ -72,14 +70,15 @@ const useStyles = makeStyles({
|
||||
|
||||
const links = [
|
||||
{ to: "/", label: "Dashboard", icon: <Home24Regular /> },
|
||||
//{ to: "/deployments", label: "Deployments", icon: <CloudFlow24Regular /> },
|
||||
//{ to: "/deployment-groups", label: "Deployment Groups", icon: <ServerMultipleRegular /> },
|
||||
{ to: "/deployments", label: "Deployments", icon: <BoxMultiple24Regular /> },
|
||||
{ to: "/worker-jobs", label: "Worker Jobs", icon: <AppsListDetail24Regular /> },
|
||||
{ to: "/domains", label: "Domains", icon: <Globe24Regular /> },
|
||||
{ to: "/environment-domains", label: "Environment Domains", icon: <LinkMultiple24Regular /> },
|
||||
{ to: "/environments", label: "Environments", icon: <DatabasePlugConnectedRegular /> },
|
||||
//{ to: "/runbooks", label: "Runbooks", icon: <PlayCircle24Regular /> },
|
||||
{ to: "/templates", label: "Templates", icon: <BoxMultiple24Regular /> },
|
||||
{ to: "/template-categories", label: "Template Categories", icon: <TagMultiple24Regular /> },
|
||||
{ to: "/services", label: "Services", icon: <AppsListDetail24Regular /> },
|
||||
{ to: "/virtual-machines", label: "Virtual Machines", icon: <Desktop24Regular /> },
|
||||
];
|
||||
|
||||
export function AppShell() {
|
||||
|
||||
20
src/main.tsx
20
src/main.tsx
@@ -5,16 +5,19 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import { AppShell } from "./layout/AppShell";
|
||||
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 { DomainDetailsPage } from "./pages/DomainDetailsPage";
|
||||
import { DomainsPage } from "./pages/DomainsPage";
|
||||
import { EnvironmentDomainsPage } from "./pages/EnvironmentDomainsPage";
|
||||
import { EnvironmentDetailsPage } from "./pages/EnvironmentDetailsPage";
|
||||
import { EnvironmentsPage } from "./pages/EnvironmentsPage";
|
||||
import { RunbooksPage } from "./pages/RunbooksPage";
|
||||
import { TemplatesPage } from "./pages/TemplatesPage";
|
||||
import { ServicesPage } from "./pages/ServicesPage";
|
||||
import { TemplateCategoriesPage } from "./pages/TemplateCategoriesPage";
|
||||
import { VirtualMachineDetailsPage } from "./pages/VirtualMachineDetailsPage";
|
||||
import { VirtualMachinesPage } from "./pages/VirtualMachinesPage";
|
||||
import "./styles/global.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -32,16 +35,19 @@ const router = createBrowserRouter([
|
||||
element: <AppShell />,
|
||||
children: [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: "deployments", element: <DeploymentsPage /> },
|
||||
{ path: "deployment-groups", element: <DeploymentGroupsPage /> },
|
||||
{ path: "deployments", element: <DeploymentGroupsPage /> },
|
||||
{ path: "deployments/:id", element: <DeploymentBatchDetailsPage /> },
|
||||
{ path: "worker-jobs", element: <DeploymentJobsPage /> },
|
||||
{ path: "worker-jobs/:id", element: <DeploymentJobDetailsPage /> },
|
||||
{ path: "domains", element: <DomainsPage /> },
|
||||
{ path: "domains/:id", element: <DomainDetailsPage /> },
|
||||
{ path: "environment-domains", element: <EnvironmentDomainsPage /> },
|
||||
{ path: "environments", element: <EnvironmentsPage /> },
|
||||
{ path: "environments/:id", element: <EnvironmentDetailsPage /> },
|
||||
{ path: "runbooks", element: <RunbooksPage /> },
|
||||
{ path: "templates", element: <TemplatesPage /> },
|
||||
{ path: "template-categories", element: <TemplateCategoriesPage /> },
|
||||
{ path: "services", element: <ServicesPage /> },
|
||||
{ path: "virtual-machines", element: <VirtualMachinesPage /> },
|
||||
{ path: "virtual-machines/:id", element: <VirtualMachineDetailsPage /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
Divider,
|
||||
makeStyles,
|
||||
Text,
|
||||
tokens,
|
||||
@@ -16,11 +17,43 @@ const useStyles = makeStyles({
|
||||
gridTemplateColumns: "repeat(4, minmax(160px, 1fr))",
|
||||
gap: "16px",
|
||||
},
|
||||
chartGrid: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(280px, 1fr))",
|
||||
gap: "16px",
|
||||
marginTop: "16px",
|
||||
},
|
||||
metric: {
|
||||
fontSize: "32px",
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
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() {
|
||||
@@ -41,10 +74,46 @@ export function DashboardPage() {
|
||||
queryKey: ["services"],
|
||||
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 =
|
||||
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 (
|
||||
<>
|
||||
@@ -58,6 +127,26 @@ export function DashboardPage() {
|
||||
<MetricCard label="Services" value={services.data?.length ?? 0} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
118
src/pages/DeploymentBatchDetailsPage.tsx
Normal file
118
src/pages/DeploymentBatchDetailsPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +1,191 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
Field,
|
||||
Input,
|
||||
makeStyles,
|
||||
Option,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
} from "@fluentui/react-components";
|
||||
import { OpenRegular } from "@fluentui/react-icons";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { portalApi } from "../api/portalApi";
|
||||
import { DataState } from "../components/DataState";
|
||||
import { FormActions, FormGrid, FormSection } from "../components/FormSection";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
actions: {
|
||||
display: "flex",
|
||||
gap: "6px",
|
||||
},
|
||||
});
|
||||
|
||||
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: ["deploymentGroups"],
|
||||
queryFn: ({ signal }) => portalApi.getDeploymentGroups(signal),
|
||||
queryKey: ["deployment-batches"],
|
||||
queryFn: ({ signal }) => portalApi.getDeploymentBatches(signal),
|
||||
});
|
||||
const addDeploymentGroup = useMutation({
|
||||
mutationFn: portalApi.addDeploymentGroup,
|
||||
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: ["deploymentGroups"] });
|
||||
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 (
|
||||
<>
|
||||
<PageHeader title="Deployment Groups" description="Gruppen von Bereitstellungen je Template." />
|
||||
<PageHeader title="Deployments" description="Deployment Batches und deren Ausfuehrungen." />
|
||||
<FormSection
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
addDeploymentGroup.mutate({ status, templateId });
|
||||
addDeploymentBatch.mutate({
|
||||
status,
|
||||
templateId,
|
||||
virtualMachineIds: isOnPremService ? virtualMachineIds : undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormGrid>
|
||||
<Field label="Template Id" required>
|
||||
<Input value={templateId} onChange={(_, data) => setTemplateId(data.value)} />
|
||||
<Field label="Service" required>
|
||||
<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 label="Status" required validationMessage={addDeploymentGroup.error?.message}>
|
||||
<Input value={status} onChange={(_, data) => setStatus(data.value)} />
|
||||
<Field label="Template" required>
|
||||
<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 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>
|
||||
<FormActions>
|
||||
<Button
|
||||
appearance="primary"
|
||||
disabled={!templateId || !status || addDeploymentGroup.isPending}
|
||||
disabled={!templateId || !status || (isOnPremService && virtualMachineIds.length === 0) || addDeploymentBatch.isPending}
|
||||
type="submit"
|
||||
>
|
||||
Add deployment group
|
||||
Add deployment batch
|
||||
</Button>
|
||||
</FormActions>
|
||||
</FormSection>
|
||||
<DataState isLoading={isLoading} error={error} />
|
||||
{data && (
|
||||
<Table aria-label="Deployment groups">
|
||||
<Table aria-label="Deployment batches">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Id</TableHeaderCell>
|
||||
<TableHeaderCell>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((deploymentGroup) => (
|
||||
<TableRow key={deploymentGroup.id}>
|
||||
<TableCell>{deploymentGroup.status ?? "Unknown"}</TableCell>
|
||||
<TableCell>{deploymentGroup.id}</TableCell>
|
||||
{data.map((deploymentBatch) => (
|
||||
<TableRow key={deploymentBatch.id}>
|
||||
<TableCell>{deploymentBatch.status ?? "Unknown"}</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>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
229
src/pages/DeploymentJobDetailsPage.tsx
Normal file
229
src/pages/DeploymentJobDetailsPage.tsx
Normal 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
229
src/pages/DeploymentJobsPage.tsx
Normal file
229
src/pages/DeploymentJobsPage.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
Field,
|
||||
Input,
|
||||
Option,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -20,43 +22,83 @@ import { PageHeader } from "../components/PageHeader";
|
||||
|
||||
export function DeploymentsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [deploymentGroupId, setDeploymentGroupId] = useState("");
|
||||
const [virtualMachineId, setVirtualMachineId] = useState("");
|
||||
const [status, setStatus] = useState("New");
|
||||
const [deploymentBatchId, setDeploymentBatchId] = useState("");
|
||||
const [selectedVirtualMachineIds, setSelectedVirtualMachineIds] = useState<string[]>([]);
|
||||
const [jsonData, setJsonData] = useState("{}");
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ["deployments"],
|
||||
queryFn: ({ signal }) => portalApi.getDeployments(signal),
|
||||
});
|
||||
const addDeployment = useMutation({
|
||||
mutationFn: portalApi.addDeployment,
|
||||
const { data: deploymentBatches } = useQuery({
|
||||
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 () => {
|
||||
setDeploymentGroupId("");
|
||||
setVirtualMachineId("");
|
||||
setStatus("New");
|
||||
setDeploymentBatchId("");
|
||||
setSelectedVirtualMachineIds([]);
|
||||
setJsonData("{}");
|
||||
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 (
|
||||
<>
|
||||
<PageHeader title="Deployments" description="Aktuelle Bereitstellungen aus der Core API." />
|
||||
<PageHeader title="Deployment Executions" description="Aktuelle Ausfuehrungen aus der Core API." />
|
||||
<FormSection
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
addDeployment.mutate({ deploymentGroupId, jsonData, status, virtualMachineId });
|
||||
addDeploymentRequest.mutate({ deploymentBatchId, jsonData, virtualMachineIds: selectedVirtualMachineIds });
|
||||
}}
|
||||
>
|
||||
<FormGrid>
|
||||
<Field label="Deployment Group Id" required>
|
||||
<Input value={deploymentGroupId} onChange={(_, data) => setDeploymentGroupId(data.value)} />
|
||||
<Field label="Deployment Batch" required validationMessage={addDeploymentRequest.error?.message}>
|
||||
<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 label="Virtual Machine Id" required>
|
||||
<Input value={virtualMachineId} onChange={(_, data) => setVirtualMachineId(data.value)} />
|
||||
</Field>
|
||||
<Field label="Status" required validationMessage={addDeployment.error?.message}>
|
||||
<Input value={status} onChange={(_, data) => setStatus(data.value)} />
|
||||
<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((id) => id !== virtualMachine.id));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<FormWide>
|
||||
<Field label="JSON data" required>
|
||||
@@ -67,26 +109,65 @@ export function DeploymentsPage() {
|
||||
<FormActions>
|
||||
<Button
|
||||
appearance="primary"
|
||||
disabled={!deploymentGroupId || !virtualMachineId || !status || addDeployment.isPending}
|
||||
disabled={!deploymentBatchId || selectedVirtualMachineIds.length === 0 || addDeploymentRequest.isPending}
|
||||
type="submit"
|
||||
>
|
||||
Add deployment
|
||||
Start deployment
|
||||
</Button>
|
||||
</FormActions>
|
||||
</FormSection>
|
||||
<DataState isLoading={isLoading} error={error} />
|
||||
<DataState isLoading={isLoading || deploymentJobsLoading} error={error ?? deploymentJobsError} />
|
||||
{deploymentJobs && (
|
||||
<Table aria-label="Worker Jobs">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Status</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="Deployments">
|
||||
<Table aria-label="Deployment executions">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Deployment Id</TableHeaderCell>
|
||||
<TableHeaderCell>Deployment Batch</TableHeaderCell>
|
||||
<TableHeaderCell>Virtual Machine</TableHeaderCell>
|
||||
<TableHeaderCell>Execution Id</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((deployment) => (
|
||||
<TableRow key={deployment.id}>
|
||||
<TableCell>{deployment.status ?? "Unknown"}</TableCell>
|
||||
<TableCell>{deployment.deploymentBatchId ?? "-"}</TableCell>
|
||||
<TableCell>{deployment.virtualMachineId ?? "-"}</TableCell>
|
||||
<TableCell>{deployment.id}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
makeStyles,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
@@ -15,8 +17,10 @@ import {
|
||||
Text,
|
||||
Title3,
|
||||
tokens,
|
||||
Tooltip,
|
||||
} 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 { portalApi } from "../api/portalApi";
|
||||
import { DataState } from "../components/DataState";
|
||||
@@ -47,19 +51,72 @@ const useStyles = makeStyles({
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
sectionTitle: {
|
||||
marginTop: "20px",
|
||||
marginBottom: "12px",
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
gap: "4px",
|
||||
...shorthands.padding("2px", "0"),
|
||||
},
|
||||
});
|
||||
|
||||
export function DomainDetailsPage() {
|
||||
const styles = useStyles();
|
||||
const { id } = useParams();
|
||||
const [search, setSearch] = useState("");
|
||||
const [sortBy, setSortBy] = useState("name");
|
||||
const [sortOrder, setSortOrder] = useState("asc");
|
||||
const { data, error, isLoading } = useQuery({
|
||||
enabled: Boolean(id),
|
||||
queryKey: ["domain", id, "environments"],
|
||||
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 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 (
|
||||
<>
|
||||
@@ -69,7 +126,7 @@ export function DomainDetailsPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
<PageHeader title={data?.name ?? "Domain"} description="Details und verknuepfte Environments." />
|
||||
<DataState isLoading={isLoading} error={error} />
|
||||
<DataState isLoading={isLoading || virtualMachinesLoading} error={error ?? virtualMachinesError} />
|
||||
{data && (
|
||||
<>
|
||||
<section className={styles.details} aria-label="Domain details">
|
||||
@@ -125,13 +182,62 @@ export function DomainDetailsPage() {
|
||||
</Badge>
|
||||
</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>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogBody,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
Field,
|
||||
Input,
|
||||
makeStyles,
|
||||
Option,
|
||||
shorthands,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,7 +21,7 @@ import {
|
||||
TableRow,
|
||||
Tooltip,
|
||||
} 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 { Link } from "react-router-dom";
|
||||
import { portalApi } from "../api/portalApi";
|
||||
@@ -31,6 +33,7 @@ const useStyles = makeStyles({
|
||||
toolbar: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
gap: "10px",
|
||||
marginBottom: "18px",
|
||||
},
|
||||
form: {
|
||||
@@ -54,10 +57,16 @@ export function DomainsPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [fqdn, setFqdn] = useState("");
|
||||
const [netBIOS, setNetBIOS] = useState("");
|
||||
const [linkDomainId, setLinkDomainId] = useState("");
|
||||
const [environmentId, setEnvironmentId] = useState("");
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ["domains"],
|
||||
queryFn: ({ signal }) => portalApi.getDomains(signal),
|
||||
});
|
||||
const { data: environments, error: environmentsError, isLoading: environmentsLoading } = useQuery({
|
||||
queryKey: ["environments"],
|
||||
queryFn: ({ signal }) => portalApi.getEnvironments(signal),
|
||||
});
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogMode(null);
|
||||
@@ -83,6 +92,11 @@ export function DomainsPage() {
|
||||
setDialogMode("edit");
|
||||
};
|
||||
|
||||
const openLinkDialog = () => {
|
||||
setLinkDomainId(data?.[0]?.id ?? "");
|
||||
setEnvironmentId(environments?.[0]?.id ?? "");
|
||||
};
|
||||
|
||||
const addDomain = useMutation({
|
||||
mutationFn: portalApi.addDomain,
|
||||
onSuccess: async () => {
|
||||
@@ -106,6 +120,16 @@ export function DomainsPage() {
|
||||
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>) => {
|
||||
event.preventDefault();
|
||||
@@ -129,6 +153,9 @@ export function DomainsPage() {
|
||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||
Domain hinzufuegen
|
||||
</Button>
|
||||
<Button appearance="secondary" icon={<LinkMultiple24Regular />} onClick={openLinkDialog}>
|
||||
Link to Environment
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
||||
@@ -159,8 +186,72 @@ export function DomainsPage() {
|
||||
</form>
|
||||
</DialogSurface>
|
||||
</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 && (
|
||||
<Table aria-label="Domains">
|
||||
<TableHeader>
|
||||
|
||||
@@ -15,8 +15,9 @@ import {
|
||||
Text,
|
||||
Title3,
|
||||
tokens,
|
||||
Tooltip,
|
||||
} 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 { portalApi } from "../api/portalApi";
|
||||
import { DataState } from "../components/DataState";
|
||||
@@ -49,6 +50,11 @@ const useStyles = makeStyles({
|
||||
sectionTitle: {
|
||||
marginBottom: "12px",
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
gap: "4px",
|
||||
...shorthands.padding("2px", "0"),
|
||||
},
|
||||
});
|
||||
|
||||
export function EnvironmentDetailsPage() {
|
||||
@@ -85,6 +91,40 @@ export function EnvironmentDetailsPage() {
|
||||
{data.id}
|
||||
</Text>
|
||||
</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>
|
||||
|
||||
<Title3 className={styles.sectionTitle}>Linked Domains</Title3>
|
||||
@@ -115,7 +155,13 @@ export function EnvironmentDetailsPage() {
|
||||
</Badge>
|
||||
</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>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogBody,
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
Field,
|
||||
Input,
|
||||
makeStyles,
|
||||
Option,
|
||||
Textarea,
|
||||
shorthands,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,7 +22,7 @@ import {
|
||||
TableRow,
|
||||
Tooltip,
|
||||
} 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 { Link } from "react-router-dom";
|
||||
import { portalApi } from "../api/portalApi";
|
||||
@@ -31,6 +34,7 @@ const useStyles = makeStyles({
|
||||
toolbar: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
gap: "10px",
|
||||
marginBottom: "18px",
|
||||
},
|
||||
form: {
|
||||
@@ -52,28 +56,58 @@ export function EnvironmentsPage() {
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
|
||||
const [selectedEnvironment, setSelectedEnvironment] = useState<EnvironmentItem | null>(null);
|
||||
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({
|
||||
queryKey: ["environments"],
|
||||
queryFn: ({ signal }) => portalApi.getEnvironments(signal),
|
||||
});
|
||||
const { data: domains, error: domainsError, isLoading: domainsLoading } = useQuery({
|
||||
queryKey: ["domains"],
|
||||
queryFn: ({ signal }) => portalApi.getDomains(signal),
|
||||
});
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogMode(null);
|
||||
setSelectedEnvironment(null);
|
||||
setName("");
|
||||
setEnvironmentType("OnPrem");
|
||||
setProviderType("");
|
||||
setTenantId("");
|
||||
setSubscriptionId("");
|
||||
setMetadataJson("");
|
||||
};
|
||||
|
||||
const openAddDialog = () => {
|
||||
setSelectedEnvironment(null);
|
||||
setName("");
|
||||
setEnvironmentType("OnPrem");
|
||||
setProviderType("");
|
||||
setTenantId("");
|
||||
setSubscriptionId("");
|
||||
setMetadataJson("");
|
||||
setDialogMode("add");
|
||||
};
|
||||
|
||||
const openEditDialog = (environment: EnvironmentItem) => {
|
||||
setSelectedEnvironment(environment);
|
||||
setName(environment.name);
|
||||
setEnvironmentType(environment.environmentType ?? "OnPrem");
|
||||
setProviderType(environment.providerType ?? "");
|
||||
setTenantId(environment.tenantId ?? "");
|
||||
setSubscriptionId(environment.subscriptionId ?? "");
|
||||
setMetadataJson(environment.metadataJson ?? "");
|
||||
setDialogMode("edit");
|
||||
};
|
||||
const openLinkDialog = () => {
|
||||
setLinkEnvironmentId(data?.[0]?.id ?? "");
|
||||
setDomainId(domains?.[0]?.id ?? "");
|
||||
};
|
||||
|
||||
const addEnvironment = useMutation({
|
||||
mutationFn: portalApi.addEnvironment,
|
||||
@@ -84,7 +118,14 @@ export function EnvironmentsPage() {
|
||||
});
|
||||
|
||||
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),
|
||||
onSuccess: async () => {
|
||||
closeDialog();
|
||||
@@ -98,10 +139,27 @@ export function EnvironmentsPage() {
|
||||
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>) => {
|
||||
event.preventDefault();
|
||||
const environment = { name };
|
||||
const environment = {
|
||||
name,
|
||||
environmentType,
|
||||
providerType,
|
||||
tenantId,
|
||||
subscriptionId,
|
||||
metadataJson,
|
||||
};
|
||||
|
||||
if (dialogMode === "edit" && selectedEnvironment) {
|
||||
updateEnvironment.mutate({ id: selectedEnvironment.id, environment });
|
||||
@@ -113,6 +171,9 @@ export function EnvironmentsPage() {
|
||||
|
||||
const formError = addEnvironment.error?.message ?? updateEnvironment.error?.message;
|
||||
const isSaving = addEnvironment.isPending || updateEnvironment.isPending;
|
||||
const isOnPrem = environmentType === "OnPrem";
|
||||
const isAzureTenant = environmentType === "AzureTenant";
|
||||
const isM365Tenant = environmentType === "M365Tenant";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -121,6 +182,9 @@ export function EnvironmentsPage() {
|
||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||
Environment hinzufuegen
|
||||
</Button>
|
||||
<Button appearance="secondary" icon={<LinkMultiple24Regular />} onClick={openLinkDialog}>
|
||||
Link to Domain
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
||||
@@ -134,6 +198,73 @@ export function EnvironmentsPage() {
|
||||
<Field label="Name" required validationMessage={formError}>
|
||||
<Input value={name} onChange={(_, data) => setName(data.value)} />
|
||||
</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>
|
||||
<DialogActions>
|
||||
<Button appearance="secondary" onClick={closeDialog}>
|
||||
@@ -147,13 +278,85 @@ export function EnvironmentsPage() {
|
||||
</form>
|
||||
</DialogSurface>
|
||||
</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 && (
|
||||
<Table aria-label="Environments">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Type</TableHeaderCell>
|
||||
<TableHeaderCell>Cloud</TableHeaderCell>
|
||||
<TableHeaderCell>Provider</TableHeaderCell>
|
||||
<TableHeaderCell>Tenant</TableHeaderCell>
|
||||
<TableHeaderCell>Subscription</TableHeaderCell>
|
||||
<TableHeaderCell>Id</TableHeaderCell>
|
||||
<TableHeaderCell>Aktionen</TableHeaderCell>
|
||||
</TableRow>
|
||||
@@ -162,6 +365,11 @@ export function EnvironmentsPage() {
|
||||
{data.map((environment) => (
|
||||
<TableRow key={environment.id}>
|
||||
<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>
|
||||
<div className={styles.actions}>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogBody,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
Field,
|
||||
Input,
|
||||
makeStyles,
|
||||
Option,
|
||||
shorthands,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -20,23 +23,51 @@ import {
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@fluentui/react-components";
|
||||
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
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 { DataState } from "../components/DataState";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import type { ServiceItem } from "../types/portal";
|
||||
import type { AddServiceRoleDefinition, ServiceItem, ServiceRoleDefinition } from "../types/portal";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
toolbar: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-end",
|
||||
gap: "12px",
|
||||
marginBottom: "18px",
|
||||
},
|
||||
toolbarActions: {
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
},
|
||||
filter: {
|
||||
minWidth: "280px",
|
||||
},
|
||||
form: {
|
||||
display: "grid",
|
||||
gap: "14px",
|
||||
},
|
||||
iconOption: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
gap: "4px",
|
||||
@@ -45,10 +76,40 @@ const useStyles = makeStyles({
|
||||
value: {
|
||||
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;
|
||||
|
||||
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() {
|
||||
const styles = useStyles();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -56,21 +117,45 @@ export function ServicesPage() {
|
||||
const [selectedService, setSelectedService] = useState<ServiceItem | null>(null);
|
||||
const [name, setName] = 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({
|
||||
queryKey: ["services"],
|
||||
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 = () => {
|
||||
setDialogMode(null);
|
||||
setSelectedService(null);
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIsCloudService(false);
|
||||
setIconKey("");
|
||||
setRoleDialogOpen(false);
|
||||
setSelectedRoleDefinition(null);
|
||||
};
|
||||
|
||||
const openAddDialog = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIsCloudService(false);
|
||||
setIconKey("");
|
||||
setSelectedService(null);
|
||||
setDialogMode("add");
|
||||
};
|
||||
@@ -79,6 +164,8 @@ export function ServicesPage() {
|
||||
setSelectedService(service);
|
||||
setName(service.name);
|
||||
setDescription(service.description ?? "");
|
||||
setIsCloudService(Boolean(service.isCloudService));
|
||||
setIconKey(service.iconKey ?? "");
|
||||
setDialogMode(mode);
|
||||
};
|
||||
|
||||
@@ -91,7 +178,7 @@ export function ServicesPage() {
|
||||
});
|
||||
|
||||
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),
|
||||
onSuccess: async () => {
|
||||
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>) => {
|
||||
event.preventDefault();
|
||||
const service = { description, name };
|
||||
const service = { description, name, isCloudService, iconKey: iconKey || undefined };
|
||||
|
||||
if (dialogMode === "edit" && selectedService) {
|
||||
updateService.mutate({ id: selectedService.id, service });
|
||||
@@ -119,16 +234,105 @@ export function ServicesPage() {
|
||||
};
|
||||
|
||||
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 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 (
|
||||
<>
|
||||
<PageHeader title="Services" description="Service-Katalog aus der Core API." />
|
||||
<div className={styles.toolbar}>
|
||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||
Service hinzufuegen
|
||||
</Button>
|
||||
<div className={styles.toolbarActions}>
|
||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||
Service hinzufuegen
|
||||
</Button>
|
||||
</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()}>
|
||||
@@ -154,6 +358,92 @@ export function ServicesPage() {
|
||||
onChange={(_, data) => setDescription(data.value)}
|
||||
/>
|
||||
</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 && (
|
||||
<Field label="Id">
|
||||
<div className={styles.value}>{selectedService.id}</div>
|
||||
@@ -175,6 +465,39 @@ export function ServicesPage() {
|
||||
</DialogSurface>
|
||||
</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} />
|
||||
{data && (
|
||||
<Table aria-label="Services">
|
||||
@@ -187,45 +510,62 @@ export function ServicesPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((service) => (
|
||||
<TableRow key={service.id}>
|
||||
<TableCell>{service.name}</TableCell>
|
||||
<TableCell>{service.description}</TableCell>
|
||||
<TableCell>{service.id}</TableCell>
|
||||
<TableCell>
|
||||
<div className={styles.actions}>
|
||||
<Tooltip content="Details" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Details"
|
||||
icon={<OpenRegular />}
|
||||
onClick={() => openServiceDialog("details", service)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Aendern" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Aendern"
|
||||
icon={<EditRegular />}
|
||||
onClick={() => openServiceDialog("edit", service)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Loeschen" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Loeschen"
|
||||
disabled={deleteService.isPending}
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Service "${service.name}" wirklich loeschen?`)) {
|
||||
deleteService.mutate(service.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{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}>
|
||||
<TableCell className={styles.nestedCell}>
|
||||
<div className={styles.nameWithIcon}>
|
||||
{getServiceIcon(service.iconKey)}
|
||||
<span>{service.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{service.description}</TableCell>
|
||||
<TableCell>{service.id}</TableCell>
|
||||
<TableCell>
|
||||
<div className={styles.actions}>
|
||||
<Tooltip content="Details" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Details"
|
||||
icon={<OpenRegular />}
|
||||
onClick={() => openServiceDialog("details", service)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Aendern" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Aendern"
|
||||
icon={<EditRegular />}
|
||||
onClick={() => openServiceDialog("edit", service)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Loeschen" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Loeschen"
|
||||
disabled={deleteService.isPending}
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Service "${service.name}" wirklich loeschen?`)) {
|
||||
deleteService.mutate(service.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
392
src/pages/TemplateCategoriesPage.tsx
Normal file
392
src/pages/TemplateCategoriesPage.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogBody,
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
Field,
|
||||
Input,
|
||||
makeStyles,
|
||||
Option,
|
||||
Tab,
|
||||
TabList,
|
||||
shorthands,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -22,18 +25,29 @@ import {
|
||||
Tooltip,
|
||||
} from "@fluentui/react-components";
|
||||
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons";
|
||||
import { useState } from "react";
|
||||
import { ChevronDownRegular, ChevronRightRegular } from "@fluentui/react-icons";
|
||||
import { Fragment, useState } from "react";
|
||||
import { portalApi } from "../api/portalApi";
|
||||
import { DataState } from "../components/DataState";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import type { Template } from "../types/portal";
|
||||
import type { ServiceItem, Template, TemplateCategory } from "../types/portal";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
toolbar: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-end",
|
||||
gap: "12px",
|
||||
marginBottom: "18px",
|
||||
},
|
||||
toolbarActions: {
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
},
|
||||
filter: {
|
||||
minWidth: "280px",
|
||||
},
|
||||
form: {
|
||||
display: "grid",
|
||||
gap: "14px",
|
||||
@@ -54,6 +68,25 @@ const useStyles = makeStyles({
|
||||
value: {
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
groupRow: {
|
||||
cursor: "pointer",
|
||||
},
|
||||
groupHeader: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
},
|
||||
nestedLevel1Cell: {
|
||||
paddingLeft: "28px",
|
||||
},
|
||||
nestedLevel2Cell: {
|
||||
paddingLeft: "48px",
|
||||
},
|
||||
categoryHeaderCell: {
|
||||
paddingTop: "10px",
|
||||
paddingBottom: "6px",
|
||||
paddingLeft: "28px",
|
||||
},
|
||||
});
|
||||
|
||||
type DialogMode = "add" | "edit" | "details" | null;
|
||||
@@ -62,6 +95,57 @@ function getTemplateJsonData(template: Template) {
|
||||
return template.jsonData ?? template.jSONData ?? "";
|
||||
}
|
||||
|
||||
type TemplateEditorTab = "parameters" | "variables" | "resources" | "raw";
|
||||
|
||||
function tryFormatJson(value: string) {
|
||||
try {
|
||||
if (!value.trim()) {
|
||||
return "";
|
||||
}
|
||||
return JSON.stringify(JSON.parse(value), null, 2);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function getCaseInsensitiveProperty(source: Record<string, unknown>, propertyName: string) {
|
||||
const key = Object.keys(source).find((entry) => entry.toLowerCase() === propertyName.toLowerCase());
|
||||
return key ? source[key] : undefined;
|
||||
}
|
||||
|
||||
function normalizeEditorJsonParts(rawJson: string) {
|
||||
if (!rawJson.trim()) {
|
||||
return {
|
||||
raw: "",
|
||||
parameters: "{}",
|
||||
variables: "{}",
|
||||
resources: "[]",
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(rawJson) as unknown;
|
||||
const root = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
||||
|
||||
const parameters = getCaseInsensitiveProperty(root, "parameters");
|
||||
const variables = getCaseInsensitiveProperty(root, "variables");
|
||||
const resources = getCaseInsensitiveProperty(root, "resources");
|
||||
|
||||
return {
|
||||
raw: JSON.stringify(root, null, 2),
|
||||
parameters: JSON.stringify(
|
||||
parameters && typeof parameters === "object" && !Array.isArray(parameters) ? parameters : {},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
variables: JSON.stringify(
|
||||
variables && typeof variables === "object" && !Array.isArray(variables) ? variables : {},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
resources: JSON.stringify(Array.isArray(resources) ? resources : [], null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
export function TemplatesPage() {
|
||||
const styles = useStyles();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -69,24 +153,45 @@ export function TemplatesPage() {
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
||||
const [templateCategoryId, setTemplateCategoryId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [cloudTemplate, setCloudTemplate] = useState(false);
|
||||
const [version, setVersion] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [jsonData, setJsonData] = useState("");
|
||||
const [jsonParameters, setJsonParameters] = useState("{}");
|
||||
const [jsonVariables, setJsonVariables] = useState("{}");
|
||||
const [jsonResources, setJsonResources] = useState("[]");
|
||||
const [editorTab, setEditorTab] = useState<TemplateEditorTab>("parameters");
|
||||
const [serviceFilterId, setServiceFilterId] = useState("all");
|
||||
const [expandedServiceGroups, setExpandedServiceGroups] = useState<Record<string, boolean>>({});
|
||||
const [expandedCategoryGroups, setExpandedCategoryGroups] = useState<Record<string, boolean>>({});
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ["templates"],
|
||||
queryFn: ({ signal }) => portalApi.getTemplates(signal),
|
||||
});
|
||||
const {
|
||||
data: templateCategories,
|
||||
error: templateCategoriesError,
|
||||
isLoading: templateCategoriesLoading,
|
||||
} = useQuery({
|
||||
queryKey: ["template-categories"],
|
||||
queryFn: ({ signal }) => portalApi.getTemplateCategories(signal),
|
||||
});
|
||||
const { data: services, error: servicesError, isLoading: servicesLoading } = useQuery({
|
||||
queryKey: ["services"],
|
||||
queryFn: ({ signal }) => portalApi.getServices(signal),
|
||||
});
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogMode(null);
|
||||
setSelectedTemplate(null);
|
||||
setTemplateCategoryId("");
|
||||
setName("");
|
||||
setCloudTemplate(false);
|
||||
setVersion("");
|
||||
setDescription("");
|
||||
setJsonData("");
|
||||
setJsonParameters("{}");
|
||||
setJsonVariables("{}");
|
||||
setJsonResources("[]");
|
||||
setEditorTab("parameters");
|
||||
};
|
||||
|
||||
const openAddDialog = () => {
|
||||
@@ -98,10 +203,21 @@ export function TemplatesPage() {
|
||||
setSelectedTemplate(template);
|
||||
setTemplateCategoryId(template.templateCategoryId ?? "");
|
||||
setName(template.name);
|
||||
setCloudTemplate(Boolean(template.cloudTemplate));
|
||||
setVersion(template.version ?? "");
|
||||
setDescription(template.description ?? "");
|
||||
setJsonData(getTemplateJsonData(template));
|
||||
const raw = getTemplateJsonData(template);
|
||||
try {
|
||||
const normalized = normalizeEditorJsonParts(raw);
|
||||
setJsonData(normalized.raw);
|
||||
setJsonParameters(normalized.parameters);
|
||||
setJsonVariables(normalized.variables);
|
||||
setJsonResources(normalized.resources);
|
||||
} catch {
|
||||
setJsonParameters("{}");
|
||||
setJsonVariables("{}");
|
||||
setJsonResources("[]");
|
||||
setJsonData(tryFormatJson(raw));
|
||||
}
|
||||
setDialogMode(mode);
|
||||
};
|
||||
|
||||
@@ -122,7 +238,6 @@ export function TemplatesPage() {
|
||||
template: {
|
||||
templateCategoryId: string;
|
||||
name: string;
|
||||
cloudTemplate: boolean;
|
||||
version: string;
|
||||
description: string;
|
||||
jsonData: string;
|
||||
@@ -143,7 +258,29 @@ export function TemplatesPage() {
|
||||
|
||||
const submitTemplate = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const template = { cloudTemplate, description, jsonData, name, templateCategoryId, version };
|
||||
let composedJson = jsonData;
|
||||
try {
|
||||
const parsedRaw = jsonData.trim() ? JSON.parse(jsonData) : {};
|
||||
const parsedParameters = jsonParameters.trim() ? JSON.parse(jsonParameters) : {};
|
||||
const parsedVariables = jsonVariables.trim() ? JSON.parse(jsonVariables) : {};
|
||||
const parsedResources = jsonResources.trim() ? JSON.parse(jsonResources) : [];
|
||||
|
||||
composedJson = JSON.stringify(
|
||||
{
|
||||
...parsedRaw,
|
||||
parameters: parsedParameters,
|
||||
variables: parsedVariables,
|
||||
resources: parsedResources,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
setJsonData(composedJson);
|
||||
} catch {
|
||||
// keep current raw JSON and let backend validation respond
|
||||
}
|
||||
|
||||
const template = { description, jsonData: composedJson, name, templateCategoryId, version };
|
||||
|
||||
if (dialogMode === "edit" && selectedTemplate) {
|
||||
updateTemplate.mutate({ id: selectedTemplate.id, template });
|
||||
@@ -156,14 +293,80 @@ export function TemplatesPage() {
|
||||
const formError = addTemplate.error?.message ?? updateTemplate.error?.message;
|
||||
const isSaving = addTemplate.isPending || updateTemplate.isPending;
|
||||
const isDetails = dialogMode === "details";
|
||||
const categoryNameById = new Map(
|
||||
(templateCategories ?? []).map((category: TemplateCategory) => [category.id, category.name]),
|
||||
);
|
||||
const categoryServiceIdById = new Map(
|
||||
(templateCategories ?? []).map((category: TemplateCategory) => [category.id, category.serviceId]),
|
||||
);
|
||||
const serviceNameById = new Map((services ?? []).map((service: ServiceItem) => [service.id, service.name]));
|
||||
const filteredTemplates =
|
||||
serviceFilterId === "all"
|
||||
? (data ?? [])
|
||||
: (data ?? []).filter((template) => {
|
||||
const templateServiceId = template.templateCategoryId
|
||||
? categoryServiceIdById.get(template.templateCategoryId)
|
||||
: undefined;
|
||||
return templateServiceId === serviceFilterId;
|
||||
});
|
||||
const groupedTemplates = (services ?? [])
|
||||
.map((service) => ({
|
||||
serviceId: service.id,
|
||||
serviceName: service.name,
|
||||
categories: (templateCategories ?? [])
|
||||
.filter((category) => category.serviceId === service.id)
|
||||
.map((category) => ({
|
||||
categoryId: category.id,
|
||||
categoryName: category.name,
|
||||
items: filteredTemplates
|
||||
.filter((template) => template.templateCategoryId === category.id)
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}))
|
||||
.filter((categoryGroup) => categoryGroup.items.length > 0)
|
||||
.sort((a, b) => a.categoryName.localeCompare(b.categoryName)),
|
||||
}))
|
||||
.filter((group) => group.categories.length > 0);
|
||||
|
||||
const toggleServiceGroup = (serviceId: string) => {
|
||||
setExpandedServiceGroups((current) => ({
|
||||
...current,
|
||||
[serviceId]: !current[serviceId],
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleCategoryGroup = (serviceId: string, categoryId: string) => {
|
||||
const key = `${serviceId}:${categoryId}`;
|
||||
setExpandedCategoryGroups((current) => ({
|
||||
...current,
|
||||
[key]: !current[key],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Templates" description="Vorlagen fuer Portal-Bereitstellungen." />
|
||||
<div className={styles.toolbar}>
|
||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||
Template hinzufuegen
|
||||
</Button>
|
||||
<div className={styles.toolbarActions}>
|
||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||
Template hinzufuegen
|
||||
</Button>
|
||||
</div>
|
||||
<Field className={styles.filter} label="Service Filter">
|
||||
<Combobox
|
||||
placeholder="Alle Services"
|
||||
value={serviceFilterId === "all" ? "Alle Services" : serviceNameById.get(serviceFilterId) ?? ""}
|
||||
onOptionSelect={(_, selectData) => setServiceFilterId(selectData.optionValue ?? "all")}
|
||||
>
|
||||
<Option text="Alle Services" value="all">
|
||||
Alle Services
|
||||
</Option>
|
||||
{(services ?? []).map((service) => (
|
||||
<Option key={service.id} text={service.name} value={service.id}>
|
||||
{service.name}
|
||||
</Option>
|
||||
))}
|
||||
</Combobox>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
||||
@@ -189,20 +392,21 @@ export function TemplatesPage() {
|
||||
onChange={(_, data) => setVersion(data.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field className={styles.wide} label="TemplateCategoryId" required validationMessage={formError}>
|
||||
<Input
|
||||
<Field className={styles.wide} label="Template Category" required validationMessage={formError}>
|
||||
<Combobox
|
||||
disabled={isDetails}
|
||||
value={templateCategoryId}
|
||||
onChange={(_, data) => setTemplateCategoryId(data.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field className={styles.wide}>
|
||||
<Checkbox
|
||||
checked={cloudTemplate}
|
||||
disabled={isDetails}
|
||||
label="Cloud Template"
|
||||
onChange={(_, data) => setCloudTemplate(Boolean(data.checked))}
|
||||
/>
|
||||
placeholder="Template Category waehlen"
|
||||
value={categoryNameById.get(templateCategoryId) ?? ""}
|
||||
onOptionSelect={(_, optionData) =>
|
||||
setTemplateCategoryId(optionData.optionValue ?? "")
|
||||
}
|
||||
>
|
||||
{(templateCategories ?? []).map((category) => (
|
||||
<Option key={category.id} text={category.name} value={category.id}>
|
||||
{category.name}
|
||||
</Option>
|
||||
))}
|
||||
</Combobox>
|
||||
</Field>
|
||||
<Field className={styles.wide} label="Description">
|
||||
<Textarea
|
||||
@@ -213,12 +417,47 @@ export function TemplatesPage() {
|
||||
/>
|
||||
</Field>
|
||||
<Field className={styles.wide} label="JSONData" required>
|
||||
<Textarea
|
||||
disabled={isDetails}
|
||||
resize="vertical"
|
||||
value={jsonData}
|
||||
onChange={(_, data) => setJsonData(data.value)}
|
||||
/>
|
||||
<TabList
|
||||
selectedValue={editorTab}
|
||||
onTabSelect={(_, tabData) => setEditorTab(tabData.value as TemplateEditorTab)}
|
||||
>
|
||||
<Tab value="parameters">Parameters</Tab>
|
||||
<Tab value="variables">Variables</Tab>
|
||||
<Tab value="resources">Resources</Tab>
|
||||
<Tab value="raw">Raw JSON</Tab>
|
||||
</TabList>
|
||||
{editorTab === "parameters" && (
|
||||
<Textarea
|
||||
disabled={isDetails}
|
||||
resize="vertical"
|
||||
value={jsonParameters}
|
||||
onChange={(_, data) => setJsonParameters(data.value)}
|
||||
/>
|
||||
)}
|
||||
{editorTab === "variables" && (
|
||||
<Textarea
|
||||
disabled={isDetails}
|
||||
resize="vertical"
|
||||
value={jsonVariables}
|
||||
onChange={(_, data) => setJsonVariables(data.value)}
|
||||
/>
|
||||
)}
|
||||
{editorTab === "resources" && (
|
||||
<Textarea
|
||||
disabled={isDetails}
|
||||
resize="vertical"
|
||||
value={jsonResources}
|
||||
onChange={(_, data) => setJsonResources(data.value)}
|
||||
/>
|
||||
)}
|
||||
{editorTab === "raw" && (
|
||||
<Textarea
|
||||
disabled={isDetails}
|
||||
resize="vertical"
|
||||
value={jsonData}
|
||||
onChange={(_, data) => setJsonData(data.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{selectedTemplate && (
|
||||
<Field className={styles.wide} label="Id">
|
||||
@@ -246,61 +485,96 @@ export function TemplatesPage() {
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
|
||||
<DataState isLoading={isLoading} error={error ?? deleteTemplate.error} />
|
||||
<DataState
|
||||
isLoading={isLoading || templateCategoriesLoading || servicesLoading}
|
||||
error={error ?? templateCategoriesError ?? servicesError ?? deleteTemplate.error}
|
||||
/>
|
||||
{data && (
|
||||
<Table aria-label="Templates">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell>Cloud</TableHeaderCell>
|
||||
<TableHeaderCell>TemplateCategoryId</TableHeaderCell>
|
||||
<TableHeaderCell>Template Category</TableHeaderCell>
|
||||
<TableHeaderCell>Id</TableHeaderCell>
|
||||
<TableHeaderCell>Aktionen</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((template) => (
|
||||
<TableRow key={template.id}>
|
||||
<TableCell>{template.name}</TableCell>
|
||||
<TableCell>{template.version}</TableCell>
|
||||
<TableCell>{template.cloudTemplate ? "Ja" : "Nein"}</TableCell>
|
||||
<TableCell>{template.templateCategoryId}</TableCell>
|
||||
<TableCell>{template.id}</TableCell>
|
||||
<TableCell>
|
||||
<div className={styles.actions}>
|
||||
<Tooltip content="Details" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Details"
|
||||
icon={<OpenRegular />}
|
||||
onClick={() => openTemplateDialog("details", template)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Aendern" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Aendern"
|
||||
icon={<EditRegular />}
|
||||
onClick={() => openTemplateDialog("edit", template)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Loeschen" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Loeschen"
|
||||
disabled={deleteTemplate.isPending}
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Template "${template.name}" wirklich loeschen?`)) {
|
||||
deleteTemplate.mutate(template.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{groupedTemplates.map((group) => (
|
||||
<Fragment key={`group-${group.serviceId}`}>
|
||||
<TableRow className={styles.groupRow} onClick={() => toggleServiceGroup(group.serviceId)}>
|
||||
<TableCell colSpan={5}>
|
||||
<div className={styles.groupHeader}>
|
||||
{expandedServiceGroups[group.serviceId] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||
<strong>{group.serviceName}</strong>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expandedServiceGroups[group.serviceId] !== false && group.categories.map((categoryGroup) => (
|
||||
<Fragment key={`group-${group.serviceId}-${categoryGroup.categoryId}`}>
|
||||
<TableRow
|
||||
className={styles.groupRow}
|
||||
onClick={() => toggleCategoryGroup(group.serviceId, categoryGroup.categoryId)}
|
||||
>
|
||||
<TableCell className={styles.categoryHeaderCell} colSpan={5}>
|
||||
<div className={styles.groupHeader}>
|
||||
{expandedCategoryGroups[`${group.serviceId}:${categoryGroup.categoryId}`] !== false ? (
|
||||
<ChevronDownRegular />
|
||||
) : (
|
||||
<ChevronRightRegular />
|
||||
)}
|
||||
<strong>{categoryGroup.categoryName}</strong>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expandedCategoryGroups[`${group.serviceId}:${categoryGroup.categoryId}`] !== false &&
|
||||
categoryGroup.items.map((template) => (
|
||||
<TableRow key={template.id}>
|
||||
<TableCell className={styles.nestedLevel2Cell}>{template.name}</TableCell>
|
||||
<TableCell>{template.version}</TableCell>
|
||||
<TableCell>
|
||||
{template.templateCategoryId ? categoryNameById.get(template.templateCategoryId) ?? template.templateCategoryId : ""}
|
||||
</TableCell>
|
||||
<TableCell>{template.id}</TableCell>
|
||||
<TableCell>
|
||||
<div className={styles.actions}>
|
||||
<Tooltip content="Details" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Details"
|
||||
icon={<OpenRegular />}
|
||||
onClick={() => openTemplateDialog("details", template)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Aendern" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Aendern"
|
||||
icon={<EditRegular />}
|
||||
onClick={() => openTemplateDialog("edit", template)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Loeschen" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label="Loeschen"
|
||||
disabled={deleteTemplate.isPending}
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Template "${template.name}" wirklich loeschen?`)) {
|
||||
deleteTemplate.mutate(template.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
102
src/pages/VirtualMachineDetailsPage.tsx
Normal file
102
src/pages/VirtualMachineDetailsPage.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
401
src/pages/VirtualMachinesPage.tsx
Normal file
401
src/pages/VirtualMachinesPage.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,89 @@
|
||||
export type Deployment = {
|
||||
export type DeploymentExecution = {
|
||||
id: string;
|
||||
deploymentGroupId?: string;
|
||||
deploymentBatchId?: string;
|
||||
virtualMachineId?: string;
|
||||
status?: string;
|
||||
jsonData?: string;
|
||||
};
|
||||
|
||||
export type AddDeployment = {
|
||||
deploymentGroupId: string;
|
||||
export type AddDeploymentExecution = {
|
||||
deploymentBatchId: string;
|
||||
virtualMachineId: string;
|
||||
status: 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;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type AddDeploymentGroup = {
|
||||
export type DeploymentBatchDetails = DeploymentBatch & {
|
||||
deployments?: DeploymentExecution[];
|
||||
};
|
||||
|
||||
export type AddDeploymentBatch = {
|
||||
templateId: string;
|
||||
status: string;
|
||||
virtualMachineIds?: string[];
|
||||
};
|
||||
|
||||
export type Domain = {
|
||||
@@ -33,6 +99,10 @@ export type DomainWithEnvironments = Domain & {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type DomainWithVirtualMachines = Domain & {
|
||||
virtualMachines?: VirtualMachine[];
|
||||
};
|
||||
|
||||
export type AddDomain = {
|
||||
name: string;
|
||||
fqdn: string;
|
||||
@@ -42,6 +112,11 @@ export type AddDomain = {
|
||||
export type EnvironmentItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
environmentType?: string;
|
||||
providerType?: string;
|
||||
tenantId?: string;
|
||||
subscriptionId?: string;
|
||||
metadataJson?: string;
|
||||
};
|
||||
|
||||
export type EnvironmentWithDomains = EnvironmentItem & {
|
||||
@@ -52,6 +127,11 @@ export type EnvironmentWithDomains = EnvironmentItem & {
|
||||
|
||||
export type AddEnvironment = {
|
||||
name: string;
|
||||
environmentType: string;
|
||||
providerType?: string;
|
||||
tenantId?: string;
|
||||
subscriptionId?: string;
|
||||
metadataJson?: string;
|
||||
};
|
||||
|
||||
export type Runbook = {
|
||||
@@ -69,7 +149,6 @@ export type Template = {
|
||||
id: string;
|
||||
name: string;
|
||||
templateCategoryId?: string;
|
||||
cloudTemplate?: boolean;
|
||||
version?: string;
|
||||
description?: string;
|
||||
jsonData?: string;
|
||||
@@ -79,7 +158,6 @@ export type Template = {
|
||||
export type AddTemplate = {
|
||||
templateCategoryId: string;
|
||||
name: string;
|
||||
cloudTemplate: boolean;
|
||||
version: string;
|
||||
description: string;
|
||||
jsonData: string;
|
||||
@@ -89,9 +167,59 @@ export type ServiceItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
isCloudService?: boolean;
|
||||
iconKey?: string;
|
||||
};
|
||||
|
||||
export type AddService = {
|
||||
name: 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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user