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 { deleteJson, getJson, postJson, putJson } from "./httpClient";
|
||||||
import type {
|
import type {
|
||||||
AddDeployment,
|
AddDeploymentExecution,
|
||||||
AddDeploymentGroup,
|
AddDeploymentRequest,
|
||||||
|
AddDeploymentBatch,
|
||||||
AddDomain,
|
AddDomain,
|
||||||
AddEnvironment,
|
AddEnvironment,
|
||||||
AddRunbook,
|
AddRunbook,
|
||||||
AddService,
|
AddService,
|
||||||
|
AddServiceRoleDefinition,
|
||||||
|
AddTemplateCategory,
|
||||||
AddTemplate,
|
AddTemplate,
|
||||||
Deployment,
|
DeploymentExecution,
|
||||||
DeploymentGroup,
|
DeploymentBatch,
|
||||||
|
DeploymentBatchDetails,
|
||||||
Domain,
|
Domain,
|
||||||
DomainWithEnvironments,
|
DomainWithEnvironments,
|
||||||
|
DomainWithVirtualMachines,
|
||||||
EnvironmentItem,
|
EnvironmentItem,
|
||||||
EnvironmentWithDomains,
|
EnvironmentWithDomains,
|
||||||
Runbook,
|
Runbook,
|
||||||
ServiceItem,
|
ServiceItem,
|
||||||
|
ServiceRoleDefinition,
|
||||||
|
TemplateCategory,
|
||||||
Template,
|
Template,
|
||||||
|
VirtualMachine,
|
||||||
|
AddVirtualMachine,
|
||||||
|
DeploymentJob,
|
||||||
|
DeploymentJobDetails,
|
||||||
} from "../types/portal";
|
} from "../types/portal";
|
||||||
|
|
||||||
export const portalApi = {
|
export const portalApi = {
|
||||||
getDeployments: (signal?: AbortSignal) => getJson<Deployment[]>("/Deployment", signal),
|
getDeployments: (signal?: AbortSignal) => getJson<DeploymentExecution[]>("/Deployment", signal),
|
||||||
addDeployment: (deployment: AddDeployment) => postJson<AddDeployment, string>("/Deployment", deployment),
|
addDeployment: (deployment: AddDeploymentExecution) => postJson<AddDeploymentExecution, string>("/Deployment", deployment),
|
||||||
getDeploymentGroups: (signal?: AbortSignal) => getJson<DeploymentGroup[]>("/DeploymentGroup", signal),
|
addDeploymentRequest: (request: AddDeploymentRequest) => postJson<AddDeploymentRequest, string>("/Deployment/Request", request),
|
||||||
addDeploymentGroup: (deploymentGroup: AddDeploymentGroup) => postJson<AddDeploymentGroup, string>("/DeploymentGroup", deploymentGroup),
|
getDeploymentJobs: (signal?: AbortSignal) => getJson<DeploymentJob[]>("/Deployment/QueueJobs", signal),
|
||||||
|
getDeploymentJobById: (deploymentJobId: string, signal?: AbortSignal) =>
|
||||||
|
getJson<DeploymentJobDetails>(`/Deployment/QueueJobs/${deploymentJobId}`, signal),
|
||||||
|
retryDeploymentJob: (deploymentJobId: string) => postJson<undefined, void>(`/Deployment/QueueJobs/${deploymentJobId}/Retry`, undefined),
|
||||||
|
approveDeploymentJobStep: (stepId: string, comment?: string) =>
|
||||||
|
postJson<{ comment?: string }, void>(`/Deployment/QueueJobs/Steps/${stepId}/Approve`, { comment }),
|
||||||
|
rejectDeploymentJobStep: (stepId: string, comment?: string) =>
|
||||||
|
postJson<{ comment?: string }, void>(`/Deployment/QueueJobs/Steps/${stepId}/Reject`, { comment }),
|
||||||
|
getDeploymentBatches: (signal?: AbortSignal) => getJson<DeploymentBatch[]>("/DeploymentGroup", signal),
|
||||||
|
getDeploymentBatchById: (deploymentBatchId: string, signal?: AbortSignal) =>
|
||||||
|
getJson<DeploymentBatchDetails>(`/DeploymentGroup/${deploymentBatchId}`, signal),
|
||||||
|
addDeploymentBatch: (deploymentBatch: AddDeploymentBatch) => postJson<AddDeploymentBatch, string>("/DeploymentGroup", deploymentBatch),
|
||||||
getDomains: (signal?: AbortSignal) => getJson<Domain[]>("/Domain", signal),
|
getDomains: (signal?: AbortSignal) => getJson<Domain[]>("/Domain", signal),
|
||||||
getDomainEnvironments: (domainId: string, signal?: AbortSignal) => getJson<DomainWithEnvironments>(`/Domain/${domainId}/Environments`, signal),
|
getDomainEnvironments: (domainId: string, signal?: AbortSignal) => getJson<DomainWithEnvironments>(`/Domain/${domainId}/Environments`, signal),
|
||||||
|
getDomainVirtualMachines: (domainId: string, signal?: AbortSignal) => getJson<DomainWithVirtualMachines>(`/Domain/${domainId}/VirtualMachines`, signal),
|
||||||
addDomain: (domain: AddDomain) => postJson<AddDomain, string>("/Domain", domain),
|
addDomain: (domain: AddDomain) => postJson<AddDomain, string>("/Domain", domain),
|
||||||
updateDomain: (domainId: string, domain: AddDomain) => putJson<AddDomain, void>(`/Domain/${domainId}`, domain),
|
updateDomain: (domainId: string, domain: AddDomain) => putJson<AddDomain, void>(`/Domain/${domainId}`, domain),
|
||||||
deleteDomain: (domainId: string) => deleteJson<void>(`/Domain/${domainId}`),
|
deleteDomain: (domainId: string) => deleteJson<void>(`/Domain/${domainId}`),
|
||||||
@@ -44,4 +67,26 @@ export const portalApi = {
|
|||||||
addService: (service: AddService) => postJson<AddService, string>("/Service", service),
|
addService: (service: AddService) => postJson<AddService, string>("/Service", service),
|
||||||
updateService: (serviceId: string, service: AddService) => putJson<AddService, void>(`/Service/${serviceId}`, service),
|
updateService: (serviceId: string, service: AddService) => putJson<AddService, void>(`/Service/${serviceId}`, service),
|
||||||
deleteService: (serviceId: string) => deleteJson<void>(`/Service/${serviceId}`),
|
deleteService: (serviceId: string) => deleteJson<void>(`/Service/${serviceId}`),
|
||||||
|
getServiceRoleDefinitions: (serviceId: string, signal?: AbortSignal) =>
|
||||||
|
getJson<ServiceRoleDefinition[]>(`/Service/${serviceId}/RoleDefinitions`, signal),
|
||||||
|
addServiceRoleDefinition: (serviceId: string, roleDefinition: AddServiceRoleDefinition) =>
|
||||||
|
postJson<AddServiceRoleDefinition, string>(`/Service/${serviceId}/RoleDefinitions`, roleDefinition),
|
||||||
|
updateServiceRoleDefinition: (serviceId: string, roleDefinitionId: string, roleDefinition: AddServiceRoleDefinition) =>
|
||||||
|
putJson<AddServiceRoleDefinition, void>(`/Service/${serviceId}/RoleDefinitions/${roleDefinitionId}`, roleDefinition),
|
||||||
|
deleteServiceRoleDefinition: (serviceId: string, roleDefinitionId: string) =>
|
||||||
|
deleteJson<void>(`/Service/${serviceId}/RoleDefinitions/${roleDefinitionId}`),
|
||||||
|
getTemplateCategories: (signal?: AbortSignal) => getJson<TemplateCategory[]>("/TemplateCategory", signal),
|
||||||
|
addTemplateCategory: (templateCategory: AddTemplateCategory) => postJson<AddTemplateCategory, string>("/TemplateCategory", templateCategory),
|
||||||
|
updateTemplateCategory: (templateCategoryId: string, templateCategory: AddTemplateCategory) =>
|
||||||
|
putJson<AddTemplateCategory, void>(`/TemplateCategory/${templateCategoryId}`, templateCategory),
|
||||||
|
deleteTemplateCategory: (templateCategoryId: string) => deleteJson<void>(`/TemplateCategory/${templateCategoryId}`),
|
||||||
|
getVirtualMachines: (signal?: AbortSignal) => getJson<VirtualMachine[]>("/VirtualMachine", signal),
|
||||||
|
getVirtualMachineById: (virtualMachineId: string, signal?: AbortSignal) => getJson<VirtualMachine>(`/VirtualMachine/${virtualMachineId}`, signal),
|
||||||
|
addVirtualMachine: (virtualMachine: AddVirtualMachine) => postJson<AddVirtualMachine, string>("/VirtualMachine", virtualMachine),
|
||||||
|
updateVirtualMachine: (virtualMachineId: string, virtualMachine: AddVirtualMachine) =>
|
||||||
|
putJson<AddVirtualMachine, void>(`/VirtualMachine/${virtualMachineId}`, virtualMachine),
|
||||||
|
deleteVirtualMachine: (virtualMachineId: string) => deleteJson<void>(`/VirtualMachine/${virtualMachineId}`),
|
||||||
|
linkVirtualMachineToDomain: (virtualMachineId: string, domainId: string) =>
|
||||||
|
postJson<undefined, void>(`/VirtualMachine/${virtualMachineId}/Domain/${domainId}`, undefined),
|
||||||
|
unlinkVirtualMachineFromDomain: (virtualMachineId: string) => deleteJson<void>(`/VirtualMachine/${virtualMachineId}/Domain`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,13 +10,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
AppsListDetail24Regular,
|
AppsListDetail24Regular,
|
||||||
BoxMultiple24Regular,
|
BoxMultiple24Regular,
|
||||||
CloudFlow24Regular,
|
|
||||||
DatabasePlugConnectedRegular,
|
DatabasePlugConnectedRegular,
|
||||||
LinkMultiple24Regular,
|
|
||||||
Globe24Regular,
|
Globe24Regular,
|
||||||
Home24Regular,
|
Home24Regular,
|
||||||
PlayCircle24Regular,
|
TagMultiple24Regular,
|
||||||
ServerMultipleRegular,
|
Desktop24Regular,
|
||||||
} from "@fluentui/react-icons";
|
} from "@fluentui/react-icons";
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
@@ -72,14 +70,15 @@ const useStyles = makeStyles({
|
|||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ to: "/", label: "Dashboard", icon: <Home24Regular /> },
|
{ to: "/", label: "Dashboard", icon: <Home24Regular /> },
|
||||||
//{ to: "/deployments", label: "Deployments", icon: <CloudFlow24Regular /> },
|
{ to: "/deployments", label: "Deployments", icon: <BoxMultiple24Regular /> },
|
||||||
//{ to: "/deployment-groups", label: "Deployment Groups", icon: <ServerMultipleRegular /> },
|
{ to: "/worker-jobs", label: "Worker Jobs", icon: <AppsListDetail24Regular /> },
|
||||||
{ to: "/domains", label: "Domains", icon: <Globe24Regular /> },
|
{ to: "/domains", label: "Domains", icon: <Globe24Regular /> },
|
||||||
{ to: "/environment-domains", label: "Environment Domains", icon: <LinkMultiple24Regular /> },
|
|
||||||
{ to: "/environments", label: "Environments", icon: <DatabasePlugConnectedRegular /> },
|
{ to: "/environments", label: "Environments", icon: <DatabasePlugConnectedRegular /> },
|
||||||
//{ to: "/runbooks", label: "Runbooks", icon: <PlayCircle24Regular /> },
|
//{ to: "/runbooks", label: "Runbooks", icon: <PlayCircle24Regular /> },
|
||||||
{ to: "/templates", label: "Templates", icon: <BoxMultiple24Regular /> },
|
{ to: "/templates", label: "Templates", icon: <BoxMultiple24Regular /> },
|
||||||
|
{ to: "/template-categories", label: "Template Categories", icon: <TagMultiple24Regular /> },
|
||||||
{ to: "/services", label: "Services", icon: <AppsListDetail24Regular /> },
|
{ to: "/services", label: "Services", icon: <AppsListDetail24Regular /> },
|
||||||
|
{ to: "/virtual-machines", label: "Virtual Machines", icon: <Desktop24Regular /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
|
|||||||
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 { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||||
import { AppShell } from "./layout/AppShell";
|
import { AppShell } from "./layout/AppShell";
|
||||||
import { DashboardPage } from "./pages/DashboardPage";
|
import { DashboardPage } from "./pages/DashboardPage";
|
||||||
import { DeploymentsPage } from "./pages/DeploymentsPage";
|
import { DeploymentBatchDetailsPage } from "./pages/DeploymentBatchDetailsPage";
|
||||||
|
import { DeploymentJobDetailsPage } from "./pages/DeploymentJobDetailsPage";
|
||||||
|
import { DeploymentJobsPage } from "./pages/DeploymentJobsPage";
|
||||||
import { DeploymentGroupsPage } from "./pages/DeploymentGroupsPage";
|
import { DeploymentGroupsPage } from "./pages/DeploymentGroupsPage";
|
||||||
import { DomainDetailsPage } from "./pages/DomainDetailsPage";
|
import { DomainDetailsPage } from "./pages/DomainDetailsPage";
|
||||||
import { DomainsPage } from "./pages/DomainsPage";
|
import { DomainsPage } from "./pages/DomainsPage";
|
||||||
import { EnvironmentDomainsPage } from "./pages/EnvironmentDomainsPage";
|
|
||||||
import { EnvironmentDetailsPage } from "./pages/EnvironmentDetailsPage";
|
import { EnvironmentDetailsPage } from "./pages/EnvironmentDetailsPage";
|
||||||
import { EnvironmentsPage } from "./pages/EnvironmentsPage";
|
import { EnvironmentsPage } from "./pages/EnvironmentsPage";
|
||||||
import { RunbooksPage } from "./pages/RunbooksPage";
|
|
||||||
import { TemplatesPage } from "./pages/TemplatesPage";
|
import { TemplatesPage } from "./pages/TemplatesPage";
|
||||||
import { ServicesPage } from "./pages/ServicesPage";
|
import { ServicesPage } from "./pages/ServicesPage";
|
||||||
|
import { TemplateCategoriesPage } from "./pages/TemplateCategoriesPage";
|
||||||
|
import { VirtualMachineDetailsPage } from "./pages/VirtualMachineDetailsPage";
|
||||||
|
import { VirtualMachinesPage } from "./pages/VirtualMachinesPage";
|
||||||
import "./styles/global.css";
|
import "./styles/global.css";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -32,16 +35,19 @@ const router = createBrowserRouter([
|
|||||||
element: <AppShell />,
|
element: <AppShell />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <DashboardPage /> },
|
{ index: true, element: <DashboardPage /> },
|
||||||
{ path: "deployments", element: <DeploymentsPage /> },
|
{ path: "deployments", element: <DeploymentGroupsPage /> },
|
||||||
{ path: "deployment-groups", element: <DeploymentGroupsPage /> },
|
{ path: "deployments/:id", element: <DeploymentBatchDetailsPage /> },
|
||||||
|
{ path: "worker-jobs", element: <DeploymentJobsPage /> },
|
||||||
|
{ path: "worker-jobs/:id", element: <DeploymentJobDetailsPage /> },
|
||||||
{ path: "domains", element: <DomainsPage /> },
|
{ path: "domains", element: <DomainsPage /> },
|
||||||
{ path: "domains/:id", element: <DomainDetailsPage /> },
|
{ path: "domains/:id", element: <DomainDetailsPage /> },
|
||||||
{ path: "environment-domains", element: <EnvironmentDomainsPage /> },
|
|
||||||
{ path: "environments", element: <EnvironmentsPage /> },
|
{ path: "environments", element: <EnvironmentsPage /> },
|
||||||
{ path: "environments/:id", element: <EnvironmentDetailsPage /> },
|
{ path: "environments/:id", element: <EnvironmentDetailsPage /> },
|
||||||
{ path: "runbooks", element: <RunbooksPage /> },
|
|
||||||
{ path: "templates", element: <TemplatesPage /> },
|
{ path: "templates", element: <TemplatesPage /> },
|
||||||
|
{ path: "template-categories", element: <TemplateCategoriesPage /> },
|
||||||
{ path: "services", element: <ServicesPage /> },
|
{ path: "services", element: <ServicesPage /> },
|
||||||
|
{ path: "virtual-machines", element: <VirtualMachinesPage /> },
|
||||||
|
{ path: "virtual-machines/:id", element: <VirtualMachineDetailsPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
|
Divider,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
Text,
|
Text,
|
||||||
tokens,
|
tokens,
|
||||||
@@ -16,11 +17,43 @@ const useStyles = makeStyles({
|
|||||||
gridTemplateColumns: "repeat(4, minmax(160px, 1fr))",
|
gridTemplateColumns: "repeat(4, minmax(160px, 1fr))",
|
||||||
gap: "16px",
|
gap: "16px",
|
||||||
},
|
},
|
||||||
|
chartGrid: {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(2, minmax(280px, 1fr))",
|
||||||
|
gap: "16px",
|
||||||
|
marginTop: "16px",
|
||||||
|
},
|
||||||
metric: {
|
metric: {
|
||||||
fontSize: "32px",
|
fontSize: "32px",
|
||||||
fontWeight: tokens.fontWeightSemibold,
|
fontWeight: tokens.fontWeightSemibold,
|
||||||
lineHeight: "40px",
|
lineHeight: "40px",
|
||||||
},
|
},
|
||||||
|
donutWrap: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "16px",
|
||||||
|
},
|
||||||
|
donut: {
|
||||||
|
width: "120px",
|
||||||
|
height: "120px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
display: "grid",
|
||||||
|
gap: "8px",
|
||||||
|
},
|
||||||
|
legendRow: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
},
|
||||||
|
swatch: {
|
||||||
|
width: "10px",
|
||||||
|
height: "10px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "inline-block",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
@@ -41,10 +74,46 @@ export function DashboardPage() {
|
|||||||
queryKey: ["services"],
|
queryKey: ["services"],
|
||||||
queryFn: ({ signal }) => portalApi.getServices(signal),
|
queryFn: ({ signal }) => portalApi.getServices(signal),
|
||||||
});
|
});
|
||||||
|
const queueJobs = useQuery({
|
||||||
|
queryKey: ["queue-jobs"],
|
||||||
|
queryFn: ({ signal }) => portalApi.getDeploymentJobs(signal),
|
||||||
|
});
|
||||||
|
const virtualMachines = useQuery({
|
||||||
|
queryKey: ["virtual-machines"],
|
||||||
|
queryFn: ({ signal }) => portalApi.getVirtualMachines(signal),
|
||||||
|
});
|
||||||
|
|
||||||
const error = domains.error ?? environments.error ?? templates.error ?? services.error;
|
const error =
|
||||||
|
domains.error ??
|
||||||
|
environments.error ??
|
||||||
|
templates.error ??
|
||||||
|
services.error ??
|
||||||
|
queueJobs.error ??
|
||||||
|
virtualMachines.error;
|
||||||
const isLoading =
|
const isLoading =
|
||||||
domains.isLoading || environments.isLoading || templates.isLoading || services.isLoading;
|
domains.isLoading ||
|
||||||
|
environments.isLoading ||
|
||||||
|
templates.isLoading ||
|
||||||
|
services.isLoading ||
|
||||||
|
queueJobs.isLoading ||
|
||||||
|
virtualMachines.isLoading;
|
||||||
|
|
||||||
|
const queueByStatus = (queueJobs.data ?? []).reduce<Record<string, number>>((acc, job) => {
|
||||||
|
const key = job.status || "Unknown";
|
||||||
|
acc[key] = (acc[key] ?? 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const vmCompliance = (virtualMachines.data ?? []).reduce(
|
||||||
|
(acc, vm) => {
|
||||||
|
const compliance = getVmCompliance(vm.metadataJson);
|
||||||
|
if (compliance === true) acc.compliant += 1;
|
||||||
|
else if (compliance === false) acc.nonCompliant += 1;
|
||||||
|
else acc.unknown += 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ compliant: 0, nonCompliant: 0, unknown: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -58,6 +127,26 @@ export function DashboardPage() {
|
|||||||
<MetricCard label="Services" value={services.data?.length ?? 0} />
|
<MetricCard label="Services" value={services.data?.length ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className={styles.chartGrid}>
|
||||||
|
<DonutCard
|
||||||
|
title="Jobs"
|
||||||
|
items={Object.entries(queueByStatus).map(([label, value], index) => ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color: chartColors[index % chartColors.length],
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<DonutCard
|
||||||
|
title="VM DSC Compliance"
|
||||||
|
items={[
|
||||||
|
{ label: "Compliant", value: vmCompliance.compliant, color: "#107C10" },
|
||||||
|
{ label: "Non-Compliant", value: vmCompliance.nonCompliant, color: "#D13438" },
|
||||||
|
{ label: "Unknown", value: vmCompliance.unknown, color: "#8A8886" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -72,3 +161,64 @@ function MetricCard({ label, value }: { label: string; value: number }) {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chartColors = ["#0F6CBD", "#107C10", "#D13438", "#8764B8", "#CA5010", "#605E5C"];
|
||||||
|
|
||||||
|
function DonutCard({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
items: Array<{ label: string; value: number; color: string }>;
|
||||||
|
}) {
|
||||||
|
const styles = useStyles();
|
||||||
|
const total = items.reduce((sum, item) => sum + item.value, 0);
|
||||||
|
const segments = total
|
||||||
|
? items
|
||||||
|
.map((item) => `${item.color} ${(item.value / total) * 100}%`)
|
||||||
|
.join(", ")
|
||||||
|
: "#E1DFDD 100%";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader header={<Text weight="semibold">{title}</Text>} />
|
||||||
|
<Divider />
|
||||||
|
<div className={styles.donutWrap}>
|
||||||
|
<div className={styles.donut} style={{ background: `conic-gradient(${segments})` }} />
|
||||||
|
<div className={styles.legend}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.label} className={styles.legendRow}>
|
||||||
|
<span className={styles.swatch} style={{ backgroundColor: item.color }} />
|
||||||
|
<Text>
|
||||||
|
{item.label}: {item.value}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Text>Total: {total}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVmCompliance(metadataJson?: string): boolean | undefined {
|
||||||
|
if (!metadataJson) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(metadataJson) as Record<string, unknown>;
|
||||||
|
const keys = ["dscCompliant", "isDscCompliant", "compliant"];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = parsed[key];
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|||||||
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Combobox,
|
||||||
Field,
|
Field,
|
||||||
Input,
|
makeStyles,
|
||||||
|
Option,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableHeaderCell,
|
TableHeaderCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
Tooltip,
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
|
import { OpenRegular } from "@fluentui/react-icons";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { portalApi } from "../api/portalApi";
|
import { portalApi } from "../api/portalApi";
|
||||||
import { DataState } from "../components/DataState";
|
import { DataState } from "../components/DataState";
|
||||||
import { FormActions, FormGrid, FormSection } from "../components/FormSection";
|
import { FormActions, FormGrid, FormSection } from "../components/FormSection";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
actions: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "6px",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export function DeploymentGroupsPage() {
|
export function DeploymentGroupsPage() {
|
||||||
|
const styles = useStyles();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [serviceId, setServiceId] = useState("");
|
||||||
const [templateId, setTemplateId] = useState("");
|
const [templateId, setTemplateId] = useState("");
|
||||||
|
const [virtualMachineIds, setVirtualMachineIds] = useState<string[]>([]);
|
||||||
const [status, setStatus] = useState("New");
|
const [status, setStatus] = useState("New");
|
||||||
const { data, error, isLoading } = useQuery({
|
const { data, error, isLoading } = useQuery({
|
||||||
queryKey: ["deploymentGroups"],
|
queryKey: ["deployment-batches"],
|
||||||
queryFn: ({ signal }) => portalApi.getDeploymentGroups(signal),
|
queryFn: ({ signal }) => portalApi.getDeploymentBatches(signal),
|
||||||
});
|
});
|
||||||
const addDeploymentGroup = useMutation({
|
const { data: services } = useQuery({
|
||||||
mutationFn: portalApi.addDeploymentGroup,
|
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 () => {
|
onSuccess: async () => {
|
||||||
|
setServiceId("");
|
||||||
setTemplateId("");
|
setTemplateId("");
|
||||||
|
setVirtualMachineIds([]);
|
||||||
setStatus("New");
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Deployment Groups" description="Gruppen von Bereitstellungen je Template." />
|
<PageHeader title="Deployments" description="Deployment Batches und deren Ausfuehrungen." />
|
||||||
<FormSection
|
<FormSection
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
addDeploymentGroup.mutate({ status, templateId });
|
addDeploymentBatch.mutate({
|
||||||
|
status,
|
||||||
|
templateId,
|
||||||
|
virtualMachineIds: isOnPremService ? virtualMachineIds : undefined,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label="Template Id" required>
|
<Field label="Service" required>
|
||||||
<Input value={templateId} onChange={(_, data) => setTemplateId(data.value)} />
|
<Combobox
|
||||||
|
placeholder="Service waehlen"
|
||||||
|
value={services?.find((service) => service.id === serviceId)?.name ?? ""}
|
||||||
|
onOptionSelect={(_, data) => {
|
||||||
|
const nextServiceId = data.optionValue ?? "";
|
||||||
|
setServiceId(nextServiceId);
|
||||||
|
setTemplateId("");
|
||||||
|
setVirtualMachineIds([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(services ?? []).map((service) => (
|
||||||
|
<Option key={service.id} text={service.name} value={service.id}>
|
||||||
|
{service.name}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Combobox>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Status" required validationMessage={addDeploymentGroup.error?.message}>
|
<Field label="Template" required>
|
||||||
<Input value={status} onChange={(_, data) => setStatus(data.value)} />
|
<Combobox
|
||||||
|
placeholder={serviceId ? "Template waehlen" : "Erst Service waehlen"}
|
||||||
|
disabled={!serviceId}
|
||||||
|
value={templates?.find((template) => template.id === templateId)?.name ?? ""}
|
||||||
|
onOptionSelect={(_, data) => setTemplateId(data.optionValue ?? "")}
|
||||||
|
>
|
||||||
|
{(templates ?? [])
|
||||||
|
.filter((template) => {
|
||||||
|
const category = (templateCategories ?? []).find((entry) => entry.id === template.templateCategoryId);
|
||||||
|
return category?.serviceId === serviceId;
|
||||||
|
})
|
||||||
|
.map((template) => (
|
||||||
|
<Option key={template.id} text={template.name} value={template.id}>
|
||||||
|
{template.name}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Combobox>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Status" required validationMessage={addDeploymentBatch.error?.message}>
|
||||||
|
<Combobox value={status} onOptionSelect={(_, data) => setStatus(data.optionValue ?? "New")}>
|
||||||
|
<Option value="New">New</Option>
|
||||||
|
<Option value="Pending">Pending</Option>
|
||||||
|
<Option value="Running">Running</Option>
|
||||||
|
<Option value="Succeeded">Succeeded</Option>
|
||||||
|
<Option value="Failed">Failed</Option>
|
||||||
|
</Combobox>
|
||||||
|
</Field>
|
||||||
|
{isOnPremService && (
|
||||||
|
<Field label="Virtual Machines" required>
|
||||||
|
<div>
|
||||||
|
{(virtualMachines ?? []).map((virtualMachine) => (
|
||||||
|
<Checkbox
|
||||||
|
key={virtualMachine.id}
|
||||||
|
label={virtualMachine.name}
|
||||||
|
checked={virtualMachineIds.includes(virtualMachine.id)}
|
||||||
|
onChange={(_, data) => {
|
||||||
|
if (data.checked) {
|
||||||
|
setVirtualMachineIds((prev) => [...prev, virtualMachine.id]);
|
||||||
|
} else {
|
||||||
|
setVirtualMachineIds((prev) => prev.filter((id) => id !== virtualMachine.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
<FormActions>
|
<FormActions>
|
||||||
<Button
|
<Button
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
disabled={!templateId || !status || addDeploymentGroup.isPending}
|
disabled={!templateId || !status || (isOnPremService && virtualMachineIds.length === 0) || addDeploymentBatch.isPending}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
Add deployment group
|
Add deployment batch
|
||||||
</Button>
|
</Button>
|
||||||
</FormActions>
|
</FormActions>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
<DataState isLoading={isLoading} error={error} />
|
<DataState isLoading={isLoading} error={error} />
|
||||||
{data && (
|
{data && (
|
||||||
<Table aria-label="Deployment groups">
|
<Table aria-label="Deployment batches">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHeaderCell>Status</TableHeaderCell>
|
<TableHeaderCell>Status</TableHeaderCell>
|
||||||
<TableHeaderCell>Id</TableHeaderCell>
|
<TableHeaderCell>Id</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Actions</TableHeaderCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.map((deploymentGroup) => (
|
{data.map((deploymentBatch) => (
|
||||||
<TableRow key={deploymentGroup.id}>
|
<TableRow key={deploymentBatch.id}>
|
||||||
<TableCell>{deploymentGroup.status ?? "Unknown"}</TableCell>
|
<TableCell>{deploymentBatch.status ?? "Unknown"}</TableCell>
|
||||||
<TableCell>{deploymentGroup.id}</TableCell>
|
<TableCell>{deploymentBatch.id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Tooltip content="Details" relationship="label">
|
||||||
|
<Link to={`/deployments/${deploymentBatch.id}`}>
|
||||||
|
<Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} />
|
||||||
|
</Link>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
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 { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Combobox,
|
||||||
Field,
|
Field,
|
||||||
Input,
|
Option,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
@@ -20,43 +22,83 @@ import { PageHeader } from "../components/PageHeader";
|
|||||||
|
|
||||||
export function DeploymentsPage() {
|
export function DeploymentsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [deploymentGroupId, setDeploymentGroupId] = useState("");
|
const [deploymentBatchId, setDeploymentBatchId] = useState("");
|
||||||
const [virtualMachineId, setVirtualMachineId] = useState("");
|
const [selectedVirtualMachineIds, setSelectedVirtualMachineIds] = useState<string[]>([]);
|
||||||
const [status, setStatus] = useState("New");
|
|
||||||
const [jsonData, setJsonData] = useState("{}");
|
const [jsonData, setJsonData] = useState("{}");
|
||||||
const { data, error, isLoading } = useQuery({
|
const { data, error, isLoading } = useQuery({
|
||||||
queryKey: ["deployments"],
|
queryKey: ["deployments"],
|
||||||
queryFn: ({ signal }) => portalApi.getDeployments(signal),
|
queryFn: ({ signal }) => portalApi.getDeployments(signal),
|
||||||
});
|
});
|
||||||
const addDeployment = useMutation({
|
const { data: deploymentBatches } = useQuery({
|
||||||
mutationFn: portalApi.addDeployment,
|
queryKey: ["deployment-batches"],
|
||||||
|
queryFn: ({ signal }) => portalApi.getDeploymentBatches(signal),
|
||||||
|
});
|
||||||
|
const { data: virtualMachines } = useQuery({
|
||||||
|
queryKey: ["virtual-machines"],
|
||||||
|
queryFn: ({ signal }) => portalApi.getVirtualMachines(signal),
|
||||||
|
});
|
||||||
|
const { data: deploymentJobs, error: deploymentJobsError, isLoading: deploymentJobsLoading } = useQuery({
|
||||||
|
queryKey: ["deployment-jobs"],
|
||||||
|
queryFn: ({ signal }) => portalApi.getDeploymentJobs(signal),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
const addDeploymentRequest = useMutation({
|
||||||
|
mutationFn: portalApi.addDeploymentRequest,
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
setDeploymentGroupId("");
|
setDeploymentBatchId("");
|
||||||
setVirtualMachineId("");
|
setSelectedVirtualMachineIds([]);
|
||||||
setStatus("New");
|
|
||||||
setJsonData("{}");
|
setJsonData("{}");
|
||||||
await queryClient.invalidateQueries({ queryKey: ["deployments"] });
|
await queryClient.invalidateQueries({ queryKey: ["deployments"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const retryDeploymentJob = useMutation({
|
||||||
|
mutationFn: portalApi.retryDeploymentJob,
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Deployments" description="Aktuelle Bereitstellungen aus der Core API." />
|
<PageHeader title="Deployment Executions" description="Aktuelle Ausfuehrungen aus der Core API." />
|
||||||
<FormSection
|
<FormSection
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
addDeployment.mutate({ deploymentGroupId, jsonData, status, virtualMachineId });
|
addDeploymentRequest.mutate({ deploymentBatchId, jsonData, virtualMachineIds: selectedVirtualMachineIds });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<Field label="Deployment Group Id" required>
|
<Field label="Deployment Batch" required validationMessage={addDeploymentRequest.error?.message}>
|
||||||
<Input value={deploymentGroupId} onChange={(_, data) => setDeploymentGroupId(data.value)} />
|
<Combobox
|
||||||
|
placeholder="Deployment Batch waehlen"
|
||||||
|
value={deploymentBatches?.find((batch) => batch.id === deploymentBatchId)?.id ?? ""}
|
||||||
|
onOptionSelect={(_, data) => setDeploymentBatchId(data.optionValue ?? "")}
|
||||||
|
>
|
||||||
|
{(deploymentBatches ?? []).map((batch) => (
|
||||||
|
<Option key={batch.id} text={batch.id} value={batch.id}>
|
||||||
|
{batch.id}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Combobox>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Virtual Machine Id" required>
|
<Field label="Targets (Virtual Machines)" required>
|
||||||
<Input value={virtualMachineId} onChange={(_, data) => setVirtualMachineId(data.value)} />
|
<div>
|
||||||
</Field>
|
{(virtualMachines ?? []).map((virtualMachine) => (
|
||||||
<Field label="Status" required validationMessage={addDeployment.error?.message}>
|
<Checkbox
|
||||||
<Input value={status} onChange={(_, data) => setStatus(data.value)} />
|
key={virtualMachine.id}
|
||||||
|
label={virtualMachine.name}
|
||||||
|
checked={selectedVirtualMachineIds.includes(virtualMachine.id)}
|
||||||
|
onChange={(_, data) => {
|
||||||
|
if (data.checked) {
|
||||||
|
setSelectedVirtualMachineIds((previous) => [...previous, virtualMachine.id]);
|
||||||
|
} else {
|
||||||
|
setSelectedVirtualMachineIds((previous) => previous.filter((id) => id !== virtualMachine.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
<FormWide>
|
<FormWide>
|
||||||
<Field label="JSON data" required>
|
<Field label="JSON data" required>
|
||||||
@@ -67,26 +109,65 @@ export function DeploymentsPage() {
|
|||||||
<FormActions>
|
<FormActions>
|
||||||
<Button
|
<Button
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
disabled={!deploymentGroupId || !virtualMachineId || !status || addDeployment.isPending}
|
disabled={!deploymentBatchId || selectedVirtualMachineIds.length === 0 || addDeploymentRequest.isPending}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
Add deployment
|
Start deployment
|
||||||
</Button>
|
</Button>
|
||||||
</FormActions>
|
</FormActions>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
<DataState isLoading={isLoading} error={error} />
|
<DataState isLoading={isLoading || deploymentJobsLoading} error={error ?? deploymentJobsError} />
|
||||||
|
{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 && (
|
{data && (
|
||||||
<Table aria-label="Deployments">
|
<Table aria-label="Deployment executions">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHeaderCell>Status</TableHeaderCell>
|
<TableHeaderCell>Status</TableHeaderCell>
|
||||||
<TableHeaderCell>Deployment Id</TableHeaderCell>
|
<TableHeaderCell>Deployment Batch</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Virtual Machine</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Execution Id</TableHeaderCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.map((deployment) => (
|
{data.map((deployment) => (
|
||||||
<TableRow key={deployment.id}>
|
<TableRow key={deployment.id}>
|
||||||
<TableCell>{deployment.status ?? "Unknown"}</TableCell>
|
<TableCell>{deployment.status ?? "Unknown"}</TableCell>
|
||||||
|
<TableCell>{deployment.deploymentBatchId ?? "-"}</TableCell>
|
||||||
|
<TableCell>{deployment.virtualMachineId ?? "-"}</TableCell>
|
||||||
<TableCell>{deployment.id}</TableCell>
|
<TableCell>{deployment.id}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
Field,
|
||||||
|
Input,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
MessageBar,
|
MessageBar,
|
||||||
MessageBarBody,
|
MessageBarBody,
|
||||||
@@ -15,8 +17,10 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Title3,
|
Title3,
|
||||||
tokens,
|
tokens,
|
||||||
|
Tooltip,
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import { ArrowLeftRegular } from "@fluentui/react-icons";
|
import { ArrowLeftRegular, OpenRegular } from "@fluentui/react-icons";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { portalApi } from "../api/portalApi";
|
import { portalApi } from "../api/portalApi";
|
||||||
import { DataState } from "../components/DataState";
|
import { DataState } from "../components/DataState";
|
||||||
@@ -47,19 +51,72 @@ const useStyles = makeStyles({
|
|||||||
overflowWrap: "anywhere",
|
overflowWrap: "anywhere",
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
|
marginTop: "20px",
|
||||||
marginBottom: "12px",
|
marginBottom: "12px",
|
||||||
},
|
},
|
||||||
|
actions: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "4px",
|
||||||
|
...shorthands.padding("2px", "0"),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function DomainDetailsPage() {
|
export function DomainDetailsPage() {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [sortBy, setSortBy] = useState("name");
|
||||||
|
const [sortOrder, setSortOrder] = useState("asc");
|
||||||
const { data, error, isLoading } = useQuery({
|
const { data, error, isLoading } = useQuery({
|
||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
queryKey: ["domain", id, "environments"],
|
queryKey: ["domain", id, "environments"],
|
||||||
queryFn: ({ signal }) => portalApi.getDomainEnvironments(id!, signal),
|
queryFn: ({ signal }) => portalApi.getDomainEnvironments(id!, signal),
|
||||||
});
|
});
|
||||||
|
const {
|
||||||
|
data: virtualMachineData,
|
||||||
|
error: virtualMachinesError,
|
||||||
|
isLoading: virtualMachinesLoading,
|
||||||
|
} = useQuery({
|
||||||
|
enabled: Boolean(id),
|
||||||
|
queryKey: ["domain", id, "virtual-machines"],
|
||||||
|
queryFn: ({ signal }) => portalApi.getDomainVirtualMachines(id!, signal),
|
||||||
|
});
|
||||||
const links = data?.environmentDomains?.filter((link) => link.environment) ?? [];
|
const links = data?.environmentDomains?.filter((link) => link.environment) ?? [];
|
||||||
|
const filteredVirtualMachines = useMemo(() => {
|
||||||
|
const items = [...(virtualMachineData?.virtualMachines ?? [])];
|
||||||
|
const searchValue = search.trim().toLowerCase();
|
||||||
|
const filteredItems = searchValue.length
|
||||||
|
? items.filter((virtualMachine) =>
|
||||||
|
[virtualMachine.name, virtualMachine.externalId, virtualMachine.id]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((value) => value!.toLowerCase().includes(searchValue)),
|
||||||
|
)
|
||||||
|
: items;
|
||||||
|
|
||||||
|
filteredItems.sort((a, b) => {
|
||||||
|
const aValue = ((sortBy === "externalId" ? a.externalId : sortBy === "id" ? a.id : a.name) ?? "").toLowerCase();
|
||||||
|
const bValue = ((sortBy === "externalId" ? b.externalId : sortBy === "id" ? b.id : b.name) ?? "").toLowerCase();
|
||||||
|
const comparison = aValue.localeCompare(bValue);
|
||||||
|
return sortOrder === "asc" ? comparison : -comparison;
|
||||||
|
});
|
||||||
|
|
||||||
|
return filteredItems;
|
||||||
|
}, [virtualMachineData?.virtualMachines, search, sortBy, sortOrder]);
|
||||||
|
|
||||||
|
const toggleSort = (column: "name" | "externalId" | "id") => {
|
||||||
|
if (sortBy === column) {
|
||||||
|
setSortOrder((previous) => (previous === "asc" ? "desc" : "asc"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSortBy(column);
|
||||||
|
setSortOrder("asc");
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortIndicator = (column: "name" | "externalId" | "id") => {
|
||||||
|
if (sortBy !== column) return "";
|
||||||
|
return sortOrder === "asc" ? " ↑" : " ↓";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -69,7 +126,7 @@ export function DomainDetailsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<PageHeader title={data?.name ?? "Domain"} description="Details und verknuepfte Environments." />
|
<PageHeader title={data?.name ?? "Domain"} description="Details und verknuepfte Environments." />
|
||||||
<DataState isLoading={isLoading} error={error} />
|
<DataState isLoading={isLoading || virtualMachinesLoading} error={error ?? virtualMachinesError} />
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
<section className={styles.details} aria-label="Domain details">
|
<section className={styles.details} aria-label="Domain details">
|
||||||
@@ -125,13 +182,62 @@ export function DomainDetailsPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link to={`/environments/${link.environment!.id}`}>Oeffnen</Link>
|
<div className={styles.actions}>
|
||||||
|
<Tooltip content="Details" relationship="label">
|
||||||
|
<Link to={`/environments/${link.environment!.id}`}>
|
||||||
|
<Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} />
|
||||||
|
</Link>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Title3 className={styles.sectionTitle}>Virtual Machines</Title3>
|
||||||
|
<div style={{ display: "grid", gap: "12px", gridTemplateColumns: "minmax(220px, 1fr)", marginBottom: "12px" }}>
|
||||||
|
<Field label="Search">
|
||||||
|
<Input
|
||||||
|
placeholder="Name, External Id oder Id"
|
||||||
|
value={search}
|
||||||
|
onChange={(_, inputData) => setSearch(inputData.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{filteredVirtualMachines.length === 0 ? (
|
||||||
|
<MessageBar>
|
||||||
|
<MessageBarBody>Keine Virtual Machines fuer diese Domain gefunden.</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
) : (
|
||||||
|
<Table aria-label="Domain virtual machines">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeaderCell onClick={() => toggleSort("name")} style={{ cursor: "pointer" }}>
|
||||||
|
Name{sortIndicator("name")}
|
||||||
|
</TableHeaderCell>
|
||||||
|
<TableHeaderCell onClick={() => toggleSort("externalId")} style={{ cursor: "pointer" }}>
|
||||||
|
External Id{sortIndicator("externalId")}
|
||||||
|
</TableHeaderCell>
|
||||||
|
<TableHeaderCell onClick={() => toggleSort("id")} style={{ cursor: "pointer" }}>
|
||||||
|
Id{sortIndicator("id")}
|
||||||
|
</TableHeaderCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredVirtualMachines.map((virtualMachine) => (
|
||||||
|
<TableRow key={virtualMachine.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link to={`/virtual-machines/${virtualMachine.id}`}>{virtualMachine.name}</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{virtualMachine.externalId ?? "-"}</TableCell>
|
||||||
|
<TableCell>{virtualMachine.id}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Combobox,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
DialogBody,
|
DialogBody,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
Field,
|
Field,
|
||||||
Input,
|
Input,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
|
Option,
|
||||||
shorthands,
|
shorthands,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -19,7 +21,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons";
|
import { AddRegular, DeleteRegular, EditRegular, OpenRegular, LinkMultiple24Regular } from "@fluentui/react-icons";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { portalApi } from "../api/portalApi";
|
import { portalApi } from "../api/portalApi";
|
||||||
@@ -31,6 +33,7 @@ const useStyles = makeStyles({
|
|||||||
toolbar: {
|
toolbar: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "flex-start",
|
justifyContent: "flex-start",
|
||||||
|
gap: "10px",
|
||||||
marginBottom: "18px",
|
marginBottom: "18px",
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
@@ -54,10 +57,16 @@ export function DomainsPage() {
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [fqdn, setFqdn] = useState("");
|
const [fqdn, setFqdn] = useState("");
|
||||||
const [netBIOS, setNetBIOS] = useState("");
|
const [netBIOS, setNetBIOS] = useState("");
|
||||||
|
const [linkDomainId, setLinkDomainId] = useState("");
|
||||||
|
const [environmentId, setEnvironmentId] = useState("");
|
||||||
const { data, error, isLoading } = useQuery({
|
const { data, error, isLoading } = useQuery({
|
||||||
queryKey: ["domains"],
|
queryKey: ["domains"],
|
||||||
queryFn: ({ signal }) => portalApi.getDomains(signal),
|
queryFn: ({ signal }) => portalApi.getDomains(signal),
|
||||||
});
|
});
|
||||||
|
const { data: environments, error: environmentsError, isLoading: environmentsLoading } = useQuery({
|
||||||
|
queryKey: ["environments"],
|
||||||
|
queryFn: ({ signal }) => portalApi.getEnvironments(signal),
|
||||||
|
});
|
||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
setDialogMode(null);
|
setDialogMode(null);
|
||||||
@@ -83,6 +92,11 @@ export function DomainsPage() {
|
|||||||
setDialogMode("edit");
|
setDialogMode("edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openLinkDialog = () => {
|
||||||
|
setLinkDomainId(data?.[0]?.id ?? "");
|
||||||
|
setEnvironmentId(environments?.[0]?.id ?? "");
|
||||||
|
};
|
||||||
|
|
||||||
const addDomain = useMutation({
|
const addDomain = useMutation({
|
||||||
mutationFn: portalApi.addDomain,
|
mutationFn: portalApi.addDomain,
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -106,6 +120,16 @@ export function DomainsPage() {
|
|||||||
await queryClient.invalidateQueries({ queryKey: ["domains"] });
|
await queryClient.invalidateQueries({ queryKey: ["domains"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const linkDomainToEnvironment = useMutation({
|
||||||
|
mutationFn: ({ domainId, environmentId: targetEnvironmentId }: { domainId: string; environmentId: string }) =>
|
||||||
|
portalApi.linkDomainToEnvironment(domainId, targetEnvironmentId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setLinkDomainId("");
|
||||||
|
setEnvironmentId("");
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["domains"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["environments"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const submitDomain = (event: React.FormEvent<HTMLFormElement>) => {
|
const submitDomain = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -129,6 +153,9 @@ export function DomainsPage() {
|
|||||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||||
Domain hinzufuegen
|
Domain hinzufuegen
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button appearance="secondary" icon={<LinkMultiple24Regular />} onClick={openLinkDialog}>
|
||||||
|
Link to Environment
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
||||||
@@ -159,8 +186,72 @@ export function DomainsPage() {
|
|||||||
</form>
|
</form>
|
||||||
</DialogSurface>
|
</DialogSurface>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(linkDomainId || environmentId)}
|
||||||
|
onOpenChange={(_, dialogData) => {
|
||||||
|
if (!dialogData.open) {
|
||||||
|
setLinkDomainId("");
|
||||||
|
setEnvironmentId("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogSurface>
|
||||||
|
<DialogBody>
|
||||||
|
<DialogTitle>Link to Environment</DialogTitle>
|
||||||
|
<DialogContent className={styles.form}>
|
||||||
|
<Field label="Domain" required>
|
||||||
|
<Combobox
|
||||||
|
placeholder="Domain waehlen"
|
||||||
|
value={data?.find((domain) => domain.id === linkDomainId)?.name ?? ""}
|
||||||
|
onOptionSelect={(_, optionData) => setLinkDomainId(optionData.optionValue ?? "")}
|
||||||
|
>
|
||||||
|
{(data ?? []).map((domain) => (
|
||||||
|
<Option key={domain.id} text={domain.name} value={domain.id}>
|
||||||
|
{domain.name}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Combobox>
|
||||||
|
</Field>
|
||||||
|
<Field label="Environment" required validationMessage={linkDomainToEnvironment.error?.message}>
|
||||||
|
<Combobox
|
||||||
|
disabled={environmentsLoading}
|
||||||
|
placeholder="Environment waehlen"
|
||||||
|
value={environments?.find((environment) => environment.id === environmentId)?.name ?? ""}
|
||||||
|
onOptionSelect={(_, data) => setEnvironmentId(data.optionValue ?? "")}
|
||||||
|
>
|
||||||
|
{(environments ?? []).map((environment) => (
|
||||||
|
<Option key={environment.id} text={environment.name} value={environment.id}>
|
||||||
|
{environment.name}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Combobox>
|
||||||
|
</Field>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
appearance="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setLinkDomainId("");
|
||||||
|
setEnvironmentId("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
appearance="primary"
|
||||||
|
disabled={!linkDomainId || !environmentId || linkDomainToEnvironment.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
linkDomainToEnvironment.mutate({ domainId: linkDomainId, environmentId });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Link
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogSurface>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<DataState isLoading={isLoading} error={error ?? deleteDomain.error} />
|
<DataState isLoading={isLoading || environmentsLoading} error={error ?? environmentsError ?? deleteDomain.error ?? linkDomainToEnvironment.error} />
|
||||||
{data && (
|
{data && (
|
||||||
<Table aria-label="Domains">
|
<Table aria-label="Domains">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Title3,
|
Title3,
|
||||||
tokens,
|
tokens,
|
||||||
|
Tooltip,
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import { ArrowLeftRegular } from "@fluentui/react-icons";
|
import { ArrowLeftRegular, OpenRegular } from "@fluentui/react-icons";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { portalApi } from "../api/portalApi";
|
import { portalApi } from "../api/portalApi";
|
||||||
import { DataState } from "../components/DataState";
|
import { DataState } from "../components/DataState";
|
||||||
@@ -49,6 +50,11 @@ const useStyles = makeStyles({
|
|||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
marginBottom: "12px",
|
marginBottom: "12px",
|
||||||
},
|
},
|
||||||
|
actions: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "4px",
|
||||||
|
...shorthands.padding("2px", "0"),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function EnvironmentDetailsPage() {
|
export function EnvironmentDetailsPage() {
|
||||||
@@ -85,6 +91,40 @@ export function EnvironmentDetailsPage() {
|
|||||||
{data.id}
|
{data.id}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<Text size={200}>Environment Type</Text>
|
||||||
|
<Text className={styles.value} weight="semibold">
|
||||||
|
{data.environmentType}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<Text size={200}>Cloud Enabled</Text>
|
||||||
|
<Text className={styles.value} weight="semibold">
|
||||||
|
{data.environmentType === "OnPrem" ? "Nein" : "Ja"}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<Text size={200}>Provider Type</Text>
|
||||||
|
<Text className={styles.value} weight="semibold">
|
||||||
|
{data.environmentType === "OnPrem" ? data.providerType : ""}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{data.environmentType !== "OnPrem" && (
|
||||||
|
<div className={styles.field}>
|
||||||
|
<Text size={200}>Tenant Id</Text>
|
||||||
|
<Text className={styles.value} weight="semibold">
|
||||||
|
{data.tenantId}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.environmentType === "AzureTenant" && (
|
||||||
|
<div className={styles.field}>
|
||||||
|
<Text size={200}>Subscription Id</Text>
|
||||||
|
<Text className={styles.value} weight="semibold">
|
||||||
|
{data.subscriptionId}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Title3 className={styles.sectionTitle}>Linked Domains</Title3>
|
<Title3 className={styles.sectionTitle}>Linked Domains</Title3>
|
||||||
@@ -115,7 +155,13 @@ export function EnvironmentDetailsPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link to={`/domains/${link.domain!.id}`}>Oeffnen</Link>
|
<div className={styles.actions}>
|
||||||
|
<Tooltip content="Details" relationship="label">
|
||||||
|
<Link to={`/domains/${link.domain!.id}`}>
|
||||||
|
<Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} />
|
||||||
|
</Link>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Combobox,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
DialogBody,
|
DialogBody,
|
||||||
@@ -10,6 +11,8 @@ import {
|
|||||||
Field,
|
Field,
|
||||||
Input,
|
Input,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
|
Option,
|
||||||
|
Textarea,
|
||||||
shorthands,
|
shorthands,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -19,7 +22,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons";
|
import { AddRegular, DeleteRegular, EditRegular, OpenRegular, LinkMultiple24Regular } from "@fluentui/react-icons";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { portalApi } from "../api/portalApi";
|
import { portalApi } from "../api/portalApi";
|
||||||
@@ -31,6 +34,7 @@ const useStyles = makeStyles({
|
|||||||
toolbar: {
|
toolbar: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "flex-start",
|
justifyContent: "flex-start",
|
||||||
|
gap: "10px",
|
||||||
marginBottom: "18px",
|
marginBottom: "18px",
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
@@ -52,28 +56,58 @@ export function EnvironmentsPage() {
|
|||||||
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
|
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
|
||||||
const [selectedEnvironment, setSelectedEnvironment] = useState<EnvironmentItem | null>(null);
|
const [selectedEnvironment, setSelectedEnvironment] = useState<EnvironmentItem | null>(null);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
const [environmentType, setEnvironmentType] = useState("OnPrem");
|
||||||
|
const [providerType, setProviderType] = useState("");
|
||||||
|
const [tenantId, setTenantId] = useState("");
|
||||||
|
const [subscriptionId, setSubscriptionId] = useState("");
|
||||||
|
const [metadataJson, setMetadataJson] = useState("");
|
||||||
|
const [linkEnvironmentId, setLinkEnvironmentId] = useState("");
|
||||||
|
const [domainId, setDomainId] = useState("");
|
||||||
const { data, error, isLoading } = useQuery({
|
const { data, error, isLoading } = useQuery({
|
||||||
queryKey: ["environments"],
|
queryKey: ["environments"],
|
||||||
queryFn: ({ signal }) => portalApi.getEnvironments(signal),
|
queryFn: ({ signal }) => portalApi.getEnvironments(signal),
|
||||||
});
|
});
|
||||||
|
const { data: domains, error: domainsError, isLoading: domainsLoading } = useQuery({
|
||||||
|
queryKey: ["domains"],
|
||||||
|
queryFn: ({ signal }) => portalApi.getDomains(signal),
|
||||||
|
});
|
||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
setDialogMode(null);
|
setDialogMode(null);
|
||||||
setSelectedEnvironment(null);
|
setSelectedEnvironment(null);
|
||||||
setName("");
|
setName("");
|
||||||
|
setEnvironmentType("OnPrem");
|
||||||
|
setProviderType("");
|
||||||
|
setTenantId("");
|
||||||
|
setSubscriptionId("");
|
||||||
|
setMetadataJson("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const openAddDialog = () => {
|
const openAddDialog = () => {
|
||||||
setSelectedEnvironment(null);
|
setSelectedEnvironment(null);
|
||||||
setName("");
|
setName("");
|
||||||
|
setEnvironmentType("OnPrem");
|
||||||
|
setProviderType("");
|
||||||
|
setTenantId("");
|
||||||
|
setSubscriptionId("");
|
||||||
|
setMetadataJson("");
|
||||||
setDialogMode("add");
|
setDialogMode("add");
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditDialog = (environment: EnvironmentItem) => {
|
const openEditDialog = (environment: EnvironmentItem) => {
|
||||||
setSelectedEnvironment(environment);
|
setSelectedEnvironment(environment);
|
||||||
setName(environment.name);
|
setName(environment.name);
|
||||||
|
setEnvironmentType(environment.environmentType ?? "OnPrem");
|
||||||
|
setProviderType(environment.providerType ?? "");
|
||||||
|
setTenantId(environment.tenantId ?? "");
|
||||||
|
setSubscriptionId(environment.subscriptionId ?? "");
|
||||||
|
setMetadataJson(environment.metadataJson ?? "");
|
||||||
setDialogMode("edit");
|
setDialogMode("edit");
|
||||||
};
|
};
|
||||||
|
const openLinkDialog = () => {
|
||||||
|
setLinkEnvironmentId(data?.[0]?.id ?? "");
|
||||||
|
setDomainId(domains?.[0]?.id ?? "");
|
||||||
|
};
|
||||||
|
|
||||||
const addEnvironment = useMutation({
|
const addEnvironment = useMutation({
|
||||||
mutationFn: portalApi.addEnvironment,
|
mutationFn: portalApi.addEnvironment,
|
||||||
@@ -84,7 +118,14 @@ export function EnvironmentsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateEnvironment = useMutation({
|
const updateEnvironment = useMutation({
|
||||||
mutationFn: ({ id, environment }: { id: string; environment: { name: string } }) =>
|
mutationFn: ({ id, environment }: { id: string; environment: {
|
||||||
|
name: string;
|
||||||
|
environmentType: string;
|
||||||
|
providerType?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
subscriptionId?: string;
|
||||||
|
metadataJson?: string;
|
||||||
|
} }) =>
|
||||||
portalApi.updateEnvironment(id, environment),
|
portalApi.updateEnvironment(id, environment),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
closeDialog();
|
closeDialog();
|
||||||
@@ -98,10 +139,27 @@ export function EnvironmentsPage() {
|
|||||||
await queryClient.invalidateQueries({ queryKey: ["environments"] });
|
await queryClient.invalidateQueries({ queryKey: ["environments"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const linkDomainToEnvironment = useMutation({
|
||||||
|
mutationFn: ({ targetDomainId, environmentId }: { targetDomainId: string; environmentId: string }) =>
|
||||||
|
portalApi.linkDomainToEnvironment(targetDomainId, environmentId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setLinkEnvironmentId("");
|
||||||
|
setDomainId("");
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["domains"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["environments"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const submitEnvironment = (event: React.FormEvent<HTMLFormElement>) => {
|
const submitEnvironment = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const environment = { name };
|
const environment = {
|
||||||
|
name,
|
||||||
|
environmentType,
|
||||||
|
providerType,
|
||||||
|
tenantId,
|
||||||
|
subscriptionId,
|
||||||
|
metadataJson,
|
||||||
|
};
|
||||||
|
|
||||||
if (dialogMode === "edit" && selectedEnvironment) {
|
if (dialogMode === "edit" && selectedEnvironment) {
|
||||||
updateEnvironment.mutate({ id: selectedEnvironment.id, environment });
|
updateEnvironment.mutate({ id: selectedEnvironment.id, environment });
|
||||||
@@ -113,6 +171,9 @@ export function EnvironmentsPage() {
|
|||||||
|
|
||||||
const formError = addEnvironment.error?.message ?? updateEnvironment.error?.message;
|
const formError = addEnvironment.error?.message ?? updateEnvironment.error?.message;
|
||||||
const isSaving = addEnvironment.isPending || updateEnvironment.isPending;
|
const isSaving = addEnvironment.isPending || updateEnvironment.isPending;
|
||||||
|
const isOnPrem = environmentType === "OnPrem";
|
||||||
|
const isAzureTenant = environmentType === "AzureTenant";
|
||||||
|
const isM365Tenant = environmentType === "M365Tenant";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -121,6 +182,9 @@ export function EnvironmentsPage() {
|
|||||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||||
Environment hinzufuegen
|
Environment hinzufuegen
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button appearance="secondary" icon={<LinkMultiple24Regular />} onClick={openLinkDialog}>
|
||||||
|
Link to Domain
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
||||||
@@ -134,6 +198,73 @@ export function EnvironmentsPage() {
|
|||||||
<Field label="Name" required validationMessage={formError}>
|
<Field label="Name" required validationMessage={formError}>
|
||||||
<Input value={name} onChange={(_, data) => setName(data.value)} />
|
<Input value={name} onChange={(_, data) => setName(data.value)} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Environment Type" required>
|
||||||
|
<Combobox
|
||||||
|
value={environmentType}
|
||||||
|
onOptionSelect={(_, data) => {
|
||||||
|
const nextType = data.optionValue ?? "OnPrem";
|
||||||
|
setEnvironmentType(nextType);
|
||||||
|
|
||||||
|
if (nextType === "OnPrem") {
|
||||||
|
setTenantId("");
|
||||||
|
setSubscriptionId("");
|
||||||
|
setMetadataJson("");
|
||||||
|
} else if (nextType === "M365Tenant") {
|
||||||
|
setProviderType("");
|
||||||
|
setSubscriptionId("");
|
||||||
|
} else {
|
||||||
|
setProviderType("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Option text="OnPrem" value="OnPrem">
|
||||||
|
OnPrem
|
||||||
|
</Option>
|
||||||
|
<Option text="AzureTenant" value="AzureTenant">
|
||||||
|
AzureTenant
|
||||||
|
</Option>
|
||||||
|
<Option text="M365Tenant" value="M365Tenant">
|
||||||
|
M365Tenant
|
||||||
|
</Option>
|
||||||
|
</Combobox>
|
||||||
|
</Field>
|
||||||
|
{isOnPrem && (
|
||||||
|
<Field label="Provider Type">
|
||||||
|
<Combobox
|
||||||
|
placeholder="Provider waehlen"
|
||||||
|
value={providerType}
|
||||||
|
onOptionSelect={(_, data) => setProviderType(data.optionValue ?? "")}
|
||||||
|
>
|
||||||
|
<Option text="Hyper-V" value="Hyper-V">
|
||||||
|
Hyper-V
|
||||||
|
</Option>
|
||||||
|
<Option text="VMware" value="VMware">
|
||||||
|
VMware
|
||||||
|
</Option>
|
||||||
|
</Combobox>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{!isOnPrem && (
|
||||||
|
<>
|
||||||
|
<Field label="Tenant Id">
|
||||||
|
<Input value={tenantId} onChange={(_, data) => setTenantId(data.value)} />
|
||||||
|
</Field>
|
||||||
|
{isAzureTenant && (
|
||||||
|
<Field label="Subscription Id">
|
||||||
|
<Input value={subscriptionId} onChange={(_, data) => setSubscriptionId(data.value)} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{(isAzureTenant || isM365Tenant) && (
|
||||||
|
<Field label="Metadata JSON">
|
||||||
|
<Textarea
|
||||||
|
resize="vertical"
|
||||||
|
value={metadataJson}
|
||||||
|
onChange={(_, data) => setMetadataJson(data.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button appearance="secondary" onClick={closeDialog}>
|
<Button appearance="secondary" onClick={closeDialog}>
|
||||||
@@ -147,13 +278,85 @@ export function EnvironmentsPage() {
|
|||||||
</form>
|
</form>
|
||||||
</DialogSurface>
|
</DialogSurface>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(linkEnvironmentId || domainId)}
|
||||||
|
onOpenChange={(_, dialogData) => {
|
||||||
|
if (!dialogData.open) {
|
||||||
|
setLinkEnvironmentId("");
|
||||||
|
setDomainId("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogSurface>
|
||||||
|
<DialogBody>
|
||||||
|
<DialogTitle>Link to Domain</DialogTitle>
|
||||||
|
<DialogContent className={styles.form}>
|
||||||
|
<Field label="Environment" required>
|
||||||
|
<Combobox
|
||||||
|
placeholder="Environment waehlen"
|
||||||
|
value={data?.find((environment) => environment.id === linkEnvironmentId)?.name ?? ""}
|
||||||
|
onOptionSelect={(_, optionData) => setLinkEnvironmentId(optionData.optionValue ?? "")}
|
||||||
|
>
|
||||||
|
{(data ?? []).map((environment) => (
|
||||||
|
<Option key={environment.id} text={environment.name} value={environment.id}>
|
||||||
|
{environment.name}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Combobox>
|
||||||
|
</Field>
|
||||||
|
<Field label="Domain" required validationMessage={linkDomainToEnvironment.error?.message}>
|
||||||
|
<Combobox
|
||||||
|
disabled={domainsLoading}
|
||||||
|
placeholder="Domain waehlen"
|
||||||
|
value={domains?.find((domain) => domain.id === domainId)?.name ?? ""}
|
||||||
|
onOptionSelect={(_, data) => setDomainId(data.optionValue ?? "")}
|
||||||
|
>
|
||||||
|
{(domains ?? []).map((domain) => (
|
||||||
|
<Option key={domain.id} text={domain.name} value={domain.id}>
|
||||||
|
{domain.name}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Combobox>
|
||||||
|
</Field>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
appearance="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setLinkEnvironmentId("");
|
||||||
|
setDomainId("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
appearance="primary"
|
||||||
|
disabled={!linkEnvironmentId || !domainId || linkDomainToEnvironment.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
linkDomainToEnvironment.mutate({
|
||||||
|
targetDomainId: domainId,
|
||||||
|
environmentId: linkEnvironmentId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Link
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogSurface>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<DataState isLoading={isLoading} error={error ?? deleteEnvironment.error} />
|
<DataState isLoading={isLoading || domainsLoading} error={error ?? domainsError ?? deleteEnvironment.error ?? linkDomainToEnvironment.error} />
|
||||||
{data && (
|
{data && (
|
||||||
<Table aria-label="Environments">
|
<Table aria-label="Environments">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHeaderCell>Name</TableHeaderCell>
|
<TableHeaderCell>Name</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Type</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Cloud</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Provider</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Tenant</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Subscription</TableHeaderCell>
|
||||||
<TableHeaderCell>Id</TableHeaderCell>
|
<TableHeaderCell>Id</TableHeaderCell>
|
||||||
<TableHeaderCell>Aktionen</TableHeaderCell>
|
<TableHeaderCell>Aktionen</TableHeaderCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -162,6 +365,11 @@ export function EnvironmentsPage() {
|
|||||||
{data.map((environment) => (
|
{data.map((environment) => (
|
||||||
<TableRow key={environment.id}>
|
<TableRow key={environment.id}>
|
||||||
<TableCell>{environment.name}</TableCell>
|
<TableCell>{environment.name}</TableCell>
|
||||||
|
<TableCell>{environment.environmentType}</TableCell>
|
||||||
|
<TableCell>{environment.environmentType === "OnPrem" ? "Nein" : "Ja"}</TableCell>
|
||||||
|
<TableCell>{environment.environmentType === "OnPrem" ? environment.providerType : ""}</TableCell>
|
||||||
|
<TableCell>{environment.environmentType !== "OnPrem" ? environment.tenantId : ""}</TableCell>
|
||||||
|
<TableCell>{environment.environmentType === "AzureTenant" ? environment.subscriptionId : ""}</TableCell>
|
||||||
<TableCell>{environment.id}</TableCell>
|
<TableCell>{environment.id}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Combobox,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
DialogBody,
|
DialogBody,
|
||||||
@@ -10,6 +12,7 @@ import {
|
|||||||
Field,
|
Field,
|
||||||
Input,
|
Input,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
|
Option,
|
||||||
shorthands,
|
shorthands,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -20,23 +23,51 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons";
|
import {
|
||||||
import { useState } from "react";
|
AddRegular,
|
||||||
|
ChevronDownRegular,
|
||||||
|
ChevronRightRegular,
|
||||||
|
CloudRegular,
|
||||||
|
DataPieRegular,
|
||||||
|
DeleteRegular,
|
||||||
|
DocumentRegular,
|
||||||
|
EditRegular,
|
||||||
|
OpenRegular,
|
||||||
|
PeopleRegular,
|
||||||
|
ShareRegular,
|
||||||
|
ShieldRegular,
|
||||||
|
} from "@fluentui/react-icons";
|
||||||
|
import { Fragment, useState } from "react";
|
||||||
import { portalApi } from "../api/portalApi";
|
import { portalApi } from "../api/portalApi";
|
||||||
import { DataState } from "../components/DataState";
|
import { DataState } from "../components/DataState";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
import type { ServiceItem } from "../types/portal";
|
import type { AddServiceRoleDefinition, ServiceItem, ServiceRoleDefinition } from "../types/portal";
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
toolbar: {
|
toolbar: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "flex-start",
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: "12px",
|
||||||
marginBottom: "18px",
|
marginBottom: "18px",
|
||||||
},
|
},
|
||||||
|
toolbarActions: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "8px",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
minWidth: "280px",
|
||||||
|
},
|
||||||
form: {
|
form: {
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gap: "14px",
|
gap: "14px",
|
||||||
},
|
},
|
||||||
|
iconOption: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: "4px",
|
gap: "4px",
|
||||||
@@ -45,10 +76,40 @@ const useStyles = makeStyles({
|
|||||||
value: {
|
value: {
|
||||||
overflowWrap: "anywhere",
|
overflowWrap: "anywhere",
|
||||||
},
|
},
|
||||||
|
nameWithIcon: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
},
|
||||||
|
groupRow: {
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
groupHeader: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
},
|
||||||
|
nestedCell: {
|
||||||
|
paddingLeft: "28px",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
type DialogMode = "add" | "edit" | "details" | null;
|
type DialogMode = "add" | "edit" | "details" | null;
|
||||||
|
|
||||||
|
const serviceIconOptions = [
|
||||||
|
{ key: "", label: "Kein Icon", icon: <DocumentRegular /> },
|
||||||
|
{ key: "SharePoint", label: "SharePoint", icon: <ShareRegular /> },
|
||||||
|
{ key: "Teams", label: "Teams", icon: <PeopleRegular /> },
|
||||||
|
{ key: "Exchange", label: "Exchange", icon: <DocumentRegular /> },
|
||||||
|
{ key: "Azure", label: "Azure", icon: <CloudRegular /> },
|
||||||
|
{ key: "SQL", label: "SQL", icon: <DataPieRegular /> },
|
||||||
|
{ key: "Security", label: "Security", icon: <ShieldRegular /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getServiceIcon(iconKey?: string) {
|
||||||
|
return serviceIconOptions.find((entry) => entry.key === (iconKey ?? ""))?.icon ?? <DocumentRegular />;
|
||||||
|
}
|
||||||
|
|
||||||
export function ServicesPage() {
|
export function ServicesPage() {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -56,21 +117,45 @@ export function ServicesPage() {
|
|||||||
const [selectedService, setSelectedService] = useState<ServiceItem | null>(null);
|
const [selectedService, setSelectedService] = useState<ServiceItem | null>(null);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
|
const [isCloudService, setIsCloudService] = useState(false);
|
||||||
|
const [iconKey, setIconKey] = useState("");
|
||||||
|
const [roleDialogOpen, setRoleDialogOpen] = useState(false);
|
||||||
|
const [selectedRoleDefinition, setSelectedRoleDefinition] = useState<ServiceRoleDefinition | null>(null);
|
||||||
|
const [roleKey, setRoleKey] = useState("");
|
||||||
|
const [roleName, setRoleName] = useState("");
|
||||||
|
const [roleDescription, setRoleDescription] = useState("");
|
||||||
|
const [serviceFilter, setServiceFilter] = useState<"all" | "cloud" | "onprem">("all");
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({
|
||||||
|
onprem: true,
|
||||||
|
cloud: true,
|
||||||
|
});
|
||||||
|
|
||||||
const { data, error, isLoading } = useQuery({
|
const { data, error, isLoading } = useQuery({
|
||||||
queryKey: ["services"],
|
queryKey: ["services"],
|
||||||
queryFn: ({ signal }) => portalApi.getServices(signal),
|
queryFn: ({ signal }) => portalApi.getServices(signal),
|
||||||
});
|
});
|
||||||
|
const { data: roleDefinitions, error: roleDefinitionsError, isLoading: roleDefinitionsLoading } = useQuery({
|
||||||
|
enabled: Boolean(selectedService?.id) && !isCloudService,
|
||||||
|
queryKey: ["service-role-definitions", selectedService?.id],
|
||||||
|
queryFn: ({ signal }) => portalApi.getServiceRoleDefinitions(selectedService!.id, signal),
|
||||||
|
});
|
||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
setDialogMode(null);
|
setDialogMode(null);
|
||||||
setSelectedService(null);
|
setSelectedService(null);
|
||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
|
setIsCloudService(false);
|
||||||
|
setIconKey("");
|
||||||
|
setRoleDialogOpen(false);
|
||||||
|
setSelectedRoleDefinition(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openAddDialog = () => {
|
const openAddDialog = () => {
|
||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
|
setIsCloudService(false);
|
||||||
|
setIconKey("");
|
||||||
setSelectedService(null);
|
setSelectedService(null);
|
||||||
setDialogMode("add");
|
setDialogMode("add");
|
||||||
};
|
};
|
||||||
@@ -79,6 +164,8 @@ export function ServicesPage() {
|
|||||||
setSelectedService(service);
|
setSelectedService(service);
|
||||||
setName(service.name);
|
setName(service.name);
|
||||||
setDescription(service.description ?? "");
|
setDescription(service.description ?? "");
|
||||||
|
setIsCloudService(Boolean(service.isCloudService));
|
||||||
|
setIconKey(service.iconKey ?? "");
|
||||||
setDialogMode(mode);
|
setDialogMode(mode);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,7 +178,7 @@ export function ServicesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateService = useMutation({
|
const updateService = useMutation({
|
||||||
mutationFn: ({ id, service }: { id: string; service: { name: string; description: string } }) =>
|
mutationFn: ({ id, service }: { id: string; service: { name: string; description: string; isCloudService: boolean; iconKey?: string } }) =>
|
||||||
portalApi.updateService(id, service),
|
portalApi.updateService(id, service),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
closeDialog();
|
closeDialog();
|
||||||
@@ -106,9 +193,37 @@ export function ServicesPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const addRoleDefinition = useMutation({
|
||||||
|
mutationFn: ({ serviceId, roleDefinition }: { serviceId: string; roleDefinition: AddServiceRoleDefinition }) =>
|
||||||
|
portalApi.addServiceRoleDefinition(serviceId, roleDefinition),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setRoleDialogOpen(false);
|
||||||
|
setSelectedRoleDefinition(null);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["service-role-definitions", selectedService?.id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateRoleDefinition = useMutation({
|
||||||
|
mutationFn: ({ serviceId, roleDefinitionId, roleDefinition }: { serviceId: string; roleDefinitionId: string; roleDefinition: AddServiceRoleDefinition }) =>
|
||||||
|
portalApi.updateServiceRoleDefinition(serviceId, roleDefinitionId, roleDefinition),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setRoleDialogOpen(false);
|
||||||
|
setSelectedRoleDefinition(null);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["service-role-definitions", selectedService?.id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteRoleDefinition = useMutation({
|
||||||
|
mutationFn: ({ serviceId, roleDefinitionId }: { serviceId: string; roleDefinitionId: string }) =>
|
||||||
|
portalApi.deleteServiceRoleDefinition(serviceId, roleDefinitionId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["service-role-definitions", selectedService?.id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const submitService = (event: React.FormEvent<HTMLFormElement>) => {
|
const submitService = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const service = { description, name };
|
const service = { description, name, isCloudService, iconKey: iconKey || undefined };
|
||||||
|
|
||||||
if (dialogMode === "edit" && selectedService) {
|
if (dialogMode === "edit" && selectedService) {
|
||||||
updateService.mutate({ id: selectedService.id, service });
|
updateService.mutate({ id: selectedService.id, service });
|
||||||
@@ -119,16 +234,105 @@ export function ServicesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formError = addService.error?.message ?? updateService.error?.message;
|
const formError = addService.error?.message ?? updateService.error?.message;
|
||||||
|
const roleError = addRoleDefinition.error?.message ?? updateRoleDefinition.error?.message ?? deleteRoleDefinition.error?.message;
|
||||||
const isSaving = addService.isPending || updateService.isPending;
|
const isSaving = addService.isPending || updateService.isPending;
|
||||||
const isDetails = dialogMode === "details";
|
const isDetails = dialogMode === "details";
|
||||||
|
|
||||||
|
const openAddRoleDialog = () => {
|
||||||
|
setSelectedRoleDefinition(null);
|
||||||
|
setRoleKey("");
|
||||||
|
setRoleName("");
|
||||||
|
setRoleDescription("");
|
||||||
|
setRoleDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditRoleDialog = (roleDefinition: ServiceRoleDefinition) => {
|
||||||
|
setSelectedRoleDefinition(roleDefinition);
|
||||||
|
setRoleKey(roleDefinition.key);
|
||||||
|
setRoleName(roleDefinition.name);
|
||||||
|
setRoleDescription(roleDefinition.description ?? "");
|
||||||
|
setRoleDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitRoleDefinition = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!selectedService) return;
|
||||||
|
|
||||||
|
const roleDefinition: AddServiceRoleDefinition = {
|
||||||
|
key: roleKey,
|
||||||
|
name: roleName,
|
||||||
|
description: roleDescription,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectedRoleDefinition) {
|
||||||
|
updateRoleDefinition.mutate({
|
||||||
|
serviceId: selectedService.id,
|
||||||
|
roleDefinitionId: selectedRoleDefinition.id,
|
||||||
|
roleDefinition,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addRoleDefinition.mutate({ serviceId: selectedService.id, roleDefinition });
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredServices =
|
||||||
|
serviceFilter === "all"
|
||||||
|
? (data ?? [])
|
||||||
|
: (data ?? []).filter((service) =>
|
||||||
|
serviceFilter === "cloud" ? Boolean(service.isCloudService) : !service.isCloudService,
|
||||||
|
);
|
||||||
|
const groupedServices = [
|
||||||
|
{
|
||||||
|
key: "onprem",
|
||||||
|
label: "On-Prem Services",
|
||||||
|
items: filteredServices.filter((service) => !service.isCloudService),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cloud",
|
||||||
|
label: "Cloud Services",
|
||||||
|
items: filteredServices.filter((service) => Boolean(service.isCloudService)),
|
||||||
|
},
|
||||||
|
].filter((group) => group.items.length > 0);
|
||||||
|
|
||||||
|
const toggleGroup = (groupKey: string) => {
|
||||||
|
setExpandedGroups((current) => ({
|
||||||
|
...current,
|
||||||
|
[groupKey]: !current[groupKey],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Services" description="Service-Katalog aus der Core API." />
|
<PageHeader title="Services" description="Service-Katalog aus der Core API." />
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
<div className={styles.toolbarActions}>
|
||||||
Service hinzufuegen
|
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||||
</Button>
|
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>
|
</div>
|
||||||
|
|
||||||
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
||||||
@@ -154,6 +358,92 @@ export function ServicesPage() {
|
|||||||
onChange={(_, data) => setDescription(data.value)}
|
onChange={(_, data) => setDescription(data.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Cloud Service">
|
||||||
|
<Checkbox
|
||||||
|
checked={isCloudService}
|
||||||
|
disabled={isDetails}
|
||||||
|
label={isCloudService ? "Ja" : "Nein"}
|
||||||
|
onChange={(_, data) => setIsCloudService(Boolean(data.checked))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Produkt Icon">
|
||||||
|
<Combobox
|
||||||
|
disabled={isDetails}
|
||||||
|
value={iconKey || "Kein Icon"}
|
||||||
|
onOptionSelect={(_, data) => setIconKey(data.optionValue ?? "")}
|
||||||
|
>
|
||||||
|
{serviceIconOptions.map((entry) => (
|
||||||
|
<Option key={entry.key || "none"} text={entry.label} value={entry.key}>
|
||||||
|
<div className={styles.iconOption}>
|
||||||
|
{entry.icon}
|
||||||
|
<span>{entry.label}</span>
|
||||||
|
</div>
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Combobox>
|
||||||
|
</Field>
|
||||||
|
{!isCloudService && (
|
||||||
|
<Field label="Roles">
|
||||||
|
{selectedService ? (
|
||||||
|
<>
|
||||||
|
<Button appearance="secondary" icon={<AddRegular />} onClick={openAddRoleDialog} disabled={isDetails}>
|
||||||
|
Role Definition hinzufuegen
|
||||||
|
</Button>
|
||||||
|
<Table aria-label="Service role definitions">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeaderCell>Key</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Name</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Description</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Aktionen</TableHeaderCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(roleDefinitions ?? []).map((roleDefinition) => (
|
||||||
|
<TableRow key={roleDefinition.id}>
|
||||||
|
<TableCell>{roleDefinition.key}</TableCell>
|
||||||
|
<TableCell>{roleDefinition.name}</TableCell>
|
||||||
|
<TableCell>{roleDefinition.description}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Tooltip content="Aendern" relationship="label">
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
aria-label="Aendern"
|
||||||
|
icon={<EditRegular />}
|
||||||
|
disabled={isDetails}
|
||||||
|
onClick={() => openEditRoleDialog(roleDefinition)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Loeschen" relationship="label">
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
aria-label="Loeschen"
|
||||||
|
icon={<DeleteRegular />}
|
||||||
|
disabled={isDetails || deleteRoleDefinition.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Role "${roleDefinition.name}" wirklich loeschen?`)) {
|
||||||
|
deleteRoleDefinition.mutate({
|
||||||
|
serviceId: selectedService.id,
|
||||||
|
roleDefinitionId: roleDefinition.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<DataState isLoading={roleDefinitionsLoading} error={roleDefinitionsError ?? (roleError ? new Error(roleError) : undefined)} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={styles.value}>Service zuerst speichern, dann Rollen konfigurieren.</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
{selectedService && (
|
{selectedService && (
|
||||||
<Field label="Id">
|
<Field label="Id">
|
||||||
<div className={styles.value}>{selectedService.id}</div>
|
<div className={styles.value}>{selectedService.id}</div>
|
||||||
@@ -175,6 +465,39 @@ export function ServicesPage() {
|
|||||||
</DialogSurface>
|
</DialogSurface>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={roleDialogOpen} onOpenChange={(_, data) => !data.open && setRoleDialogOpen(false)}>
|
||||||
|
<DialogSurface>
|
||||||
|
<form onSubmit={submitRoleDefinition}>
|
||||||
|
<DialogBody>
|
||||||
|
<DialogTitle>{selectedRoleDefinition ? "Role Definition aendern" : "Role Definition hinzufuegen"}</DialogTitle>
|
||||||
|
<DialogContent className={styles.form}>
|
||||||
|
<Field label="Key" required validationMessage={roleError}>
|
||||||
|
<Input value={roleKey} onChange={(_, data) => setRoleKey(data.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Name" required>
|
||||||
|
<Input value={roleName} onChange={(_, data) => setRoleName(data.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Description">
|
||||||
|
<Textarea resize="vertical" value={roleDescription} onChange={(_, data) => setRoleDescription(data.value)} />
|
||||||
|
</Field>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button appearance="secondary" onClick={() => setRoleDialogOpen(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
appearance="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={!roleKey || !roleName || addRoleDefinition.isPending || updateRoleDefinition.isPending}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</DialogBody>
|
||||||
|
</form>
|
||||||
|
</DialogSurface>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<DataState isLoading={isLoading} error={error ?? deleteService.error} />
|
<DataState isLoading={isLoading} error={error ?? deleteService.error} />
|
||||||
{data && (
|
{data && (
|
||||||
<Table aria-label="Services">
|
<Table aria-label="Services">
|
||||||
@@ -187,45 +510,62 @@ export function ServicesPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.map((service) => (
|
{groupedServices.map((group) => (
|
||||||
<TableRow key={service.id}>
|
<Fragment key={group.key}>
|
||||||
<TableCell>{service.name}</TableCell>
|
<TableRow className={styles.groupRow} onClick={() => toggleGroup(group.key)}>
|
||||||
<TableCell>{service.description}</TableCell>
|
<TableCell colSpan={4}>
|
||||||
<TableCell>{service.id}</TableCell>
|
<div className={styles.groupHeader}>
|
||||||
<TableCell>
|
{expandedGroups[group.key] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||||
<div className={styles.actions}>
|
<strong>{group.label}</strong>
|
||||||
<Tooltip content="Details" relationship="label">
|
</div>
|
||||||
<Button
|
</TableCell>
|
||||||
appearance="subtle"
|
</TableRow>
|
||||||
aria-label="Details"
|
{expandedGroups[group.key] !== false && group.items.map((service) => (
|
||||||
icon={<OpenRegular />}
|
<TableRow key={service.id}>
|
||||||
onClick={() => openServiceDialog("details", service)}
|
<TableCell className={styles.nestedCell}>
|
||||||
/>
|
<div className={styles.nameWithIcon}>
|
||||||
</Tooltip>
|
{getServiceIcon(service.iconKey)}
|
||||||
<Tooltip content="Aendern" relationship="label">
|
<span>{service.name}</span>
|
||||||
<Button
|
</div>
|
||||||
appearance="subtle"
|
</TableCell>
|
||||||
aria-label="Aendern"
|
<TableCell>{service.description}</TableCell>
|
||||||
icon={<EditRegular />}
|
<TableCell>{service.id}</TableCell>
|
||||||
onClick={() => openServiceDialog("edit", service)}
|
<TableCell>
|
||||||
/>
|
<div className={styles.actions}>
|
||||||
</Tooltip>
|
<Tooltip content="Details" relationship="label">
|
||||||
<Tooltip content="Loeschen" relationship="label">
|
<Button
|
||||||
<Button
|
appearance="subtle"
|
||||||
appearance="subtle"
|
aria-label="Details"
|
||||||
aria-label="Loeschen"
|
icon={<OpenRegular />}
|
||||||
disabled={deleteService.isPending}
|
onClick={() => openServiceDialog("details", service)}
|
||||||
icon={<DeleteRegular />}
|
/>
|
||||||
onClick={() => {
|
</Tooltip>
|
||||||
if (window.confirm(`Service "${service.name}" wirklich loeschen?`)) {
|
<Tooltip content="Aendern" relationship="label">
|
||||||
deleteService.mutate(service.id);
|
<Button
|
||||||
}
|
appearance="subtle"
|
||||||
}}
|
aria-label="Aendern"
|
||||||
/>
|
icon={<EditRegular />}
|
||||||
</Tooltip>
|
onClick={() => openServiceDialog("edit", service)}
|
||||||
</div>
|
/>
|
||||||
</TableCell>
|
</Tooltip>
|
||||||
</TableRow>
|
<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>
|
</TableBody>
|
||||||
</Table>
|
</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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Combobox,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
DialogBody,
|
DialogBody,
|
||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
Field,
|
Field,
|
||||||
Input,
|
Input,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
|
Option,
|
||||||
|
Tab,
|
||||||
|
TabList,
|
||||||
shorthands,
|
shorthands,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -22,18 +25,29 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons";
|
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons";
|
||||||
import { useState } from "react";
|
import { ChevronDownRegular, ChevronRightRegular } from "@fluentui/react-icons";
|
||||||
|
import { Fragment, useState } from "react";
|
||||||
import { portalApi } from "../api/portalApi";
|
import { portalApi } from "../api/portalApi";
|
||||||
import { DataState } from "../components/DataState";
|
import { DataState } from "../components/DataState";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
import type { Template } from "../types/portal";
|
import type { ServiceItem, Template, TemplateCategory } from "../types/portal";
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
toolbar: {
|
toolbar: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "flex-start",
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: "12px",
|
||||||
marginBottom: "18px",
|
marginBottom: "18px",
|
||||||
},
|
},
|
||||||
|
toolbarActions: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "8px",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
minWidth: "280px",
|
||||||
|
},
|
||||||
form: {
|
form: {
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gap: "14px",
|
gap: "14px",
|
||||||
@@ -54,6 +68,25 @@ const useStyles = makeStyles({
|
|||||||
value: {
|
value: {
|
||||||
overflowWrap: "anywhere",
|
overflowWrap: "anywhere",
|
||||||
},
|
},
|
||||||
|
groupRow: {
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
groupHeader: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
},
|
||||||
|
nestedLevel1Cell: {
|
||||||
|
paddingLeft: "28px",
|
||||||
|
},
|
||||||
|
nestedLevel2Cell: {
|
||||||
|
paddingLeft: "48px",
|
||||||
|
},
|
||||||
|
categoryHeaderCell: {
|
||||||
|
paddingTop: "10px",
|
||||||
|
paddingBottom: "6px",
|
||||||
|
paddingLeft: "28px",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
type DialogMode = "add" | "edit" | "details" | null;
|
type DialogMode = "add" | "edit" | "details" | null;
|
||||||
@@ -62,6 +95,57 @@ function getTemplateJsonData(template: Template) {
|
|||||||
return template.jsonData ?? template.jSONData ?? "";
|
return template.jsonData ?? template.jSONData ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TemplateEditorTab = "parameters" | "variables" | "resources" | "raw";
|
||||||
|
|
||||||
|
function tryFormatJson(value: string) {
|
||||||
|
try {
|
||||||
|
if (!value.trim()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return JSON.stringify(JSON.parse(value), null, 2);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCaseInsensitiveProperty(source: Record<string, unknown>, propertyName: string) {
|
||||||
|
const key = Object.keys(source).find((entry) => entry.toLowerCase() === propertyName.toLowerCase());
|
||||||
|
return key ? source[key] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEditorJsonParts(rawJson: string) {
|
||||||
|
if (!rawJson.trim()) {
|
||||||
|
return {
|
||||||
|
raw: "",
|
||||||
|
parameters: "{}",
|
||||||
|
variables: "{}",
|
||||||
|
resources: "[]",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(rawJson) as unknown;
|
||||||
|
const root = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
||||||
|
|
||||||
|
const parameters = getCaseInsensitiveProperty(root, "parameters");
|
||||||
|
const variables = getCaseInsensitiveProperty(root, "variables");
|
||||||
|
const resources = getCaseInsensitiveProperty(root, "resources");
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw: JSON.stringify(root, null, 2),
|
||||||
|
parameters: JSON.stringify(
|
||||||
|
parameters && typeof parameters === "object" && !Array.isArray(parameters) ? parameters : {},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
variables: JSON.stringify(
|
||||||
|
variables && typeof variables === "object" && !Array.isArray(variables) ? variables : {},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
resources: JSON.stringify(Array.isArray(resources) ? resources : [], null, 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function TemplatesPage() {
|
export function TemplatesPage() {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -69,24 +153,45 @@ export function TemplatesPage() {
|
|||||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
||||||
const [templateCategoryId, setTemplateCategoryId] = useState("");
|
const [templateCategoryId, setTemplateCategoryId] = useState("");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [cloudTemplate, setCloudTemplate] = useState(false);
|
|
||||||
const [version, setVersion] = useState("");
|
const [version, setVersion] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [jsonData, setJsonData] = useState("");
|
const [jsonData, setJsonData] = useState("");
|
||||||
|
const [jsonParameters, setJsonParameters] = useState("{}");
|
||||||
|
const [jsonVariables, setJsonVariables] = useState("{}");
|
||||||
|
const [jsonResources, setJsonResources] = useState("[]");
|
||||||
|
const [editorTab, setEditorTab] = useState<TemplateEditorTab>("parameters");
|
||||||
|
const [serviceFilterId, setServiceFilterId] = useState("all");
|
||||||
|
const [expandedServiceGroups, setExpandedServiceGroups] = useState<Record<string, boolean>>({});
|
||||||
|
const [expandedCategoryGroups, setExpandedCategoryGroups] = useState<Record<string, boolean>>({});
|
||||||
const { data, error, isLoading } = useQuery({
|
const { data, error, isLoading } = useQuery({
|
||||||
queryKey: ["templates"],
|
queryKey: ["templates"],
|
||||||
queryFn: ({ signal }) => portalApi.getTemplates(signal),
|
queryFn: ({ signal }) => portalApi.getTemplates(signal),
|
||||||
});
|
});
|
||||||
|
const {
|
||||||
|
data: templateCategories,
|
||||||
|
error: templateCategoriesError,
|
||||||
|
isLoading: templateCategoriesLoading,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["template-categories"],
|
||||||
|
queryFn: ({ signal }) => portalApi.getTemplateCategories(signal),
|
||||||
|
});
|
||||||
|
const { data: services, error: servicesError, isLoading: servicesLoading } = useQuery({
|
||||||
|
queryKey: ["services"],
|
||||||
|
queryFn: ({ signal }) => portalApi.getServices(signal),
|
||||||
|
});
|
||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
setDialogMode(null);
|
setDialogMode(null);
|
||||||
setSelectedTemplate(null);
|
setSelectedTemplate(null);
|
||||||
setTemplateCategoryId("");
|
setTemplateCategoryId("");
|
||||||
setName("");
|
setName("");
|
||||||
setCloudTemplate(false);
|
|
||||||
setVersion("");
|
setVersion("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setJsonData("");
|
setJsonData("");
|
||||||
|
setJsonParameters("{}");
|
||||||
|
setJsonVariables("{}");
|
||||||
|
setJsonResources("[]");
|
||||||
|
setEditorTab("parameters");
|
||||||
};
|
};
|
||||||
|
|
||||||
const openAddDialog = () => {
|
const openAddDialog = () => {
|
||||||
@@ -98,10 +203,21 @@ export function TemplatesPage() {
|
|||||||
setSelectedTemplate(template);
|
setSelectedTemplate(template);
|
||||||
setTemplateCategoryId(template.templateCategoryId ?? "");
|
setTemplateCategoryId(template.templateCategoryId ?? "");
|
||||||
setName(template.name);
|
setName(template.name);
|
||||||
setCloudTemplate(Boolean(template.cloudTemplate));
|
|
||||||
setVersion(template.version ?? "");
|
setVersion(template.version ?? "");
|
||||||
setDescription(template.description ?? "");
|
setDescription(template.description ?? "");
|
||||||
setJsonData(getTemplateJsonData(template));
|
const raw = getTemplateJsonData(template);
|
||||||
|
try {
|
||||||
|
const normalized = normalizeEditorJsonParts(raw);
|
||||||
|
setJsonData(normalized.raw);
|
||||||
|
setJsonParameters(normalized.parameters);
|
||||||
|
setJsonVariables(normalized.variables);
|
||||||
|
setJsonResources(normalized.resources);
|
||||||
|
} catch {
|
||||||
|
setJsonParameters("{}");
|
||||||
|
setJsonVariables("{}");
|
||||||
|
setJsonResources("[]");
|
||||||
|
setJsonData(tryFormatJson(raw));
|
||||||
|
}
|
||||||
setDialogMode(mode);
|
setDialogMode(mode);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,7 +238,6 @@ export function TemplatesPage() {
|
|||||||
template: {
|
template: {
|
||||||
templateCategoryId: string;
|
templateCategoryId: string;
|
||||||
name: string;
|
name: string;
|
||||||
cloudTemplate: boolean;
|
|
||||||
version: string;
|
version: string;
|
||||||
description: string;
|
description: string;
|
||||||
jsonData: string;
|
jsonData: string;
|
||||||
@@ -143,7 +258,29 @@ export function TemplatesPage() {
|
|||||||
|
|
||||||
const submitTemplate = (event: React.FormEvent<HTMLFormElement>) => {
|
const submitTemplate = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const template = { cloudTemplate, description, jsonData, name, templateCategoryId, version };
|
let composedJson = jsonData;
|
||||||
|
try {
|
||||||
|
const parsedRaw = jsonData.trim() ? JSON.parse(jsonData) : {};
|
||||||
|
const parsedParameters = jsonParameters.trim() ? JSON.parse(jsonParameters) : {};
|
||||||
|
const parsedVariables = jsonVariables.trim() ? JSON.parse(jsonVariables) : {};
|
||||||
|
const parsedResources = jsonResources.trim() ? JSON.parse(jsonResources) : [];
|
||||||
|
|
||||||
|
composedJson = JSON.stringify(
|
||||||
|
{
|
||||||
|
...parsedRaw,
|
||||||
|
parameters: parsedParameters,
|
||||||
|
variables: parsedVariables,
|
||||||
|
resources: parsedResources,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
setJsonData(composedJson);
|
||||||
|
} catch {
|
||||||
|
// keep current raw JSON and let backend validation respond
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = { description, jsonData: composedJson, name, templateCategoryId, version };
|
||||||
|
|
||||||
if (dialogMode === "edit" && selectedTemplate) {
|
if (dialogMode === "edit" && selectedTemplate) {
|
||||||
updateTemplate.mutate({ id: selectedTemplate.id, template });
|
updateTemplate.mutate({ id: selectedTemplate.id, template });
|
||||||
@@ -156,14 +293,80 @@ export function TemplatesPage() {
|
|||||||
const formError = addTemplate.error?.message ?? updateTemplate.error?.message;
|
const formError = addTemplate.error?.message ?? updateTemplate.error?.message;
|
||||||
const isSaving = addTemplate.isPending || updateTemplate.isPending;
|
const isSaving = addTemplate.isPending || updateTemplate.isPending;
|
||||||
const isDetails = dialogMode === "details";
|
const isDetails = dialogMode === "details";
|
||||||
|
const categoryNameById = new Map(
|
||||||
|
(templateCategories ?? []).map((category: TemplateCategory) => [category.id, category.name]),
|
||||||
|
);
|
||||||
|
const categoryServiceIdById = new Map(
|
||||||
|
(templateCategories ?? []).map((category: TemplateCategory) => [category.id, category.serviceId]),
|
||||||
|
);
|
||||||
|
const serviceNameById = new Map((services ?? []).map((service: ServiceItem) => [service.id, service.name]));
|
||||||
|
const filteredTemplates =
|
||||||
|
serviceFilterId === "all"
|
||||||
|
? (data ?? [])
|
||||||
|
: (data ?? []).filter((template) => {
|
||||||
|
const templateServiceId = template.templateCategoryId
|
||||||
|
? categoryServiceIdById.get(template.templateCategoryId)
|
||||||
|
: undefined;
|
||||||
|
return templateServiceId === serviceFilterId;
|
||||||
|
});
|
||||||
|
const groupedTemplates = (services ?? [])
|
||||||
|
.map((service) => ({
|
||||||
|
serviceId: service.id,
|
||||||
|
serviceName: service.name,
|
||||||
|
categories: (templateCategories ?? [])
|
||||||
|
.filter((category) => category.serviceId === service.id)
|
||||||
|
.map((category) => ({
|
||||||
|
categoryId: category.id,
|
||||||
|
categoryName: category.name,
|
||||||
|
items: filteredTemplates
|
||||||
|
.filter((template) => template.templateCategoryId === category.id)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
}))
|
||||||
|
.filter((categoryGroup) => categoryGroup.items.length > 0)
|
||||||
|
.sort((a, b) => a.categoryName.localeCompare(b.categoryName)),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.categories.length > 0);
|
||||||
|
|
||||||
|
const toggleServiceGroup = (serviceId: string) => {
|
||||||
|
setExpandedServiceGroups((current) => ({
|
||||||
|
...current,
|
||||||
|
[serviceId]: !current[serviceId],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCategoryGroup = (serviceId: string, categoryId: string) => {
|
||||||
|
const key = `${serviceId}:${categoryId}`;
|
||||||
|
setExpandedCategoryGroups((current) => ({
|
||||||
|
...current,
|
||||||
|
[key]: !current[key],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Templates" description="Vorlagen fuer Portal-Bereitstellungen." />
|
<PageHeader title="Templates" description="Vorlagen fuer Portal-Bereitstellungen." />
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
<div className={styles.toolbarActions}>
|
||||||
Template hinzufuegen
|
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||||
</Button>
|
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>
|
</div>
|
||||||
|
|
||||||
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
||||||
@@ -189,20 +392,21 @@ export function TemplatesPage() {
|
|||||||
onChange={(_, data) => setVersion(data.value)}
|
onChange={(_, data) => setVersion(data.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field className={styles.wide} label="TemplateCategoryId" required validationMessage={formError}>
|
<Field className={styles.wide} label="Template Category" required validationMessage={formError}>
|
||||||
<Input
|
<Combobox
|
||||||
disabled={isDetails}
|
disabled={isDetails}
|
||||||
value={templateCategoryId}
|
placeholder="Template Category waehlen"
|
||||||
onChange={(_, data) => setTemplateCategoryId(data.value)}
|
value={categoryNameById.get(templateCategoryId) ?? ""}
|
||||||
/>
|
onOptionSelect={(_, optionData) =>
|
||||||
</Field>
|
setTemplateCategoryId(optionData.optionValue ?? "")
|
||||||
<Field className={styles.wide}>
|
}
|
||||||
<Checkbox
|
>
|
||||||
checked={cloudTemplate}
|
{(templateCategories ?? []).map((category) => (
|
||||||
disabled={isDetails}
|
<Option key={category.id} text={category.name} value={category.id}>
|
||||||
label="Cloud Template"
|
{category.name}
|
||||||
onChange={(_, data) => setCloudTemplate(Boolean(data.checked))}
|
</Option>
|
||||||
/>
|
))}
|
||||||
|
</Combobox>
|
||||||
</Field>
|
</Field>
|
||||||
<Field className={styles.wide} label="Description">
|
<Field className={styles.wide} label="Description">
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -213,12 +417,47 @@ export function TemplatesPage() {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field className={styles.wide} label="JSONData" required>
|
<Field className={styles.wide} label="JSONData" required>
|
||||||
<Textarea
|
<TabList
|
||||||
disabled={isDetails}
|
selectedValue={editorTab}
|
||||||
resize="vertical"
|
onTabSelect={(_, tabData) => setEditorTab(tabData.value as TemplateEditorTab)}
|
||||||
value={jsonData}
|
>
|
||||||
onChange={(_, data) => setJsonData(data.value)}
|
<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>
|
</Field>
|
||||||
{selectedTemplate && (
|
{selectedTemplate && (
|
||||||
<Field className={styles.wide} label="Id">
|
<Field className={styles.wide} label="Id">
|
||||||
@@ -246,61 +485,96 @@ export function TemplatesPage() {
|
|||||||
</DialogSurface>
|
</DialogSurface>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<DataState isLoading={isLoading} error={error ?? deleteTemplate.error} />
|
<DataState
|
||||||
|
isLoading={isLoading || templateCategoriesLoading || servicesLoading}
|
||||||
|
error={error ?? templateCategoriesError ?? servicesError ?? deleteTemplate.error}
|
||||||
|
/>
|
||||||
{data && (
|
{data && (
|
||||||
<Table aria-label="Templates">
|
<Table aria-label="Templates">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHeaderCell>Name</TableHeaderCell>
|
<TableHeaderCell>Name</TableHeaderCell>
|
||||||
<TableHeaderCell>Version</TableHeaderCell>
|
<TableHeaderCell>Version</TableHeaderCell>
|
||||||
<TableHeaderCell>Cloud</TableHeaderCell>
|
<TableHeaderCell>Template Category</TableHeaderCell>
|
||||||
<TableHeaderCell>TemplateCategoryId</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Id</TableHeaderCell>
|
<TableHeaderCell>Id</TableHeaderCell>
|
||||||
<TableHeaderCell>Aktionen</TableHeaderCell>
|
<TableHeaderCell>Aktionen</TableHeaderCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.map((template) => (
|
{groupedTemplates.map((group) => (
|
||||||
<TableRow key={template.id}>
|
<Fragment key={`group-${group.serviceId}`}>
|
||||||
<TableCell>{template.name}</TableCell>
|
<TableRow className={styles.groupRow} onClick={() => toggleServiceGroup(group.serviceId)}>
|
||||||
<TableCell>{template.version}</TableCell>
|
<TableCell colSpan={5}>
|
||||||
<TableCell>{template.cloudTemplate ? "Ja" : "Nein"}</TableCell>
|
<div className={styles.groupHeader}>
|
||||||
<TableCell>{template.templateCategoryId}</TableCell>
|
{expandedServiceGroups[group.serviceId] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||||
<TableCell>{template.id}</TableCell>
|
<strong>{group.serviceName}</strong>
|
||||||
<TableCell>
|
</div>
|
||||||
<div className={styles.actions}>
|
</TableCell>
|
||||||
<Tooltip content="Details" relationship="label">
|
</TableRow>
|
||||||
<Button
|
{expandedServiceGroups[group.serviceId] !== false && group.categories.map((categoryGroup) => (
|
||||||
appearance="subtle"
|
<Fragment key={`group-${group.serviceId}-${categoryGroup.categoryId}`}>
|
||||||
aria-label="Details"
|
<TableRow
|
||||||
icon={<OpenRegular />}
|
className={styles.groupRow}
|
||||||
onClick={() => openTemplateDialog("details", template)}
|
onClick={() => toggleCategoryGroup(group.serviceId, categoryGroup.categoryId)}
|
||||||
/>
|
>
|
||||||
</Tooltip>
|
<TableCell className={styles.categoryHeaderCell} colSpan={5}>
|
||||||
<Tooltip content="Aendern" relationship="label">
|
<div className={styles.groupHeader}>
|
||||||
<Button
|
{expandedCategoryGroups[`${group.serviceId}:${categoryGroup.categoryId}`] !== false ? (
|
||||||
appearance="subtle"
|
<ChevronDownRegular />
|
||||||
aria-label="Aendern"
|
) : (
|
||||||
icon={<EditRegular />}
|
<ChevronRightRegular />
|
||||||
onClick={() => openTemplateDialog("edit", template)}
|
)}
|
||||||
/>
|
<strong>{categoryGroup.categoryName}</strong>
|
||||||
</Tooltip>
|
</div>
|
||||||
<Tooltip content="Loeschen" relationship="label">
|
</TableCell>
|
||||||
<Button
|
</TableRow>
|
||||||
appearance="subtle"
|
{expandedCategoryGroups[`${group.serviceId}:${categoryGroup.categoryId}`] !== false &&
|
||||||
aria-label="Loeschen"
|
categoryGroup.items.map((template) => (
|
||||||
disabled={deleteTemplate.isPending}
|
<TableRow key={template.id}>
|
||||||
icon={<DeleteRegular />}
|
<TableCell className={styles.nestedLevel2Cell}>{template.name}</TableCell>
|
||||||
onClick={() => {
|
<TableCell>{template.version}</TableCell>
|
||||||
if (window.confirm(`Template "${template.name}" wirklich loeschen?`)) {
|
<TableCell>
|
||||||
deleteTemplate.mutate(template.id);
|
{template.templateCategoryId ? categoryNameById.get(template.templateCategoryId) ?? template.templateCategoryId : ""}
|
||||||
}
|
</TableCell>
|
||||||
}}
|
<TableCell>{template.id}</TableCell>
|
||||||
/>
|
<TableCell>
|
||||||
</Tooltip>
|
<div className={styles.actions}>
|
||||||
</div>
|
<Tooltip content="Details" relationship="label">
|
||||||
</TableCell>
|
<Button
|
||||||
</TableRow>
|
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>
|
</TableBody>
|
||||||
</Table>
|
</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;
|
id: string;
|
||||||
|
deploymentGroupId?: string;
|
||||||
|
deploymentBatchId?: string;
|
||||||
|
virtualMachineId?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
jsonData?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AddDeployment = {
|
export type AddDeploymentExecution = {
|
||||||
deploymentGroupId: string;
|
deploymentBatchId: string;
|
||||||
virtualMachineId: string;
|
virtualMachineId: string;
|
||||||
status: string;
|
status: string;
|
||||||
jsonData: string;
|
jsonData: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DeploymentGroup = {
|
export type AddDeploymentRequest = {
|
||||||
|
deploymentBatchId: string;
|
||||||
|
virtualMachineIds: string[];
|
||||||
|
jsonData: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeploymentJob = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
attempts: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
started?: string;
|
||||||
|
finished?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
targetCount: number;
|
||||||
|
succeededTargetCount: number;
|
||||||
|
failedTargetCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeploymentJobTarget = {
|
||||||
|
id: string;
|
||||||
|
virtualMachineId: string;
|
||||||
|
deploymentBatchId: string;
|
||||||
|
templateId: string;
|
||||||
|
status: string;
|
||||||
|
attempts: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeploymentJobStep = {
|
||||||
|
id: string;
|
||||||
|
dependsOnDeploymentJobStepId?: string;
|
||||||
|
sortOrder: number;
|
||||||
|
name: string;
|
||||||
|
stepType: string;
|
||||||
|
status: string;
|
||||||
|
metadataJson?: string;
|
||||||
|
approvedAt?: string;
|
||||||
|
approvedBy?: string;
|
||||||
|
approvalComment?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeploymentJobDetails = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
payloadJson: string;
|
||||||
|
attempts: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
started?: string;
|
||||||
|
finished?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
targets: DeploymentJobTarget[];
|
||||||
|
steps: DeploymentJobStep[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeploymentBatch = {
|
||||||
id: string;
|
id: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AddDeploymentGroup = {
|
export type DeploymentBatchDetails = DeploymentBatch & {
|
||||||
|
deployments?: DeploymentExecution[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddDeploymentBatch = {
|
||||||
templateId: string;
|
templateId: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
virtualMachineIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Domain = {
|
export type Domain = {
|
||||||
@@ -33,6 +99,10 @@ export type DomainWithEnvironments = Domain & {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DomainWithVirtualMachines = Domain & {
|
||||||
|
virtualMachines?: VirtualMachine[];
|
||||||
|
};
|
||||||
|
|
||||||
export type AddDomain = {
|
export type AddDomain = {
|
||||||
name: string;
|
name: string;
|
||||||
fqdn: string;
|
fqdn: string;
|
||||||
@@ -42,6 +112,11 @@ export type AddDomain = {
|
|||||||
export type EnvironmentItem = {
|
export type EnvironmentItem = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
environmentType?: string;
|
||||||
|
providerType?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
subscriptionId?: string;
|
||||||
|
metadataJson?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EnvironmentWithDomains = EnvironmentItem & {
|
export type EnvironmentWithDomains = EnvironmentItem & {
|
||||||
@@ -52,6 +127,11 @@ export type EnvironmentWithDomains = EnvironmentItem & {
|
|||||||
|
|
||||||
export type AddEnvironment = {
|
export type AddEnvironment = {
|
||||||
name: string;
|
name: string;
|
||||||
|
environmentType: string;
|
||||||
|
providerType?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
subscriptionId?: string;
|
||||||
|
metadataJson?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Runbook = {
|
export type Runbook = {
|
||||||
@@ -69,7 +149,6 @@ export type Template = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
templateCategoryId?: string;
|
templateCategoryId?: string;
|
||||||
cloudTemplate?: boolean;
|
|
||||||
version?: string;
|
version?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
jsonData?: string;
|
jsonData?: string;
|
||||||
@@ -79,7 +158,6 @@ export type Template = {
|
|||||||
export type AddTemplate = {
|
export type AddTemplate = {
|
||||||
templateCategoryId: string;
|
templateCategoryId: string;
|
||||||
name: string;
|
name: string;
|
||||||
cloudTemplate: boolean;
|
|
||||||
version: string;
|
version: string;
|
||||||
description: string;
|
description: string;
|
||||||
jsonData: string;
|
jsonData: string;
|
||||||
@@ -89,9 +167,59 @@ export type ServiceItem = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
isCloudService?: boolean;
|
||||||
|
iconKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AddService = {
|
export type AddService = {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
isCloudService: boolean;
|
||||||
|
iconKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceRoleDefinition = {
|
||||||
|
id: string;
|
||||||
|
serviceId: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddServiceRoleDefinition = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TemplateCategory = {
|
||||||
|
id: string;
|
||||||
|
serviceId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddTemplateCategory = {
|
||||||
|
serviceId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VirtualMachine = {
|
||||||
|
id: string;
|
||||||
|
domainID?: string;
|
||||||
|
name: string;
|
||||||
|
externalId?: string;
|
||||||
|
metadataJson?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddVirtualMachine = {
|
||||||
|
domainID?: string;
|
||||||
|
name: string;
|
||||||
|
externalId?: string;
|
||||||
|
metadataJson?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user