feat: Refactor deployment types and add new deployment rules management

- Updated DeploymentExecution and related types to use targetId instead of virtualMachineId.
- Introduced new types for DeploymentRule, DeploymentRuleStep, and related entities.
- Added DeploymentRulesPage for managing deployment rules with CRUD operations.
- Created TargetDetailsPage and TargetsPage for managing targets with linking and unlinking functionality.
- Enhanced UI components with Fluent UI for better user experience.
- Implemented data fetching and state management using React Query.
This commit is contained in:
Torsten Brendgen
2026-07-07 23:30:08 +02:00
parent 08bcd60746
commit 3083e81452
27 changed files with 1492 additions and 399 deletions

View File

@@ -1,4 +1,4 @@
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "/api"; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "/api";
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
@@ -116,3 +116,4 @@ export async function deleteJson<TResult = unknown>(path: string): Promise<TResu
return text as TResult; return text as TResult;
} }
} }

View File

@@ -1,8 +1,11 @@
import { deleteJson, getJson, postJson, putJson } from "./httpClient"; import { deleteJson, getJson, postJson, putJson } from "./httpClient";
import type { import type {
AddDeploymentExecution, AddDeploymentExecution,
AddDeploymentParameterValue,
AddDeploymentRequest, AddDeploymentRequest,
AddDeploymentBatch, AddDeploymentBatch,
AddDeploymentTargetAssignment,
AddDeploymentTemplateSelection,
AddDomain, AddDomain,
AddEnvironment, AddEnvironment,
AddRunbook, AddRunbook,
@@ -15,7 +18,7 @@ import type {
DeploymentBatchDetails, DeploymentBatchDetails,
Domain, Domain,
DomainWithEnvironments, DomainWithEnvironments,
DomainWithVirtualMachines, DomainWithTargets,
EnvironmentItem, EnvironmentItem,
EnvironmentWithDomains, EnvironmentWithDomains,
Runbook, Runbook,
@@ -23,10 +26,13 @@ import type {
ServiceRoleDefinition, ServiceRoleDefinition,
TemplateCategory, TemplateCategory,
Template, Template,
VirtualMachine, TemplateVersion,
AddVirtualMachine, Target,
AddTarget,
DeploymentJob, DeploymentJob,
DeploymentJobDetails, DeploymentJobDetails,
DeploymentRule,
AddDeploymentRule,
} from "../types/portal"; } from "../types/portal";
export const portalApi = { export const portalApi = {
@@ -41,17 +47,38 @@ export const portalApi = {
postJson<{ comment?: string }, void>(`/Deployment/QueueJobs/Steps/${stepId}/Approve`, { comment }), postJson<{ comment?: string }, void>(`/Deployment/QueueJobs/Steps/${stepId}/Approve`, { comment }),
rejectDeploymentJobStep: (stepId: string, comment?: string) => rejectDeploymentJobStep: (stepId: string, comment?: string) =>
postJson<{ comment?: string }, void>(`/Deployment/QueueJobs/Steps/${stepId}/Reject`, { comment }), postJson<{ comment?: string }, void>(`/Deployment/QueueJobs/Steps/${stepId}/Reject`, { comment }),
getDeploymentBatches: (signal?: AbortSignal) => getJson<DeploymentBatch[]>("/DeploymentGroup", signal), getDeploymentBatches: (signal?: AbortSignal) => getJson<DeploymentBatch[]>("/deployment-batches", signal),
getDeploymentBatchById: (deploymentBatchId: string, signal?: AbortSignal) => getDeploymentBatchById: (deploymentBatchId: string, signal?: AbortSignal) =>
getJson<DeploymentBatchDetails>(`/DeploymentGroup/${deploymentBatchId}`, signal), getJson<DeploymentBatchDetails>(`/deployment-batches/${deploymentBatchId}`, signal),
addDeploymentBatch: (deploymentBatch: AddDeploymentBatch) => postJson<AddDeploymentBatch, string>("/DeploymentGroup", deploymentBatch), addDeploymentBatch: (deploymentBatch: AddDeploymentBatch) => postJson<AddDeploymentBatch, string>("/deployment-batches", deploymentBatch),
deleteDeploymentBatch: (deploymentBatchId: string) => deleteJson<void>(`/deployment-batches/${deploymentBatchId}`),
addDeploymentTemplateSelection: (deploymentBatchId: string, templateSelection: AddDeploymentTemplateSelection) =>
postJson<AddDeploymentTemplateSelection, string>(`/deployment-batches/${deploymentBatchId}/template-selections`, templateSelection),
deleteDeploymentTemplateSelection: (deploymentBatchId: string, templateSelectionId: string) =>
deleteJson<void>(`/deployment-batches/${deploymentBatchId}/template-selections/${templateSelectionId}`),
addDeploymentParameterValue: (deploymentBatchId: string, parameterValue: AddDeploymentParameterValue) =>
postJson<AddDeploymentParameterValue, string>(`/deployment-batches/${deploymentBatchId}/parameter-values`, parameterValue),
deleteDeploymentParameterValue: (deploymentBatchId: string, parameterValueId: string) =>
deleteJson<void>(`/deployment-batches/${deploymentBatchId}/parameter-values/${parameterValueId}`),
addDeploymentTargetAssignment: (deploymentBatchId: string, targetAssignment: AddDeploymentTargetAssignment) =>
postJson<AddDeploymentTargetAssignment, string>(`/deployment-batches/${deploymentBatchId}/target-assignments`, targetAssignment),
deleteDeploymentTargetAssignment: (deploymentBatchId: string, targetAssignmentId: string) =>
deleteJson<void>(`/deployment-batches/${deploymentBatchId}/target-assignments/${targetAssignmentId}`),
deleteDeploymentExecution: (deploymentBatchId: string, targetId: string) =>
deleteJson<void>(`/Deployment/Batch/${deploymentBatchId}/Target/${targetId}`),
getDeploymentRules: (signal?: AbortSignal) => getJson<DeploymentRule[]>("/deployment-rules", signal),
addDeploymentRule: (rule: AddDeploymentRule) => postJson<AddDeploymentRule, string>("/deployment-rules", rule),
updateDeploymentRule: (ruleId: string, rule: AddDeploymentRule) => putJson<AddDeploymentRule, void>(`/deployment-rules/${ruleId}`, rule),
deleteDeploymentRule: (ruleId: string) => deleteJson<void>(`/deployment-rules/${ruleId}`),
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), getDomainTargets: (domainId: string, signal?: AbortSignal) => getJson<DomainWithTargets>(`/Domain/${domainId}/Targets`, 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}`),
linkDomainToEnvironment: (domainId: string, environmentId: string) => postJson<undefined, string>(`/Domain/${domainId}/Environment/${environmentId}`, undefined), linkDomainToEnvironment: (domainId: string, environmentId: string) => postJson<undefined, string>(`/Domain/${domainId}/Environment/${environmentId}`, undefined),
unlinkDomainFromEnvironment: (domainId: string, environmentId: string) =>
deleteJson<void>(`/Domain/${domainId}/Environment/${environmentId}`),
getEnvironments: (signal?: AbortSignal) => getJson<EnvironmentItem[]>("/Environment", signal), getEnvironments: (signal?: AbortSignal) => getJson<EnvironmentItem[]>("/Environment", signal),
getEnvironmentDomains: (environmentId: string, signal?: AbortSignal) => getJson<EnvironmentWithDomains>(`/Environment/${environmentId}/Domains`, signal), getEnvironmentDomains: (environmentId: string, signal?: AbortSignal) => getJson<EnvironmentWithDomains>(`/Environment/${environmentId}/Domains`, signal),
addEnvironment: (environment: AddEnvironment) => postJson<AddEnvironment, string>("/Environment", environment), addEnvironment: (environment: AddEnvironment) => postJson<AddEnvironment, string>("/Environment", environment),
@@ -60,6 +87,7 @@ export const portalApi = {
getRunbooks: (signal?: AbortSignal) => getJson<Runbook[]>("/Runbook", signal), getRunbooks: (signal?: AbortSignal) => getJson<Runbook[]>("/Runbook", signal),
addRunbook: (runbook: AddRunbook) => postJson<AddRunbook, string>("/Runbook", runbook), addRunbook: (runbook: AddRunbook) => postJson<AddRunbook, string>("/Runbook", runbook),
getTemplates: (signal?: AbortSignal) => getJson<Template[]>("/Template", signal), getTemplates: (signal?: AbortSignal) => getJson<Template[]>("/Template", signal),
getTemplateVersions: (templateId: string, signal?: AbortSignal) => getJson<TemplateVersion[]>(`/Template/${templateId}/Versions`, signal),
addTemplate: (template: AddTemplate) => postJson<AddTemplate, string>("/Template", template), addTemplate: (template: AddTemplate) => postJson<AddTemplate, string>("/Template", template),
updateTemplate: (templateId: string, template: AddTemplate) => putJson<AddTemplate, void>(`/Template/${templateId}`, template), updateTemplate: (templateId: string, template: AddTemplate) => putJson<AddTemplate, void>(`/Template/${templateId}`, template),
deleteTemplate: (templateId: string) => deleteJson<void>(`/Template/${templateId}`), deleteTemplate: (templateId: string) => deleteJson<void>(`/Template/${templateId}`),
@@ -80,13 +108,14 @@ export const portalApi = {
updateTemplateCategory: (templateCategoryId: string, templateCategory: AddTemplateCategory) => updateTemplateCategory: (templateCategoryId: string, templateCategory: AddTemplateCategory) =>
putJson<AddTemplateCategory, void>(`/TemplateCategory/${templateCategoryId}`, templateCategory), putJson<AddTemplateCategory, void>(`/TemplateCategory/${templateCategoryId}`, templateCategory),
deleteTemplateCategory: (templateCategoryId: string) => deleteJson<void>(`/TemplateCategory/${templateCategoryId}`), deleteTemplateCategory: (templateCategoryId: string) => deleteJson<void>(`/TemplateCategory/${templateCategoryId}`),
getVirtualMachines: (signal?: AbortSignal) => getJson<VirtualMachine[]>("/VirtualMachine", signal), getTargets: (signal?: AbortSignal) => getJson<Target[]>("/Target", signal),
getVirtualMachineById: (virtualMachineId: string, signal?: AbortSignal) => getJson<VirtualMachine>(`/VirtualMachine/${virtualMachineId}`, signal), getTargetById: (targetId: string, signal?: AbortSignal) => getJson<Target>(`/Target/${targetId}`, signal),
addVirtualMachine: (virtualMachine: AddVirtualMachine) => postJson<AddVirtualMachine, string>("/VirtualMachine", virtualMachine), addTarget: (target: AddTarget) => postJson<AddTarget, string>("/Target", target),
updateVirtualMachine: (virtualMachineId: string, virtualMachine: AddVirtualMachine) => updateTarget: (targetId: string, target: AddTarget) =>
putJson<AddVirtualMachine, void>(`/VirtualMachine/${virtualMachineId}`, virtualMachine), putJson<AddTarget, void>(`/Target/${targetId}`, target),
deleteVirtualMachine: (virtualMachineId: string) => deleteJson<void>(`/VirtualMachine/${virtualMachineId}`), deleteTarget: (targetId: string) => deleteJson<void>(`/Target/${targetId}`),
linkVirtualMachineToDomain: (virtualMachineId: string, domainId: string) => linkTargetToDomain: (targetId: string, domainId: string) =>
postJson<undefined, void>(`/VirtualMachine/${virtualMachineId}/Domain/${domainId}`, undefined), postJson<undefined, void>(`/Target/${targetId}/Domain/${domainId}`, undefined),
unlinkVirtualMachineFromDomain: (virtualMachineId: string) => deleteJson<void>(`/VirtualMachine/${virtualMachineId}/Domain`), unlinkTargetFromDomain: (targetId: string) => deleteJson<void>(`/Target/${targetId}/Domain`),
}; };

View File

@@ -1,4 +1,4 @@
import { MessageBar, MessageBarBody, Spinner } from "@fluentui/react-components"; import { MessageBar, MessageBarBody, Spinner } from "@fluentui/react-components";
type DataStateProps = { type DataStateProps = {
isLoading: boolean; isLoading: boolean;
@@ -21,3 +21,4 @@ export function DataState({ isLoading, error }: DataStateProps) {
return null; return null;
} }

View File

@@ -1,4 +1,4 @@
import { makeStyles, shorthands, tokens } from "@fluentui/react-components"; import { makeStyles, shorthands, tokens } from "@fluentui/react-components";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
const useStyles = makeStyles({ const useStyles = makeStyles({
@@ -58,3 +58,4 @@ export function FormActions({ children }: PropsWithChildren) {
return <div className={styles.actions}>{children}</div>; return <div className={styles.actions}>{children}</div>;
} }

View File

@@ -1,4 +1,4 @@
import { makeStyles, Text, Title1 } from "@fluentui/react-components"; import { makeStyles, Text, Title1 } from "@fluentui/react-components";
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
@@ -23,3 +23,4 @@ export function PageHeader({ title, description }: PageHeaderProps) {
</div> </div>
); );
} }

View File

@@ -1,4 +1,4 @@
import { Outlet, NavLink } from "react-router-dom"; import { Outlet, NavLink } from "react-router-dom";
import { import {
Button, Button,
makeStyles, makeStyles,
@@ -71,6 +71,7 @@ const useStyles = makeStyles({
const links = [ const links = [
{ to: "/", label: "Dashboard", icon: <Home24Regular /> }, { to: "/", label: "Dashboard", icon: <Home24Regular /> },
{ to: "/deployments", label: "Deployments", icon: <BoxMultiple24Regular /> }, { to: "/deployments", label: "Deployments", icon: <BoxMultiple24Regular /> },
{ to: "/deployment-rules", label: "Deployment Rules", icon: <AppsListDetail24Regular /> },
{ to: "/worker-jobs", label: "Worker Jobs", icon: <AppsListDetail24Regular /> }, { to: "/worker-jobs", label: "Worker Jobs", icon: <AppsListDetail24Regular /> },
{ to: "/domains", label: "Domains", icon: <Globe24Regular /> }, { to: "/domains", label: "Domains", icon: <Globe24Regular /> },
{ to: "/environments", label: "Environments", icon: <DatabasePlugConnectedRegular /> }, { to: "/environments", label: "Environments", icon: <DatabasePlugConnectedRegular /> },
@@ -78,7 +79,7 @@ const links = [
{ to: "/templates", label: "Templates", icon: <BoxMultiple24Regular /> }, { to: "/templates", label: "Templates", icon: <BoxMultiple24Regular /> },
{ to: "/template-categories", label: "Template Categories", icon: <TagMultiple24Regular /> }, { 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 /> }, { to: "/targets", label: "Targets", icon: <Desktop24Regular /> },
]; ];
export function AppShell() { export function AppShell() {
@@ -119,3 +120,4 @@ export function AppShell() {
</div> </div>
); );
} }

View File

@@ -1,4 +1,4 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { FluentProvider, webLightTheme } from "@fluentui/react-components"; import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -9,6 +9,7 @@ import { DeploymentBatchDetailsPage } from "./pages/DeploymentBatchDetailsPage";
import { DeploymentJobDetailsPage } from "./pages/DeploymentJobDetailsPage"; import { DeploymentJobDetailsPage } from "./pages/DeploymentJobDetailsPage";
import { DeploymentJobsPage } from "./pages/DeploymentJobsPage"; import { DeploymentJobsPage } from "./pages/DeploymentJobsPage";
import { DeploymentGroupsPage } from "./pages/DeploymentGroupsPage"; import { DeploymentGroupsPage } from "./pages/DeploymentGroupsPage";
import { DeploymentRulesPage } from "./pages/DeploymentRulesPage";
import { DomainDetailsPage } from "./pages/DomainDetailsPage"; import { DomainDetailsPage } from "./pages/DomainDetailsPage";
import { DomainsPage } from "./pages/DomainsPage"; import { DomainsPage } from "./pages/DomainsPage";
import { EnvironmentDetailsPage } from "./pages/EnvironmentDetailsPage"; import { EnvironmentDetailsPage } from "./pages/EnvironmentDetailsPage";
@@ -16,8 +17,8 @@ import { EnvironmentsPage } from "./pages/EnvironmentsPage";
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 { TemplateCategoriesPage } from "./pages/TemplateCategoriesPage";
import { VirtualMachineDetailsPage } from "./pages/VirtualMachineDetailsPage"; import { TargetDetailsPage } from "./pages/TargetDetailsPage";
import { VirtualMachinesPage } from "./pages/VirtualMachinesPage"; import { TargetsPage } from "./pages/TargetsPage";
import "./styles/global.css"; import "./styles/global.css";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -36,6 +37,7 @@ const router = createBrowserRouter([
children: [ children: [
{ index: true, element: <DashboardPage /> }, { index: true, element: <DashboardPage /> },
{ path: "deployments", element: <DeploymentGroupsPage /> }, { path: "deployments", element: <DeploymentGroupsPage /> },
{ path: "deployment-rules", element: <DeploymentRulesPage /> },
{ path: "deployments/:id", element: <DeploymentBatchDetailsPage /> }, { path: "deployments/:id", element: <DeploymentBatchDetailsPage /> },
{ path: "worker-jobs", element: <DeploymentJobsPage /> }, { path: "worker-jobs", element: <DeploymentJobsPage /> },
{ path: "worker-jobs/:id", element: <DeploymentJobDetailsPage /> }, { path: "worker-jobs/:id", element: <DeploymentJobDetailsPage /> },
@@ -46,8 +48,8 @@ const router = createBrowserRouter([
{ path: "templates", element: <TemplatesPage /> }, { path: "templates", element: <TemplatesPage /> },
{ path: "template-categories", element: <TemplateCategoriesPage /> }, { path: "template-categories", element: <TemplateCategoriesPage /> },
{ path: "services", element: <ServicesPage /> }, { path: "services", element: <ServicesPage /> },
{ path: "virtual-machines", element: <VirtualMachinesPage /> }, { path: "targets", element: <TargetsPage /> },
{ path: "virtual-machines/:id", element: <VirtualMachineDetailsPage /> }, { path: "targets/:id", element: <TargetDetailsPage /> },
], ],
}, },
]); ]);
@@ -61,3 +63,4 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
</FluentProvider> </FluentProvider>
</React.StrictMode>, </React.StrictMode>,
); );

View File

@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Card, Card,
CardHeader, CardHeader,
@@ -78,9 +78,9 @@ export function DashboardPage() {
queryKey: ["queue-jobs"], queryKey: ["queue-jobs"],
queryFn: ({ signal }) => portalApi.getDeploymentJobs(signal), queryFn: ({ signal }) => portalApi.getDeploymentJobs(signal),
}); });
const virtualMachines = useQuery({ const targets = useQuery({
queryKey: ["virtual-machines"], queryKey: ["targets"],
queryFn: ({ signal }) => portalApi.getVirtualMachines(signal), queryFn: ({ signal }) => portalApi.getTargets(signal),
}); });
const error = const error =
@@ -89,14 +89,14 @@ export function DashboardPage() {
templates.error ?? templates.error ??
services.error ?? services.error ??
queueJobs.error ?? queueJobs.error ??
virtualMachines.error; targets.error;
const isLoading = const isLoading =
domains.isLoading || domains.isLoading ||
environments.isLoading || environments.isLoading ||
templates.isLoading || templates.isLoading ||
services.isLoading || services.isLoading ||
queueJobs.isLoading || queueJobs.isLoading ||
virtualMachines.isLoading; targets.isLoading;
const queueByStatus = (queueJobs.data ?? []).reduce<Record<string, number>>((acc, job) => { const queueByStatus = (queueJobs.data ?? []).reduce<Record<string, number>>((acc, job) => {
const key = job.status || "Unknown"; const key = job.status || "Unknown";
@@ -104,7 +104,7 @@ export function DashboardPage() {
return acc; return acc;
}, {}); }, {});
const vmCompliance = (virtualMachines.data ?? []).reduce( const vmCompliance = (targets.data ?? []).reduce(
(acc, vm) => { (acc, vm) => {
const compliance = getVmCompliance(vm.metadataJson); const compliance = getVmCompliance(vm.metadataJson);
if (compliance === true) acc.compliant += 1; if (compliance === true) acc.compliant += 1;
@@ -222,3 +222,4 @@ function getVmCompliance(metadataJson?: string): boolean | undefined {
return undefined; return undefined;
} }

View File

@@ -1,8 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Checkbox, Checkbox,
Combobox,
Field, Field,
Input,
makeStyles,
Option,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@@ -10,96 +14,437 @@ import {
TableHeaderCell, TableHeaderCell,
TableRow, TableRow,
Textarea, Textarea,
Tooltip,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { ArrowLeftRegular } from "@fluentui/react-icons"; import { AddRegular, ArrowLeftRegular, DeleteRegular } from "@fluentui/react-icons";
import { useMemo, useState } from "react"; 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";
import { FormActions, FormGrid, FormSection, FormWide } from "../components/FormSection";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
const useStyles = makeStyles({
layout: {
display: "grid",
gap: "24px",
},
section: {
display: "grid",
gap: "12px",
},
sectionHeader: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
},
sectionTitle: {
fontSize: "18px",
fontWeight: 600,
},
formGrid: {
display: "grid",
gridTemplateColumns: "repeat(4, minmax(160px, 1fr))",
gap: "12px",
},
wide: {
gridColumn: "span 2",
},
actions: {
display: "flex",
gap: "6px",
},
monospace: {
fontFamily: "Consolas, monospace",
fontSize: "12px",
wordBreak: "break-word",
},
});
export function DeploymentBatchDetailsPage() { export function DeploymentBatchDetailsPage() {
const styles = useStyles();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { id } = useParams(); const { id } = useParams();
const [selectedVirtualMachineIds, setSelectedVirtualMachineIds] = useState<string[]>([]);
const [jsonData, setJsonData] = useState("{}"); const [templateId, setTemplateId] = useState("");
const [templateVersionId, setTemplateVersionId] = useState("");
const [templateRole, setTemplateRole] = useState("Service");
const [templateAlias, setTemplateAlias] = useState("");
const [parameterSelectionId, setParameterSelectionId] = useState("");
const [parameterName, setParameterName] = useState("");
const [parameterValueJson, setParameterValueJson] = useState('{"value":""}');
const [parameterIsSecret, setParameterIsSecret] = useState(false);
const [targetTargetId, setTargetTargetId] = useState("");
const [targetRoleKey, setTargetRoleKey] = useState("Node");
const [targetNodeDataJson, setTargetNodeDataJson] = useState("{}");
const { data: deploymentBatch, error, isLoading } = useQuery({ const { data: deploymentBatch, error, isLoading } = useQuery({
queryKey: ["deployment-batches", id], queryKey: ["deployment-batches", id],
queryFn: ({ signal }) => portalApi.getDeploymentBatchById(id!, signal), queryFn: ({ signal }) => portalApi.getDeploymentBatchById(id!, signal),
enabled: Boolean(id), enabled: Boolean(id),
}); });
const { data: virtualMachines } = useQuery({ const { data: allDeployments } = useQuery({
queryKey: ["virtual-machines"], queryKey: ["deployments"],
queryFn: ({ signal }) => portalApi.getVirtualMachines(signal), queryFn: ({ signal }) => portalApi.getDeployments(signal),
});
const { data: templates } = useQuery({
queryKey: ["templates"],
queryFn: ({ signal }) => portalApi.getTemplates(signal),
});
const { data: templateVersions } = useQuery({
queryKey: ["template-versions", templateId],
queryFn: ({ signal }) => portalApi.getTemplateVersions(templateId, signal),
enabled: Boolean(templateId),
});
const { data: targets } = useQuery({
queryKey: ["targets"],
queryFn: ({ signal }) => portalApi.getTargets(signal),
}); });
const addDeploymentRequest = useMutation({ const invalidateBatch = async () => {
mutationFn: portalApi.addDeploymentRequest,
onSuccess: async () => {
setSelectedVirtualMachineIds([]);
setJsonData("{}");
await queryClient.invalidateQueries({ queryKey: ["deployments"] });
await queryClient.invalidateQueries({ queryKey: ["deployment-batches", id] }); await queryClient.invalidateQueries({ queryKey: ["deployment-batches", id] });
await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] }); await queryClient.invalidateQueries({ queryKey: ["deployment-batches"] });
};
const addTemplateSelection = useMutation({
mutationFn: () =>
portalApi.addDeploymentTemplateSelection(id!, {
templateVersionId,
templateRole,
alias: templateAlias || undefined,
}),
onSuccess: async () => {
setTemplateId("");
setTemplateVersionId("");
setTemplateRole("Service");
setTemplateAlias("");
await invalidateBatch();
}, },
}); });
const filteredExecutions = useMemo(() => deploymentBatch?.deployments ?? [], [deploymentBatch?.deployments]); const deleteTemplateSelection = useMutation({
mutationFn: (selectionId: string) => portalApi.deleteDeploymentTemplateSelection(id!, selectionId),
onSuccess: invalidateBatch,
});
const addParameterValue = useMutation({
mutationFn: () =>
portalApi.addDeploymentParameterValue(id!, {
deploymentTemplateSelectionId: parameterSelectionId || undefined,
name: parameterName,
valueJson: parameterValueJson,
isSecretReference: parameterIsSecret,
isOverride: true,
}),
onSuccess: async () => {
setParameterSelectionId("");
setParameterName("");
setParameterValueJson('{"value":""}');
setParameterIsSecret(false);
await invalidateBatch();
},
});
const deleteParameterValue = useMutation({
mutationFn: (parameterValueId: string) => portalApi.deleteDeploymentParameterValue(id!, parameterValueId),
onSuccess: invalidateBatch,
});
const addTargetAssignment = useMutation({
mutationFn: () =>
portalApi.addDeploymentTargetAssignment(id!, {
targetId: targetTargetId,
roleKey: targetRoleKey,
nodeDataJson: targetNodeDataJson,
}),
onSuccess: async () => {
setTargetTargetId("");
setTargetRoleKey("Node");
setTargetNodeDataJson("{}");
await invalidateBatch();
},
});
const deleteTargetAssignment = useMutation({
mutationFn: (targetAssignmentId: string) => portalApi.deleteDeploymentTargetAssignment(id!, targetAssignmentId),
onSuccess: invalidateBatch,
});
const filteredExecutions = useMemo(() => {
if (deploymentBatch?.deployments && deploymentBatch.deployments.length > 0) {
return deploymentBatch.deployments;
}
return (allDeployments ?? []).filter(
(execution) => execution.deploymentGroupId === id || execution.deploymentBatchId === id,
);
}, [allDeployments, deploymentBatch?.deployments, id]);
const templateSelectionError = addTemplateSelection.error;
const parameterError = addParameterValue.error;
const targetError = addTargetAssignment.error;
return ( return (
<> <div className={styles.layout}>
<Link to="/deployments"> <Link to="/deployments">
<Button appearance="subtle" icon={<ArrowLeftRegular />}> <Button appearance="subtle" icon={<ArrowLeftRegular />}>
Deployments Deployments
</Button> </Button>
</Link> </Link>
<PageHeader title={id ? `Deployment Batch ${id}` : "Deployment Batch"} description="Executions und Start neuer Deployments fuer diesen Batch." /> <PageHeader title={id ? `Deployment Batch ${id}` : "Deployment Batch"} description="Composition, Targets und Executions fuer diesen Batch." />
<FormSection <DataState
onSubmit={(event) => { isLoading={isLoading}
event.preventDefault(); error={error ?? templateSelectionError ?? parameterError ?? targetError}
if (!id) return; />
addDeploymentRequest.mutate({ deploymentBatchId: id, jsonData, virtualMachineIds: selectedVirtualMachineIds });
<section className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>Template Selections</div>
<Button
appearance="primary"
icon={<AddRegular />}
disabled={!id || !templateVersionId || addTemplateSelection.isPending}
onClick={() => addTemplateSelection.mutate()}
>
Add
</Button>
</div>
<div className={styles.formGrid}>
<Field label="Template">
<Combobox
placeholder="Template"
value={(templates ?? []).find((template) => template.id === templateId)?.name ?? ""}
onOptionSelect={(_, data) => {
setTemplateId(data.optionValue ?? "");
setTemplateVersionId("");
}} }}
> >
<FormGrid> {(templates ?? []).map((template) => (
<Field label="Targets (Virtual Machines)" required> <Option key={template.id} text={template.name} value={template.id}>
<div> {template.name}
{(virtualMachines ?? []).map((virtualMachine) => ( </Option>
<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));
}
}}
/>
))} ))}
</Combobox>
</Field>
<Field label="Version">
<Combobox
disabled={!templateId}
placeholder="Version"
value={(templateVersions ?? []).find((version) => version.id === templateVersionId)?.version ?? ""}
onOptionSelect={(_, data) => setTemplateVersionId(data.optionValue ?? "")}
>
{(templateVersions ?? []).map((version) => (
<Option key={version.id} text={version.version} value={version.id}>
{version.version}{version.isPublished ? " published" : ""}
</Option>
))}
</Combobox>
</Field>
<Field label="Role">
<Input value={templateRole} onChange={(_, data) => setTemplateRole(data.value)} />
</Field>
<Field label="Alias">
<Input value={templateAlias} onChange={(_, data) => setTemplateAlias(data.value)} />
</Field>
</div> </div>
</Field> <Table aria-label="Template selections">
<FormWide> <TableHeader>
<Field label="JSON data" required validationMessage={addDeploymentRequest.error?.message}> <TableRow>
<Textarea value={jsonData} onChange={(_, data) => setJsonData(data.value)} /> <TableHeaderCell>Order</TableHeaderCell>
</Field> <TableHeaderCell>Role</TableHeaderCell>
</FormWide> <TableHeaderCell>Alias</TableHeaderCell>
</FormGrid> <TableHeaderCell>Version</TableHeaderCell>
<FormActions> <TableHeaderCell>Schema</TableHeaderCell>
<Button appearance="primary" disabled={!id || selectedVirtualMachineIds.length === 0 || addDeploymentRequest.isPending} type="submit"> <TableHeaderCell>Hash</TableHeaderCell>
Start deployment <TableHeaderCell>Actions</TableHeaderCell>
</Button> </TableRow>
</FormActions> </TableHeader>
</FormSection> <TableBody>
{(deploymentBatch?.templateSelections ?? []).map((selection) => (
<TableRow key={selection.id}>
<TableCell>{selection.sortOrder}</TableCell>
<TableCell>{selection.templateRole}</TableCell>
<TableCell>{selection.alias ?? "-"}</TableCell>
<TableCell>{selection.templateVersion?.version ?? selection.templateVersionId}</TableCell>
<TableCell>{selection.templateVersion?.schemaVersion ?? "-"}</TableCell>
<TableCell className={styles.monospace}>{selection.templateVersion?.jsonHash?.slice(0, 12) ?? "-"}</TableCell>
<TableCell>
<Tooltip content="Delete selection" relationship="label">
<Button
appearance="subtle"
aria-label="Delete selection"
icon={<DeleteRegular />}
disabled={deleteTemplateSelection.isPending}
onClick={() => deleteTemplateSelection.mutate(selection.id)}
/>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
<DataState isLoading={isLoading} error={error} /> <section className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>Parameter Values</div>
<Button
appearance="primary"
icon={<AddRegular />}
disabled={!id || !parameterName || addParameterValue.isPending}
onClick={() => addParameterValue.mutate()}
>
Add
</Button>
</div>
<div className={styles.formGrid}>
<Field label="Scope">
<Combobox
placeholder="Global"
value={
(deploymentBatch?.templateSelections ?? []).find((selection) => selection.id === parameterSelectionId)?.alias ??
(parameterSelectionId ? parameterSelectionId : "Global")
}
onOptionSelect={(_, data) => setParameterSelectionId(data.optionValue ?? "")}
>
<Option text="Global" value="">
Global
</Option>
{(deploymentBatch?.templateSelections ?? []).map((selection) => (
<Option key={selection.id} text={selection.alias ?? selection.templateRole} value={selection.id}>
{selection.alias ?? selection.templateRole}
</Option>
))}
</Combobox>
</Field>
<Field label="Name">
<Input value={parameterName} onChange={(_, data) => setParameterName(data.value)} />
</Field>
<Field label="Secret">
<Checkbox checked={parameterIsSecret} onChange={(_, data) => setParameterIsSecret(Boolean(data.checked))} />
</Field>
<Field className={styles.wide} label="Value JSON">
<Textarea value={parameterValueJson} onChange={(_, data) => setParameterValueJson(data.value)} resize="vertical" />
</Field>
</div>
<Table aria-label="Parameter values">
<TableHeader>
<TableRow>
<TableHeaderCell>Scope</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Secret</TableHeaderCell>
<TableHeaderCell>Value JSON</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{(deploymentBatch?.parameterValues ?? []).map((parameterValue) => {
const selection = (deploymentBatch?.templateSelections ?? []).find(
(entry) => entry.id === parameterValue.deploymentTemplateSelectionId,
);
return (
<TableRow key={parameterValue.id}>
<TableCell>{selection?.alias ?? selection?.templateRole ?? "Global"}</TableCell>
<TableCell>{parameterValue.name}</TableCell>
<TableCell>{parameterValue.isSecretReference ? "Yes" : "No"}</TableCell>
<TableCell className={styles.monospace}>{parameterValue.valueJson}</TableCell>
<TableCell>
<Tooltip content="Delete parameter" relationship="label">
<Button
appearance="subtle"
aria-label="Delete parameter"
icon={<DeleteRegular />}
disabled={deleteParameterValue.isPending}
onClick={() => deleteParameterValue.mutate(parameterValue.id)}
/>
</Tooltip>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</section>
<section className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>Target Assignments</div>
<Button
appearance="primary"
icon={<AddRegular />}
disabled={!id || !targetTargetId || !targetRoleKey || addTargetAssignment.isPending}
onClick={() => addTargetAssignment.mutate()}
>
Add
</Button>
</div>
<div className={styles.formGrid}>
<Field label="Target">
<Combobox
placeholder="Target"
value={(targets ?? []).find((target) => target.id === targetTargetId)?.name ?? ""}
onOptionSelect={(_, data) => setTargetTargetId(data.optionValue ?? "")}
>
{(targets ?? []).map((target) => (
<Option key={target.id} text={target.name} value={target.id}>
{target.name}
</Option>
))}
</Combobox>
</Field>
<Field label="Role">
<Input value={targetRoleKey} onChange={(_, data) => setTargetRoleKey(data.value)} />
</Field>
<Field className={styles.wide} label="Node Data JSON">
<Textarea value={targetNodeDataJson} onChange={(_, data) => setTargetNodeDataJson(data.value)} resize="vertical" />
</Field>
</div>
<Table aria-label="Target assignments">
<TableHeader>
<TableRow>
<TableHeaderCell>Order</TableHeaderCell>
<TableHeaderCell>Target</TableHeaderCell>
<TableHeaderCell>Role</TableHeaderCell>
<TableHeaderCell>Node Data JSON</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{(deploymentBatch?.targetAssignments ?? []).map((targetAssignment) => (
<TableRow key={targetAssignment.id}>
<TableCell>{targetAssignment.sortOrder}</TableCell>
<TableCell>{targetAssignment.target?.name ?? targetAssignment.targetId}</TableCell>
<TableCell>{targetAssignment.roleKey}</TableCell>
<TableCell className={styles.monospace}>{targetAssignment.nodeDataJson}</TableCell>
<TableCell>
<Tooltip content="Delete target" relationship="label">
<Button
appearance="subtle"
aria-label="Delete target"
icon={<DeleteRegular />}
disabled={deleteTargetAssignment.isPending}
onClick={() => deleteTargetAssignment.mutate(targetAssignment.id)}
/>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
<section className={styles.section}>
<div className={styles.sectionTitle}>Executions</div>
<Table aria-label="Deployment executions"> <Table aria-label="Deployment executions">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>Status</TableHeaderCell> <TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Virtual Machine</TableHeaderCell> <TableHeaderCell>VM Name</TableHeaderCell>
<TableHeaderCell>VM Id</TableHeaderCell>
<TableHeaderCell>Domain Id</TableHeaderCell>
<TableHeaderCell>JSON Data</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Modified</TableHeaderCell>
<TableHeaderCell>Execution Id</TableHeaderCell> <TableHeaderCell>Execution Id</TableHeaderCell>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -107,12 +452,20 @@ export function DeploymentBatchDetailsPage() {
{filteredExecutions.map((execution) => ( {filteredExecutions.map((execution) => (
<TableRow key={execution.id}> <TableRow key={execution.id}>
<TableCell>{execution.status ?? "Unknown"}</TableCell> <TableCell>{execution.status ?? "Unknown"}</TableCell>
<TableCell>{execution.virtualMachineId ?? "-"}</TableCell> <TableCell>{execution.target?.name ?? "-"}</TableCell>
<TableCell>{execution.targetId ?? "-"}</TableCell>
<TableCell>{execution.target?.domainID ?? "-"}</TableCell>
<TableCell className={styles.monospace}>{execution.jsonData ? execution.jsonData.slice(0, 80) : "-"}</TableCell>
<TableCell>{execution.created ? new Date(execution.created).toLocaleString() : "-"}</TableCell>
<TableCell>{execution.modified ? new Date(execution.modified).toLocaleString() : "-"}</TableCell>
<TableCell>{execution.id}</TableCell> <TableCell>{execution.id}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</> </section>
</div>
); );
} }

View File

@@ -1,9 +1,16 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Checkbox, Checkbox,
Combobox, Combobox,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field, Field,
Input,
makeStyles, makeStyles,
Option, Option,
Table, Table,
@@ -14,12 +21,11 @@ import {
TableRow, TableRow,
Tooltip, Tooltip,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { OpenRegular } from "@fluentui/react-icons"; import { AddRegular, DeleteRegular, OpenRegular } from "@fluentui/react-icons";
import { useState } from "react"; import { useMemo, 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";
import { DataState } from "../components/DataState"; import { DataState } from "../components/DataState";
import { FormActions, FormGrid, FormSection } from "../components/FormSection";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
const useStyles = makeStyles({ const useStyles = makeStyles({
@@ -34,8 +40,10 @@ export function DeploymentGroupsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [serviceId, setServiceId] = useState(""); const [serviceId, setServiceId] = useState("");
const [templateId, setTemplateId] = useState(""); const [templateId, setTemplateId] = useState("");
const [virtualMachineIds, setVirtualMachineIds] = useState<string[]>([]); const [deploymentRuleId, setDeploymentRuleId] = useState("");
const [status, setStatus] = useState("New"); const [targetIds, setTargetIds] = useState<string[]>([]);
const [targetSearch, setTargetSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const { data, error, isLoading } = useQuery({ const { data, error, isLoading } = useQuery({
queryKey: ["deployment-batches"], queryKey: ["deployment-batches"],
queryFn: ({ signal }) => portalApi.getDeploymentBatches(signal), queryFn: ({ signal }) => portalApi.getDeploymentBatches(signal),
@@ -52,17 +60,29 @@ export function DeploymentGroupsPage() {
queryKey: ["template-categories"], queryKey: ["template-categories"],
queryFn: ({ signal }) => portalApi.getTemplateCategories(signal), queryFn: ({ signal }) => portalApi.getTemplateCategories(signal),
}); });
const { data: virtualMachines } = useQuery({ const { data: targets } = useQuery({
queryKey: ["virtual-machines"], queryKey: ["targets"],
queryFn: ({ signal }) => portalApi.getVirtualMachines(signal), queryFn: ({ signal }) => portalApi.getTargets(signal),
});
const { data: deploymentRules } = useQuery({
queryKey: ["deployment-rules"],
queryFn: ({ signal }) => portalApi.getDeploymentRules(signal),
}); });
const addDeploymentBatch = useMutation({ const addDeploymentBatch = useMutation({
mutationFn: portalApi.addDeploymentBatch, mutationFn: portalApi.addDeploymentBatch,
onSuccess: async () => { onSuccess: async () => {
setServiceId(""); setServiceId("");
setTemplateId(""); setTemplateId("");
setVirtualMachineIds([]); setDeploymentRuleId("");
setStatus("New"); setTargetIds([]);
setDialogOpen(false);
await queryClient.invalidateQueries({ queryKey: ["deployment-batches"] });
await queryClient.invalidateQueries({ queryKey: ["deployments"] });
},
});
const deleteDeploymentBatch = useMutation({
mutationFn: portalApi.deleteDeploymentBatch,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["deployment-batches"] }); await queryClient.invalidateQueries({ queryKey: ["deployment-batches"] });
await queryClient.invalidateQueries({ queryKey: ["deployments"] }); await queryClient.invalidateQueries({ queryKey: ["deployments"] });
}, },
@@ -70,21 +90,89 @@ export function DeploymentGroupsPage() {
const selectedService = (services ?? []).find((service) => service.id === serviceId); const selectedService = (services ?? []).find((service) => service.id === serviceId);
const isOnPremService = selectedService ? !selectedService.isCloudService : false; const isOnPremService = selectedService ? !selectedService.isCloudService : false;
const filteredTargets = useMemo(() => {
const search = targetSearch.trim().toLowerCase();
if (!search) {
return targets ?? [];
}
return (targets ?? []).filter((target) => {
const name = target.name?.toLowerCase() ?? "";
const id = target.id?.toLowerCase() ?? "";
return name.includes(search) || id.includes(search);
});
}, [targetSearch, targets]);
const allVisibleSelected =
filteredTargets.length > 0 &&
filteredTargets.every((target) => targetIds.includes(target.id));
return ( return (
<> <>
<PageHeader title="Deployments" description="Deployment Batches und deren Ausfuehrungen." /> <PageHeader title="Deployments" description="Deployment Batches und deren Ausfuehrungen." />
<FormSection <Button appearance="primary" icon={<AddRegular />} onClick={() => setDialogOpen(true)}>
onSubmit={(event) => { Neues Deployment
event.preventDefault(); </Button>
addDeploymentBatch.mutate({ <DataState isLoading={isLoading} error={error} />
status, {data && (
templateId, <Table aria-label="Deployment batches">
virtualMachineIds: isOnPremService ? virtualMachineIds : undefined, <TableHeader>
}); <TableRow>
}} <TableHeaderCell>Service</TableHeaderCell>
> <TableHeaderCell>Template</TableHeaderCell>
<FormGrid> <TableHeaderCell>Rule</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Modified</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{data.map((deploymentBatch) => (
<TableRow key={deploymentBatch.id}>
<TableCell>
{(() => {
const template = (templates ?? []).find((entry) => entry.id === deploymentBatch.templateId);
const category = (templateCategories ?? []).find((entry) => entry.id === template?.templateCategoryId);
const service = (services ?? []).find((entry) => entry.id === category?.serviceId);
return service?.name ?? "-";
})()}
</TableCell>
<TableCell>{(templates ?? []).find((entry) => entry.id === deploymentBatch.templateId)?.name ?? "-"}</TableCell>
<TableCell>
{(deploymentRules ?? []).find((entry) => entry.id === deploymentBatch.deploymentRuleId)?.name ?? "-"}
</TableCell>
<TableCell>{deploymentBatch.status ?? "Unknown"}</TableCell>
<TableCell>{deploymentBatch.created ? new Date(deploymentBatch.created).toLocaleString() : "-"}</TableCell>
<TableCell>{deploymentBatch.modified ? new Date(deploymentBatch.modified).toLocaleString() : "-"}</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>
<Tooltip content="Delete" relationship="label">
<Button
appearance="subtle"
aria-label="Delete"
icon={<DeleteRegular />}
onClick={() => deleteDeploymentBatch.mutate(deploymentBatch.id)}
/>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Dialog open={dialogOpen} onOpenChange={(_, data) => setDialogOpen(data.open)}>
<DialogSurface>
<DialogBody>
<DialogTitle>Neues Deployment</DialogTitle>
<DialogContent>
<Field label="Service" required> <Field label="Service" required>
<Combobox <Combobox
placeholder="Service waehlen" placeholder="Service waehlen"
@@ -93,7 +181,7 @@ export function DeploymentGroupsPage() {
const nextServiceId = data.optionValue ?? ""; const nextServiceId = data.optionValue ?? "";
setServiceId(nextServiceId); setServiceId(nextServiceId);
setTemplateId(""); setTemplateId("");
setVirtualMachineIds([]); setTargetIds([]);
}} }}
> >
{(services ?? []).map((service) => ( {(services ?? []).map((service) => (
@@ -122,75 +210,98 @@ export function DeploymentGroupsPage() {
))} ))}
</Combobox> </Combobox>
</Field> </Field>
<Field label="Status" required validationMessage={addDeploymentBatch.error?.message}> <Field label="Deployment Rule">
<Combobox value={status} onOptionSelect={(_, data) => setStatus(data.optionValue ?? "New")}> <Combobox
<Option value="New">New</Option> placeholder="Rule waehlen (optional)"
<Option value="Pending">Pending</Option> value={deploymentRules?.find((rule) => rule.id === deploymentRuleId)?.name ?? ""}
<Option value="Running">Running</Option> onOptionSelect={(_, data) => setDeploymentRuleId(data.optionValue ?? "")}
<Option value="Succeeded">Succeeded</Option> >
<Option value="Failed">Failed</Option> {(deploymentRules ?? []).map((rule) => (
<Option key={rule.id} text={rule.name} value={rule.id}>
{rule.name}
</Option>
))}
</Combobox> </Combobox>
</Field> </Field>
{isOnPremService && ( {isOnPremService && (
<Field label="Virtual Machines" required> <Field label="Targets" required>
<div> <div>
{(virtualMachines ?? []).map((virtualMachine) => ( <Input
placeholder="VM suchen (Name oder Id)"
value={targetSearch}
onChange={(_, data) => setTargetSearch(data.value)}
/>
<Table aria-label="Targets selection">
<TableHeader>
<TableRow>
<TableHeaderCell>
<Checkbox <Checkbox
key={virtualMachine.id} checked={allVisibleSelected}
label={virtualMachine.name}
checked={virtualMachineIds.includes(virtualMachine.id)}
onChange={(_, data) => { onChange={(_, data) => {
if (data.checked) { if (data.checked) {
setVirtualMachineIds((prev) => [...prev, virtualMachine.id]); setTargetIds((prev) =>
Array.from(new Set([...prev, ...filteredTargets.map((vm) => vm.id)])),
);
} else { } else {
setVirtualMachineIds((prev) => prev.filter((id) => id !== virtualMachine.id)); const visibleIds = new Set(filteredTargets.map((vm) => vm.id));
setTargetIds((prev) => prev.filter((id) => !visibleIds.has(id)));
} }
}} }}
/> />
))} </TableHeaderCell>
</div> <TableHeaderCell>Name</TableHeaderCell>
</Field>
)}
</FormGrid>
<FormActions>
<Button
appearance="primary"
disabled={!templateId || !status || (isOnPremService && virtualMachineIds.length === 0) || addDeploymentBatch.isPending}
type="submit"
>
Add deployment batch
</Button>
</FormActions>
</FormSection>
<DataState isLoading={isLoading} error={error} />
{data && (
<Table aria-label="Deployment batches">
<TableHeader>
<TableRow>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell> <TableHeaderCell>Id</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.map((deploymentBatch) => ( {filteredTargets.map((target) => (
<TableRow key={deploymentBatch.id}> <TableRow key={target.id}>
<TableCell>{deploymentBatch.status ?? "Unknown"}</TableCell>
<TableCell>{deploymentBatch.id}</TableCell>
<TableCell> <TableCell>
<div className={styles.actions}> <Checkbox
<Tooltip content="Details" relationship="label"> checked={targetIds.includes(target.id)}
<Link to={`/deployments/${deploymentBatch.id}`}> onChange={(_, data) => {
<Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} /> if (data.checked) {
</Link> setTargetIds((prev) => [...prev, target.id]);
</Tooltip> } else {
</div> setTargetIds((prev) => prev.filter((entry) => entry !== target.id));
}
}}
/>
</TableCell> </TableCell>
<TableCell>{target.name}</TableCell>
<TableCell>{target.id}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
</Field>
)} )}
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={() => setDialogOpen(false)}>
Abbrechen
</Button>
<Button
appearance="primary"
disabled={!templateId || (isOnPremService && targetIds.length === 0) || addDeploymentBatch.isPending}
onClick={() =>
addDeploymentBatch.mutate({
status: "New",
templateId,
deploymentRuleId: deploymentRuleId || undefined,
targetIds: isOnPremService ? targetIds : undefined,
})
}
>
Deployment anlegen
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
</> </>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Badge, Badge,
Button, Button,
@@ -202,7 +202,7 @@ export function DeploymentJobDetailsPage() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>Status</TableHeaderCell> <TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Virtual Machine Id</TableHeaderCell> <TableHeaderCell>Target Id</TableHeaderCell>
<TableHeaderCell>Deployment Batch Id</TableHeaderCell> <TableHeaderCell>Deployment Batch Id</TableHeaderCell>
<TableHeaderCell>Template Id</TableHeaderCell> <TableHeaderCell>Template Id</TableHeaderCell>
<TableHeaderCell>Attempts</TableHeaderCell> <TableHeaderCell>Attempts</TableHeaderCell>
@@ -213,7 +213,7 @@ export function DeploymentJobDetailsPage() {
{data.targets.map((target) => ( {data.targets.map((target) => (
<TableRow key={target.id}> <TableRow key={target.id}>
<TableCell>{target.status}</TableCell> <TableCell>{target.status}</TableCell>
<TableCell>{target.virtualMachineId}</TableCell> <TableCell>{target.targetId}</TableCell>
<TableCell>{target.deploymentBatchId}</TableCell> <TableCell>{target.deploymentBatchId}</TableCell>
<TableCell>{target.templateId}</TableCell> <TableCell>{target.templateId}</TableCell>
<TableCell>{target.attempts}</TableCell> <TableCell>{target.attempts}</TableCell>
@@ -227,3 +227,5 @@ export function DeploymentJobDetailsPage() {
</> </>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Badge, Badge,
Button, Button,
@@ -227,3 +227,4 @@ export function DeploymentJobsPage() {
</> </>
); );
} }

View File

@@ -0,0 +1,355 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Checkbox,
Combobox,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field,
Input,
makeStyles,
Option,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
Textarea,
Tooltip,
} from "@fluentui/react-components";
import { AddRegular, ArrowDownRegular, ArrowUpRegular, DeleteRegular, EditRegular } from "@fluentui/react-icons";
import { useMemo, useState } from "react";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { PageHeader } from "../components/PageHeader";
import type { AddDeploymentRule, DeploymentRule, DeploymentRuleStep } from "../types/portal";
type DialogMode = "add" | "edit" | null;
const useStyles = makeStyles({
dialogSurface: {
width: "min(1200px, 96vw)",
maxWidth: "1200px",
},
stepActions: {
display: "flex",
gap: "4px",
alignItems: "center",
},
stepsTable: {
tableLayout: "fixed",
width: "100%",
},
stepCellNarrow: {
width: "52px",
},
stepCellAction: {
width: "120px",
borderLeft: "1px solid #e0e0e0",
},
stepCellName: {
width: "240px",
},
stepCellType: {
width: "140px",
},
stepCellMetadata: {
width: "420px",
},
fullWidthInput: {
width: "100%",
minWidth: 0,
},
fullWidthControl: {
width: "100%",
},
addStepButton: {
marginTop: "12px",
},
});
function createDefaultStep(sortOrder: number): DeploymentRuleStep {
return {
sortOrder,
name: `Step ${sortOrder}`,
stepType: "Deploy",
requiresApproval: false,
metadataJson: "",
};
}
export function DeploymentRulesPage() {
const styles = useStyles();
const queryClient = useQueryClient();
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
const [selectedRuleId, setSelectedRuleId] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isActive, setIsActive] = useState(true);
const [steps, setSteps] = useState<DeploymentRuleStep[]>([createDefaultStep(1)]);
const { data, error, isLoading } = useQuery({
queryKey: ["deployment-rules"],
queryFn: ({ signal }) => portalApi.getDeploymentRules(signal),
});
const addRule = useMutation({
mutationFn: portalApi.addDeploymentRule,
onSuccess: async () => {
closeDialog();
await queryClient.invalidateQueries({ queryKey: ["deployment-rules"] });
},
});
const updateRule = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: AddDeploymentRule }) =>
portalApi.updateDeploymentRule(id, payload),
onSuccess: async () => {
closeDialog();
await queryClient.invalidateQueries({ queryKey: ["deployment-rules"] });
},
});
const deleteRule = useMutation({
mutationFn: portalApi.deleteDeploymentRule,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["deployment-rules"] });
},
});
const openAddDialog = () => {
setDialogMode("add");
setSelectedRuleId("");
setName("");
setDescription("");
setIsActive(true);
setSteps([createDefaultStep(1)]);
};
const openEditDialog = (rule: DeploymentRule) => {
setDialogMode("edit");
setSelectedRuleId(rule.id);
setName(rule.name);
setDescription(rule.description ?? "");
setIsActive(rule.isActive);
setSteps(
(rule.steps?.length ? rule.steps : [createDefaultStep(1)]).map((step, index) => ({
...step,
sortOrder: index + 1,
})),
);
};
const closeDialog = () => {
setDialogMode(null);
};
const normalizedSteps = useMemo(
() =>
steps.map((step, index) => ({
...step,
sortOrder: index + 1,
})),
[steps],
);
const payload: AddDeploymentRule = {
name,
description,
isActive,
steps: normalizedSteps,
};
return (
<>
<PageHeader title="Deployment Rules" description="Low-Code Regeln fuer Deployment Pipelines gestalten." />
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
Neue Rule
</Button>
<DataState isLoading={isLoading} error={error} />
{data && (
<Table aria-label="Deployment rules">
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Steps</TableHeaderCell>
<TableHeaderCell>Modified</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{data.map((rule) => (
<TableRow key={rule.id}>
<TableCell>{rule.name}</TableCell>
<TableCell>{rule.isActive ? "Active" : "Inactive"}</TableCell>
<TableCell>{rule.steps?.length ?? 0}</TableCell>
<TableCell>{rule.modified ? new Date(rule.modified).toLocaleString() : "-"}</TableCell>
<TableCell>
<Tooltip content="Edit" relationship="label">
<Button appearance="subtle" icon={<EditRegular />} onClick={() => openEditDialog(rule)} />
</Tooltip>
<Tooltip content="Delete" relationship="label">
<Button appearance="subtle" icon={<DeleteRegular />} onClick={() => deleteRule.mutate(rule.id)} />
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
<DialogSurface className={styles.dialogSurface}>
<DialogBody>
<DialogTitle>{dialogMode === "edit" ? "Rule bearbeiten" : "Neue Rule"}</DialogTitle>
<DialogContent>
<Field label="Name" required>
<Input value={name} onChange={(_, data) => setName(data.value)} />
</Field>
<Field label="Description">
<Textarea value={description} onChange={(_, data) => setDescription(data.value)} />
</Field>
<Field label="Active">
<Checkbox checked={isActive} onChange={(_, data) => setIsActive(Boolean(data.checked))} />
</Field>
<Field label="Pipeline Steps">
<Table aria-label="Rule steps" className={styles.stepsTable}>
<TableHeader>
<TableRow>
<TableHeaderCell className={styles.stepCellNarrow}>#</TableHeaderCell>
<TableHeaderCell className={styles.stepCellName}>Name</TableHeaderCell>
<TableHeaderCell className={styles.stepCellType}>Action</TableHeaderCell>
<TableHeaderCell className={styles.stepCellMetadata}>Metadata</TableHeaderCell>
<TableHeaderCell className={styles.stepCellAction}>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{steps.map((step, index) => (
<TableRow key={`${index}-${step.name}`}>
<TableCell className={styles.stepCellNarrow}>{index + 1}</TableCell>
<TableCell className={styles.stepCellName}>
<Input
className={styles.fullWidthControl}
value={step.name}
onChange={(_, data) =>
setSteps((prev) => prev.map((entry, i) => (i === index ? { ...entry, name: data.value } : entry)))
}
/>
</TableCell>
<TableCell className={styles.stepCellType}>
<Combobox
className={styles.fullWidthControl}
value={step.stepType}
onOptionSelect={(_, data) =>
setSteps((prev) => prev.map((entry, i) => (i === index ? { ...entry, stepType: data.optionValue ?? "Deploy" } : entry)))
}
>
<Option value="Deploy">Deploy</Option>
<Option value="Validate">Validate</Option>
<Option value="Approval">Approval</Option>
<Option value="Notify">Notify</Option>
</Combobox>
</TableCell>
<TableCell className={styles.stepCellMetadata}>
<Input
placeholder={
step.stepType === "Approval"
? "z.B. approverUser / approverMail"
: step.stepType === "Deploy" || step.stepType === "Validate"
? "z.B. target=Test|Prod oder scope=VM|Domain"
: "Schluessel/Wert JSON oder Text"
}
value={step.metadataJson ?? ""}
onChange={(_, data) =>
setSteps((prev) => prev.map((entry, i) => (i === index ? { ...entry, metadataJson: data.value } : entry)))
}
/>
</TableCell>
<TableCell className={styles.stepCellAction}>
<div className={styles.stepActions}>
<Tooltip content="Nach oben" relationship="label">
<Button
appearance="subtle"
size="small"
icon={<ArrowUpRegular />}
disabled={index === 0}
onClick={() =>
setSteps((prev) => {
const clone = [...prev];
[clone[index - 1], clone[index]] = [clone[index], clone[index - 1]];
return clone;
})
}
/>
</Tooltip>
<Tooltip content="Nach unten" relationship="label">
<Button
appearance="subtle"
size="small"
icon={<ArrowDownRegular />}
disabled={index === steps.length - 1}
onClick={() =>
setSteps((prev) => {
const clone = [...prev];
[clone[index + 1], clone[index]] = [clone[index], clone[index + 1]];
return clone;
})
}
/>
</Tooltip>
<Tooltip content="Step löschen" relationship="label">
<Button
appearance="subtle"
size="small"
icon={<DeleteRegular />}
disabled={steps.length === 1}
onClick={() => setSteps((prev) => prev.filter((_, i) => i !== index))}
/>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Button
appearance="secondary"
className={styles.addStepButton}
icon={<AddRegular />}
onClick={() => setSteps((prev) => [...prev, createDefaultStep(prev.length + 1)])}
>
Step hinzufügen
</Button>
</Field>
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={closeDialog}>
Abbrechen
</Button>
<Button
appearance="primary"
disabled={!name.trim()}
onClick={() => {
if (dialogMode === "edit" && selectedRuleId) {
updateRule.mutate({ id: selectedRuleId, payload });
return;
}
addRule.mutate(payload);
}}
>
Speichern
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
</>
);
}

View File

@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Button, Button,
Checkbox, Checkbox,
@@ -23,7 +23,7 @@ import { PageHeader } from "../components/PageHeader";
export function DeploymentsPage() { export function DeploymentsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [deploymentBatchId, setDeploymentBatchId] = useState(""); const [deploymentBatchId, setDeploymentBatchId] = useState("");
const [selectedVirtualMachineIds, setSelectedVirtualMachineIds] = useState<string[]>([]); const [selectedTargetIds, setSelectedTargetIds] = useState<string[]>([]);
const [jsonData, setJsonData] = useState("{}"); const [jsonData, setJsonData] = useState("{}");
const { data, error, isLoading } = useQuery({ const { data, error, isLoading } = useQuery({
queryKey: ["deployments"], queryKey: ["deployments"],
@@ -33,9 +33,9 @@ export function DeploymentsPage() {
queryKey: ["deployment-batches"], queryKey: ["deployment-batches"],
queryFn: ({ signal }) => portalApi.getDeploymentBatches(signal), queryFn: ({ signal }) => portalApi.getDeploymentBatches(signal),
}); });
const { data: virtualMachines } = useQuery({ const { data: targets } = useQuery({
queryKey: ["virtual-machines"], queryKey: ["targets"],
queryFn: ({ signal }) => portalApi.getVirtualMachines(signal), queryFn: ({ signal }) => portalApi.getTargets(signal),
}); });
const { data: deploymentJobs, error: deploymentJobsError, isLoading: deploymentJobsLoading } = useQuery({ const { data: deploymentJobs, error: deploymentJobsError, isLoading: deploymentJobsLoading } = useQuery({
queryKey: ["deployment-jobs"], queryKey: ["deployment-jobs"],
@@ -46,7 +46,7 @@ export function DeploymentsPage() {
mutationFn: portalApi.addDeploymentRequest, mutationFn: portalApi.addDeploymentRequest,
onSuccess: async () => { onSuccess: async () => {
setDeploymentBatchId(""); setDeploymentBatchId("");
setSelectedVirtualMachineIds([]); setSelectedTargetIds([]);
setJsonData("{}"); setJsonData("{}");
await queryClient.invalidateQueries({ queryKey: ["deployments"] }); await queryClient.invalidateQueries({ queryKey: ["deployments"] });
await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] }); await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] });
@@ -65,7 +65,7 @@ export function DeploymentsPage() {
<FormSection <FormSection
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
addDeploymentRequest.mutate({ deploymentBatchId, jsonData, virtualMachineIds: selectedVirtualMachineIds }); addDeploymentRequest.mutate({ deploymentBatchId, jsonData, targetIds: selectedTargetIds });
}} }}
> >
<FormGrid> <FormGrid>
@@ -82,18 +82,18 @@ export function DeploymentsPage() {
))} ))}
</Combobox> </Combobox>
</Field> </Field>
<Field label="Targets (Virtual Machines)" required> <Field label="Targets (Targets)" required>
<div> <div>
{(virtualMachines ?? []).map((virtualMachine) => ( {(targets ?? []).map((target) => (
<Checkbox <Checkbox
key={virtualMachine.id} key={target.id}
label={virtualMachine.name} label={target.name}
checked={selectedVirtualMachineIds.includes(virtualMachine.id)} checked={selectedTargetIds.includes(target.id)}
onChange={(_, data) => { onChange={(_, data) => {
if (data.checked) { if (data.checked) {
setSelectedVirtualMachineIds((previous) => [...previous, virtualMachine.id]); setSelectedTargetIds((previous) => [...previous, target.id]);
} else { } else {
setSelectedVirtualMachineIds((previous) => previous.filter((id) => id !== virtualMachine.id)); setSelectedTargetIds((previous) => previous.filter((id) => id !== target.id));
} }
}} }}
/> />
@@ -109,7 +109,7 @@ export function DeploymentsPage() {
<FormActions> <FormActions>
<Button <Button
appearance="primary" appearance="primary"
disabled={!deploymentBatchId || selectedVirtualMachineIds.length === 0 || addDeploymentRequest.isPending} disabled={!deploymentBatchId || selectedTargetIds.length === 0 || addDeploymentRequest.isPending}
type="submit" type="submit"
> >
Start deployment Start deployment
@@ -158,7 +158,7 @@ export function DeploymentsPage() {
<TableRow> <TableRow>
<TableHeaderCell>Status</TableHeaderCell> <TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Deployment Batch</TableHeaderCell> <TableHeaderCell>Deployment Batch</TableHeaderCell>
<TableHeaderCell>Virtual Machine</TableHeaderCell> <TableHeaderCell>Target</TableHeaderCell>
<TableHeaderCell>Execution Id</TableHeaderCell> <TableHeaderCell>Execution Id</TableHeaderCell>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -167,7 +167,7 @@ export function DeploymentsPage() {
<TableRow key={deployment.id}> <TableRow key={deployment.id}>
<TableCell>{deployment.status ?? "Unknown"}</TableCell> <TableCell>{deployment.status ?? "Unknown"}</TableCell>
<TableCell>{deployment.deploymentBatchId ?? "-"}</TableCell> <TableCell>{deployment.deploymentBatchId ?? "-"}</TableCell>
<TableCell>{deployment.virtualMachineId ?? "-"}</TableCell> <TableCell>{deployment.targetId ?? "-"}</TableCell>
<TableCell>{deployment.id}</TableCell> <TableCell>{deployment.id}</TableCell>
</TableRow> </TableRow>
))} ))}
@@ -177,3 +177,5 @@ export function DeploymentsPage() {
</> </>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Badge, Badge,
Button, Button,
@@ -73,21 +73,21 @@ export function DomainDetailsPage() {
queryFn: ({ signal }) => portalApi.getDomainEnvironments(id!, signal), queryFn: ({ signal }) => portalApi.getDomainEnvironments(id!, signal),
}); });
const { const {
data: virtualMachineData, data: targetData,
error: virtualMachinesError, error: targetsError,
isLoading: virtualMachinesLoading, isLoading: targetsLoading,
} = useQuery({ } = useQuery({
enabled: Boolean(id), enabled: Boolean(id),
queryKey: ["domain", id, "virtual-machines"], queryKey: ["domain", id, "targets"],
queryFn: ({ signal }) => portalApi.getDomainVirtualMachines(id!, signal), queryFn: ({ signal }) => portalApi.getDomainTargets(id!, signal),
}); });
const links = data?.environmentDomains?.filter((link) => link.environment) ?? []; const links = data?.environmentDomains?.filter((link) => link.environment) ?? [];
const filteredVirtualMachines = useMemo(() => { const filteredTargets = useMemo(() => {
const items = [...(virtualMachineData?.virtualMachines ?? [])]; const items = [...(targetData?.targets ?? [])];
const searchValue = search.trim().toLowerCase(); const searchValue = search.trim().toLowerCase();
const filteredItems = searchValue.length const filteredItems = searchValue.length
? items.filter((virtualMachine) => ? items.filter((target) =>
[virtualMachine.name, virtualMachine.externalId, virtualMachine.id] [target.name, target.externalId, target.id]
.filter(Boolean) .filter(Boolean)
.some((value) => value!.toLowerCase().includes(searchValue)), .some((value) => value!.toLowerCase().includes(searchValue)),
) )
@@ -101,7 +101,7 @@ export function DomainDetailsPage() {
}); });
return filteredItems; return filteredItems;
}, [virtualMachineData?.virtualMachines, search, sortBy, sortOrder]); }, [targetData?.targets, search, sortBy, sortOrder]);
const toggleSort = (column: "name" | "externalId" | "id") => { const toggleSort = (column: "name" | "externalId" | "id") => {
if (sortBy === column) { if (sortBy === column) {
@@ -115,7 +115,7 @@ export function DomainDetailsPage() {
const sortIndicator = (column: "name" | "externalId" | "id") => { const sortIndicator = (column: "name" | "externalId" | "id") => {
if (sortBy !== column) return ""; if (sortBy !== column) return "";
return sortOrder === "asc" ? " " : " "; return sortOrder === "asc" ? " ↑" : " ↓";
}; };
return ( return (
@@ -126,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 || virtualMachinesLoading} error={error ?? virtualMachinesError} /> <DataState isLoading={isLoading || targetsLoading} error={error ?? targetsError} />
{data && ( {data && (
<> <>
<section className={styles.details} aria-label="Domain details"> <section className={styles.details} aria-label="Domain details">
@@ -196,7 +196,7 @@ export function DomainDetailsPage() {
</Table> </Table>
)} )}
<Title3 className={styles.sectionTitle}>Virtual Machines</Title3> <Title3 className={styles.sectionTitle}>Targets</Title3>
<div style={{ display: "grid", gap: "12px", gridTemplateColumns: "minmax(220px, 1fr)", marginBottom: "12px" }}> <div style={{ display: "grid", gap: "12px", gridTemplateColumns: "minmax(220px, 1fr)", marginBottom: "12px" }}>
<Field label="Search"> <Field label="Search">
<Input <Input
@@ -206,12 +206,12 @@ export function DomainDetailsPage() {
/> />
</Field> </Field>
</div> </div>
{filteredVirtualMachines.length === 0 ? ( {filteredTargets.length === 0 ? (
<MessageBar> <MessageBar>
<MessageBarBody>Keine Virtual Machines fuer diese Domain gefunden.</MessageBarBody> <MessageBarBody>Keine Targets fuer diese Domain gefunden.</MessageBarBody>
</MessageBar> </MessageBar>
) : ( ) : (
<Table aria-label="Domain virtual machines"> <Table aria-label="Domain targets">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell onClick={() => toggleSort("name")} style={{ cursor: "pointer" }}> <TableHeaderCell onClick={() => toggleSort("name")} style={{ cursor: "pointer" }}>
@@ -226,13 +226,13 @@ export function DomainDetailsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredVirtualMachines.map((virtualMachine) => ( {filteredTargets.map((target) => (
<TableRow key={virtualMachine.id}> <TableRow key={target.id}>
<TableCell> <TableCell>
<Link to={`/virtual-machines/${virtualMachine.id}`}>{virtualMachine.name}</Link> <Link to={`/targets/${target.id}`}>{target.name}</Link>
</TableCell> </TableCell>
<TableCell>{virtualMachine.externalId ?? "-"}</TableCell> <TableCell>{target.externalId ?? "-"}</TableCell>
<TableCell>{virtualMachine.id}</TableCell> <TableCell>{target.id}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -243,3 +243,5 @@ export function DomainDetailsPage() {
</> </>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Combobox, Combobox,
@@ -308,3 +308,4 @@ export function DomainsPage() {
</> </>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Badge, Badge,
Button, Button,
@@ -17,7 +17,7 @@ import {
tokens, tokens,
Tooltip, Tooltip,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { ArrowLeftRegular, OpenRegular } from "@fluentui/react-icons"; import { ArrowLeftRegular, DeleteRegular, 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";
@@ -59,6 +59,7 @@ const useStyles = makeStyles({
export function EnvironmentDetailsPage() { export function EnvironmentDetailsPage() {
const styles = useStyles(); const styles = useStyles();
const queryClient = useQueryClient();
const { id } = useParams(); const { id } = useParams();
const { data, error, isLoading } = useQuery({ const { data, error, isLoading } = useQuery({
enabled: Boolean(id), enabled: Boolean(id),
@@ -66,6 +67,15 @@ export function EnvironmentDetailsPage() {
queryFn: ({ signal }) => portalApi.getEnvironmentDomains(id!, signal), queryFn: ({ signal }) => portalApi.getEnvironmentDomains(id!, signal),
}); });
const links = data?.environmentDomains?.filter((link) => link.domain) ?? []; const links = data?.environmentDomains?.filter((link) => link.domain) ?? [];
const unlinkDomainFromEnvironment = useMutation({
mutationFn: ({ domainId, environmentId }: { domainId: string; environmentId: string }) =>
portalApi.unlinkDomainFromEnvironment(domainId, environmentId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["environment", id, "domains"] });
await queryClient.invalidateQueries({ queryKey: ["domains"] });
await queryClient.invalidateQueries({ queryKey: ["environments"] });
},
});
return ( return (
<> <>
@@ -92,24 +102,30 @@ export function EnvironmentDetailsPage() {
</Text> </Text>
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<Text size={200}>Environment Type</Text> <Text size={200}>Environment Stage</Text>
<Text className={styles.value} weight="semibold"> <Text className={styles.value} weight="semibold">
{data.environmentType} {data.environmentType}
</Text> </Text>
</div> </div>
<div className={styles.field}>
<Text size={200}>Hosting Type</Text>
<Text className={styles.value} weight="semibold">
{data.hostingType}
</Text>
</div>
<div className={styles.field}> <div className={styles.field}>
<Text size={200}>Cloud Enabled</Text> <Text size={200}>Cloud Enabled</Text>
<Text className={styles.value} weight="semibold"> <Text className={styles.value} weight="semibold">
{data.environmentType === "OnPrem" ? "Nein" : "Ja"} {data.hostingType === "OnPrem" ? "Nein" : "Ja"}
</Text> </Text>
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<Text size={200}>Provider Type</Text> <Text size={200}>Provider Type</Text>
<Text className={styles.value} weight="semibold"> <Text className={styles.value} weight="semibold">
{data.environmentType === "OnPrem" ? data.providerType : ""} {data.hostingType === "OnPrem" ? data.providerType : ""}
</Text> </Text>
</div> </div>
{data.environmentType !== "OnPrem" && ( {data.hostingType !== "OnPrem" && (
<div className={styles.field}> <div className={styles.field}>
<Text size={200}>Tenant Id</Text> <Text size={200}>Tenant Id</Text>
<Text className={styles.value} weight="semibold"> <Text className={styles.value} weight="semibold">
@@ -117,7 +133,7 @@ export function EnvironmentDetailsPage() {
</Text> </Text>
</div> </div>
)} )}
{data.environmentType === "AzureTenant" && ( {data.hostingType === "AzureTenant" && (
<div className={styles.field}> <div className={styles.field}>
<Text size={200}>Subscription Id</Text> <Text size={200}>Subscription Id</Text>
<Text className={styles.value} weight="semibold"> <Text className={styles.value} weight="semibold">
@@ -140,7 +156,7 @@ export function EnvironmentDetailsPage() {
<TableHeaderCell>FQDN</TableHeaderCell> <TableHeaderCell>FQDN</TableHeaderCell>
<TableHeaderCell>NetBIOS</TableHeaderCell> <TableHeaderCell>NetBIOS</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell> <TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Details</TableHeaderCell> <TableHeaderCell>Actions</TableHeaderCell>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -161,6 +177,21 @@ export function EnvironmentDetailsPage() {
<Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} /> <Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} />
</Link> </Link>
</Tooltip> </Tooltip>
<Tooltip content="Unlink" relationship="label">
<Button
appearance="subtle"
aria-label="Unlink"
disabled={unlinkDomainFromEnvironment.isPending}
icon={<DeleteRegular />}
onClick={() => {
if (!id) return;
unlinkDomainFromEnvironment.mutate({
domainId: link.domain!.id,
environmentId: id,
});
}}
/>
</Tooltip>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -173,3 +204,4 @@ export function EnvironmentDetailsPage() {
</> </>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { import {
Button, Button,
Combobox, Combobox,
@@ -102,3 +102,4 @@ export function EnvironmentDomainsPage() {
</> </>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Combobox, Combobox,
@@ -56,7 +56,8 @@ 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 [environmentType, setEnvironmentType] = useState("Test");
const [hostingType, setHostingType] = useState("OnPrem");
const [providerType, setProviderType] = useState(""); const [providerType, setProviderType] = useState("");
const [tenantId, setTenantId] = useState(""); const [tenantId, setTenantId] = useState("");
const [subscriptionId, setSubscriptionId] = useState(""); const [subscriptionId, setSubscriptionId] = useState("");
@@ -76,7 +77,8 @@ export function EnvironmentsPage() {
setDialogMode(null); setDialogMode(null);
setSelectedEnvironment(null); setSelectedEnvironment(null);
setName(""); setName("");
setEnvironmentType("OnPrem"); setEnvironmentType("Test");
setHostingType("OnPrem");
setProviderType(""); setProviderType("");
setTenantId(""); setTenantId("");
setSubscriptionId(""); setSubscriptionId("");
@@ -86,7 +88,8 @@ export function EnvironmentsPage() {
const openAddDialog = () => { const openAddDialog = () => {
setSelectedEnvironment(null); setSelectedEnvironment(null);
setName(""); setName("");
setEnvironmentType("OnPrem"); setEnvironmentType("Test");
setHostingType("OnPrem");
setProviderType(""); setProviderType("");
setTenantId(""); setTenantId("");
setSubscriptionId(""); setSubscriptionId("");
@@ -97,7 +100,8 @@ export function EnvironmentsPage() {
const openEditDialog = (environment: EnvironmentItem) => { const openEditDialog = (environment: EnvironmentItem) => {
setSelectedEnvironment(environment); setSelectedEnvironment(environment);
setName(environment.name); setName(environment.name);
setEnvironmentType(environment.environmentType ?? "OnPrem"); setEnvironmentType(environment.environmentType ?? "Test");
setHostingType(environment.hostingType ?? "OnPrem");
setProviderType(environment.providerType ?? ""); setProviderType(environment.providerType ?? "");
setTenantId(environment.tenantId ?? ""); setTenantId(environment.tenantId ?? "");
setSubscriptionId(environment.subscriptionId ?? ""); setSubscriptionId(environment.subscriptionId ?? "");
@@ -121,6 +125,7 @@ export function EnvironmentsPage() {
mutationFn: ({ id, environment }: { id: string; environment: { mutationFn: ({ id, environment }: { id: string; environment: {
name: string; name: string;
environmentType: string; environmentType: string;
hostingType: string;
providerType?: string; providerType?: string;
tenantId?: string; tenantId?: string;
subscriptionId?: string; subscriptionId?: string;
@@ -155,6 +160,7 @@ export function EnvironmentsPage() {
const environment = { const environment = {
name, name,
environmentType, environmentType,
hostingType,
providerType, providerType,
tenantId, tenantId,
subscriptionId, subscriptionId,
@@ -171,9 +177,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 isOnPrem = hostingType === "OnPrem";
const isAzureTenant = environmentType === "AzureTenant"; const isAzureTenant = hostingType === "AzureTenant";
const isM365Tenant = environmentType === "M365Tenant"; const isM365Tenant = hostingType === "M365Tenant";
return ( return (
<> <>
@@ -198,12 +204,28 @@ 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> <Field label="Environment Stage" required>
<Combobox value={environmentType} onOptionSelect={(_, data) => setEnvironmentType(data.optionValue ?? "Test")}>
<Option text="Development" value="Development">
Development
</Option>
<Option text="Test" value="Test">
Test
</Option>
<Option text="QA" value="QA">
QA
</Option>
<Option text="Production" value="Production">
Production
</Option>
</Combobox>
</Field>
<Field label="Hosting Type" required>
<Combobox <Combobox
value={environmentType} value={hostingType}
onOptionSelect={(_, data) => { onOptionSelect={(_, data) => {
const nextType = data.optionValue ?? "OnPrem"; const nextType = data.optionValue ?? "OnPrem";
setEnvironmentType(nextType); setHostingType(nextType);
if (nextType === "OnPrem") { if (nextType === "OnPrem") {
setTenantId(""); setTenantId("");
@@ -352,7 +374,8 @@ export function EnvironmentsPage() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>Name</TableHeaderCell> <TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Type</TableHeaderCell> <TableHeaderCell>Stage</TableHeaderCell>
<TableHeaderCell>Hosting</TableHeaderCell>
<TableHeaderCell>Cloud</TableHeaderCell> <TableHeaderCell>Cloud</TableHeaderCell>
<TableHeaderCell>Provider</TableHeaderCell> <TableHeaderCell>Provider</TableHeaderCell>
<TableHeaderCell>Tenant</TableHeaderCell> <TableHeaderCell>Tenant</TableHeaderCell>
@@ -366,10 +389,11 @@ export function EnvironmentsPage() {
<TableRow key={environment.id}> <TableRow key={environment.id}>
<TableCell>{environment.name}</TableCell> <TableCell>{environment.name}</TableCell>
<TableCell>{environment.environmentType}</TableCell> <TableCell>{environment.environmentType}</TableCell>
<TableCell>{environment.environmentType === "OnPrem" ? "Nein" : "Ja"}</TableCell> <TableCell>{environment.hostingType}</TableCell>
<TableCell>{environment.environmentType === "OnPrem" ? environment.providerType : ""}</TableCell> <TableCell>{environment.hostingType === "OnPrem" ? "Nein" : "Ja"}</TableCell>
<TableCell>{environment.environmentType !== "OnPrem" ? environment.tenantId : ""}</TableCell> <TableCell>{environment.hostingType === "OnPrem" ? environment.providerType : ""}</TableCell>
<TableCell>{environment.environmentType === "AzureTenant" ? environment.subscriptionId : ""}</TableCell> <TableCell>{environment.hostingType !== "OnPrem" ? environment.tenantId : ""}</TableCell>
<TableCell>{environment.hostingType === "AzureTenant" ? environment.subscriptionId : ""}</TableCell>
<TableCell>{environment.id}</TableCell> <TableCell>{environment.id}</TableCell>
<TableCell> <TableCell>
<div className={styles.actions}> <div className={styles.actions}>
@@ -409,3 +433,4 @@ export function EnvironmentsPage() {
</> </>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Field, Field,
@@ -87,3 +87,4 @@ export function RunbooksPage() {
</> </>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Checkbox, Checkbox,
@@ -573,3 +573,4 @@ export function ServicesPage() {
</> </>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Badge, Badge,
Button, Button,
@@ -39,32 +39,44 @@ const useStyles = makeStyles({
}, },
}); });
export function VirtualMachineDetailsPage() { export function TargetDetailsPage() {
const styles = useStyles(); const styles = useStyles();
const { id } = useParams(); const { id } = useParams();
const { data, error, isLoading } = useQuery({ const { data, error, isLoading } = useQuery({
enabled: Boolean(id), enabled: Boolean(id),
queryKey: ["virtual-machine", id], queryKey: ["target", id],
queryFn: ({ signal }) => portalApi.getVirtualMachineById(id!, signal), queryFn: ({ signal }) => portalApi.getTargetById(id!, signal),
}); });
return ( return (
<> <>
<Link className={styles.backLink} to="/virtual-machines"> <Link className={styles.backLink} to="/targets">
<Button appearance="subtle" icon={<ArrowLeftRegular />}> <Button appearance="subtle" icon={<ArrowLeftRegular />}>
Virtual Machines Targets
</Button> </Button>
</Link> </Link>
<PageHeader title={data?.name ?? "Virtual Machine"} description="Details zur Virtual Machine." /> <PageHeader title={data?.name ?? "Target"} description="Details zum Deployment-Ziel." />
<DataState isLoading={isLoading} error={error} /> <DataState isLoading={isLoading} error={error} />
{data && ( {data && (
<section className={styles.details} aria-label="Virtual machine details"> <section className={styles.details} aria-label="Target details">
<div className={styles.field}> <div className={styles.field}>
<Text size={200}>Name</Text> <Text size={200}>Name</Text>
<Text className={styles.value} weight="semibold"> <Text className={styles.value} weight="semibold">
{data.name} {data.name}
</Text> </Text>
</div> </div>
<div className={styles.field}>
<Text size={200}>Target Type</Text>
<Text className={styles.value} weight="semibold">
{data.targetType}
</Text>
</div>
<div className={styles.field}>
<Text size={200}>Provider Type</Text>
<Text className={styles.value} weight="semibold">
{data.providerType}
</Text>
</div>
<div className={styles.field}> <div className={styles.field}>
<Text size={200}>Domain Link Status</Text> <Text size={200}>Domain Link Status</Text>
<Badge appearance={data.domainID ? "filled" : "tint"} color={data.domainID ? "success" : "informative"}> <Badge appearance={data.domainID ? "filled" : "tint"} color={data.domainID ? "success" : "informative"}>
@@ -100,3 +112,4 @@ export function VirtualMachineDetailsPage() {
</> </>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Combobox, Combobox,
@@ -27,7 +27,7 @@ import { 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 { Domain, VirtualMachine } from "../types/portal"; import type { Domain, Target } from "../types/portal";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
const useStyles = makeStyles({ const useStyles = makeStyles({
@@ -52,22 +52,24 @@ const useStyles = makeStyles({
type DialogMode = "add" | "edit" | null; type DialogMode = "add" | "edit" | null;
export function VirtualMachinesPage() { export function TargetsPage() {
const styles = useStyles(); const styles = useStyles();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [dialogMode, setDialogMode] = useState<DialogMode>(null); const [dialogMode, setDialogMode] = useState<DialogMode>(null);
const [selectedVirtualMachine, setSelectedVirtualMachine] = useState<VirtualMachine | null>(null); const [selectedTarget, setSelectedTarget] = useState<Target | null>(null);
const [domainID, setDomainID] = useState<string | undefined>(undefined); const [domainID, setDomainID] = useState<string | undefined>(undefined);
const [linkVirtualMachineId, setLinkVirtualMachineId] = useState(""); const [linkTargetId, setLinkTargetId] = useState("");
const [linkDomainId, setLinkDomainId] = useState(""); const [linkDomainId, setLinkDomainId] = useState("");
const [unlinkVirtualMachineId, setUnlinkVirtualMachineId] = useState(""); const [unlinkTargetId, setUnlinkTargetId] = useState("");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [targetType, setTargetType] = useState("VirtualMachine");
const [providerType, setProviderType] = useState("OnPrem");
const [externalId, setExternalId] = useState(""); const [externalId, setExternalId] = useState("");
const [metadataJson, setMetadataJson] = useState(""); const [metadataJson, setMetadataJson] = useState("");
const { data, error, isLoading } = useQuery({ const { data, error, isLoading } = useQuery({
queryKey: ["virtual-machines"], queryKey: ["targets"],
queryFn: ({ signal }) => portalApi.getVirtualMachines(signal), queryFn: ({ signal }) => portalApi.getTargets(signal),
}); });
const { data: domains, error: domainsError, isLoading: domainsLoading } = useQuery({ const { data: domains, error: domainsError, isLoading: domainsLoading } = useQuery({
queryKey: ["domains"], queryKey: ["domains"],
@@ -76,110 +78,116 @@ export function VirtualMachinesPage() {
const closeDialog = () => { const closeDialog = () => {
setDialogMode(null); setDialogMode(null);
setSelectedVirtualMachine(null); setSelectedTarget(null);
setDomainID(undefined); setDomainID(undefined);
setName(""); setName("");
setTargetType("VirtualMachine");
setProviderType("OnPrem");
setExternalId(""); setExternalId("");
setMetadataJson(""); setMetadataJson("");
}; };
const openAddDialog = () => { const openAddDialog = () => {
setSelectedVirtualMachine(null); setSelectedTarget(null);
setDomainID(undefined); setDomainID(undefined);
setName(""); setName("");
setTargetType("VirtualMachine");
setProviderType("OnPrem");
setExternalId(""); setExternalId("");
setMetadataJson(""); setMetadataJson("");
setDialogMode("add"); setDialogMode("add");
}; };
const openVirtualMachineDialog = (mode: "edit", virtualMachine: VirtualMachine) => { const openTargetDialog = (mode: "edit", target: Target) => {
setSelectedVirtualMachine(virtualMachine); setSelectedTarget(target);
setDomainID(virtualMachine.domainID); setDomainID(target.domainID);
setName(virtualMachine.name); setName(target.name);
setExternalId(virtualMachine.externalId ?? ""); setTargetType(target.targetType ?? "VirtualMachine");
setMetadataJson(virtualMachine.metadataJson ?? ""); setProviderType(target.providerType ?? "OnPrem");
setExternalId(target.externalId ?? "");
setMetadataJson(target.metadataJson ?? "");
setDialogMode(mode); setDialogMode(mode);
}; };
const addVirtualMachine = useMutation({ const addTarget = useMutation({
mutationFn: portalApi.addVirtualMachine, mutationFn: portalApi.addTarget,
onSuccess: async () => { onSuccess: async () => {
closeDialog(); closeDialog();
await queryClient.invalidateQueries({ queryKey: ["virtual-machines"] }); await queryClient.invalidateQueries({ queryKey: ["targets"] });
}, },
}); });
const updateVirtualMachine = useMutation({ const updateTarget = useMutation({
mutationFn: ({ id, virtualMachine }: { id: string; virtualMachine: { name: string; externalId?: string; metadataJson?: string } }) => mutationFn: ({ id, target }: { id: string; target: { name: string; targetType: string; providerType: string; externalId?: string; metadataJson?: string } }) =>
portalApi.updateVirtualMachine(id, virtualMachine), portalApi.updateTarget(id, target),
onSuccess: async () => { onSuccess: async () => {
closeDialog(); closeDialog();
await queryClient.invalidateQueries({ queryKey: ["virtual-machines"] }); await queryClient.invalidateQueries({ queryKey: ["targets"] });
}, },
}); });
const deleteVirtualMachine = useMutation({ const deleteTarget = useMutation({
mutationFn: portalApi.deleteVirtualMachine, mutationFn: portalApi.deleteTarget,
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["virtual-machines"] }); await queryClient.invalidateQueries({ queryKey: ["targets"] });
}, },
}); });
const linkVirtualMachineToDomain = useMutation({ const linkTargetToDomain = useMutation({
mutationFn: ({ virtualMachineId, targetDomainId }: { virtualMachineId: string; targetDomainId: string }) => mutationFn: ({ targetId, targetDomainId }: { targetId: string; targetDomainId: string }) =>
portalApi.linkVirtualMachineToDomain(virtualMachineId, targetDomainId), portalApi.linkTargetToDomain(targetId, targetDomainId),
onSuccess: async () => { onSuccess: async () => {
setLinkVirtualMachineId(""); setLinkTargetId("");
setLinkDomainId(""); setLinkDomainId("");
await queryClient.invalidateQueries({ queryKey: ["virtual-machines"] }); await queryClient.invalidateQueries({ queryKey: ["targets"] });
}, },
}); });
const unlinkVirtualMachineFromDomain = useMutation({ const unlinkTargetFromDomain = useMutation({
mutationFn: (virtualMachineId: string) => portalApi.unlinkVirtualMachineFromDomain(virtualMachineId), mutationFn: (targetId: string) => portalApi.unlinkTargetFromDomain(targetId),
onSuccess: async () => { onSuccess: async () => {
setUnlinkVirtualMachineId(""); setUnlinkTargetId("");
await queryClient.invalidateQueries({ queryKey: ["virtual-machines"] }); await queryClient.invalidateQueries({ queryKey: ["targets"] });
}, },
}); });
const submitVirtualMachine = (event: React.FormEvent<HTMLFormElement>) => { const submitTarget = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
const virtualMachine = { domainID, name, externalId, metadataJson }; const target = { domainID, name, targetType, providerType, externalId, metadataJson };
if (dialogMode === "edit" && selectedVirtualMachine) { if (dialogMode === "edit" && selectedTarget) {
updateVirtualMachine.mutate({ id: selectedVirtualMachine.id, virtualMachine: { name, externalId, metadataJson } }); updateTarget.mutate({ id: selectedTarget.id, target: { name, targetType, providerType, externalId, metadataJson } });
return; return;
} }
addVirtualMachine.mutate(virtualMachine); addTarget.mutate(target);
}; };
const formError = const formError =
addVirtualMachine.error?.message ?? addTarget.error?.message ??
updateVirtualMachine.error?.message ?? updateTarget.error?.message ??
linkVirtualMachineToDomain.error?.message ?? linkTargetToDomain.error?.message ??
unlinkVirtualMachineFromDomain.error?.message; unlinkTargetFromDomain.error?.message;
const isSaving = addVirtualMachine.isPending || updateVirtualMachine.isPending; const isSaving = addTarget.isPending || updateTarget.isPending;
const domainNameById = new Map((domains ?? []).map((domain: Domain) => [domain.id, domain.name])); const domainNameById = new Map((domains ?? []).map((domain: Domain) => [domain.id, domain.name]));
const linkedVirtualMachines = (data ?? []).filter((virtualMachine) => Boolean(virtualMachine.domainID)); const linkedTargets = (data ?? []).filter((target) => Boolean(target.domainID));
const unlinkedVirtualMachines = (data ?? []).filter((virtualMachine) => !virtualMachine.domainID); const unlinkedTargets = (data ?? []).filter((target) => !target.domainID);
return ( return (
<> <>
<PageHeader title="Virtual Machines" description="Ziele fuer Deployments verwalten." /> <PageHeader title="Targets" description="Generische Ziele fuer On-Prem-, Hybrid- und Cloud-Deployments verwalten." />
<div className={styles.toolbar}> <div className={styles.toolbar}>
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}> <Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
Virtual Machine hinzufuegen Target hinzufuegen
</Button> </Button>
<Button <Button
appearance="secondary" appearance="secondary"
icon={<LinkRegular />} icon={<LinkRegular />}
onClick={() => { onClick={() => {
setLinkVirtualMachineId(unlinkedVirtualMachines[0]?.id ?? ""); setLinkTargetId(unlinkedTargets[0]?.id ?? "");
setLinkDomainId(domains?.[0]?.id ?? ""); setLinkDomainId(domains?.[0]?.id ?? "");
}} }}
disabled={!unlinkedVirtualMachines.length || !domains?.length} disabled={!unlinkedTargets.length || !domains?.length}
style={{ marginLeft: "10px" }} style={{ marginLeft: "10px" }}
> >
Link to Domain Link to Domain
@@ -188,9 +196,9 @@ export function VirtualMachinesPage() {
appearance="secondary" appearance="secondary"
icon={<LinkDismissRegular />} icon={<LinkDismissRegular />}
onClick={() => { onClick={() => {
setUnlinkVirtualMachineId(linkedVirtualMachines[0]?.id ?? ""); setUnlinkTargetId(linkedTargets[0]?.id ?? "");
}} }}
disabled={!linkedVirtualMachines.length} disabled={!linkedTargets.length}
style={{ marginLeft: "10px" }} style={{ marginLeft: "10px" }}
> >
Unlink Domain Unlink Domain
@@ -199,12 +207,12 @@ export function VirtualMachinesPage() {
<Dialog open={dialogMode !== null} onOpenChange={(_, dialogData) => !dialogData.open && closeDialog()}> <Dialog open={dialogMode !== null} onOpenChange={(_, dialogData) => !dialogData.open && closeDialog()}>
<DialogSurface> <DialogSurface>
<form onSubmit={submitVirtualMachine}> <form onSubmit={submitTarget}>
<DialogBody> <DialogBody>
<DialogTitle> <DialogTitle>
{dialogMode === "edit" {dialogMode === "edit"
? "Virtual Machine aendern" ? "Target aendern"
: "Virtual Machine hinzufuegen"} : "Target hinzufuegen"}
</DialogTitle> </DialogTitle>
<DialogContent className={styles.form}> <DialogContent className={styles.form}>
<Field label="Domain (optional)" validationMessage={formError}> <Field label="Domain (optional)" validationMessage={formError}>
@@ -224,6 +232,30 @@ export function VirtualMachinesPage() {
<Field label="Name" required> <Field label="Name" required>
<Input value={name} onChange={(_, inputData) => setName(inputData.value)} /> <Input value={name} onChange={(_, inputData) => setName(inputData.value)} />
</Field> </Field>
<Field label="Target Type" required>
<Combobox
value={targetType}
onOptionSelect={(_, optionData) => setTargetType(optionData.optionValue ?? "VirtualMachine")}
>
{["VirtualMachine", "Tenant", "Subscription", "ResourceGroup", "User", "Group", "Site", "PolicyScope"].map((type) => (
<Option key={type} text={type} value={type}>
{type}
</Option>
))}
</Combobox>
</Field>
<Field label="Provider Type" required>
<Combobox
value={providerType}
onOptionSelect={(_, optionData) => setProviderType(optionData.optionValue ?? "OnPrem")}
>
{["OnPrem", "Azure", "Microsoft365", "ExchangeOnline", "Teams"].map((provider) => (
<Option key={provider} text={provider} value={provider}>
{provider}
</Option>
))}
</Combobox>
</Field>
<Field label="External Id"> <Field label="External Id">
<Input <Input
value={externalId} value={externalId}
@@ -237,9 +269,9 @@ export function VirtualMachinesPage() {
onChange={(_, inputData) => setMetadataJson(inputData.value)} onChange={(_, inputData) => setMetadataJson(inputData.value)}
/> />
</Field> </Field>
{selectedVirtualMachine && ( {selectedTarget && (
<Field label="Id"> <Field label="Id">
<div className={styles.value}>{selectedVirtualMachine.id}</div> <div className={styles.value}>{selectedTarget.id}</div>
</Field> </Field>
)} )}
</DialogContent> </DialogContent>
@@ -256,20 +288,20 @@ export function VirtualMachinesPage() {
</DialogSurface> </DialogSurface>
</Dialog> </Dialog>
<Dialog open={Boolean(linkVirtualMachineId)} onOpenChange={(_, dialogData) => !dialogData.open && setLinkVirtualMachineId("")}> <Dialog open={Boolean(linkTargetId)} onOpenChange={(_, dialogData) => !dialogData.open && setLinkTargetId("")}>
<DialogSurface> <DialogSurface>
<DialogBody> <DialogBody>
<DialogTitle>Link Virtual Machine to Domain</DialogTitle> <DialogTitle>Link Target to Domain</DialogTitle>
<DialogContent className={styles.form}> <DialogContent className={styles.form}>
<Field label="Virtual Machine" required> <Field label="Target" required>
<Combobox <Combobox
placeholder="Virtual Machine waehlen" placeholder="Target waehlen"
value={(data ?? []).find((virtualMachine) => virtualMachine.id === linkVirtualMachineId)?.name ?? ""} value={(data ?? []).find((target) => target.id === linkTargetId)?.name ?? ""}
onOptionSelect={(_, optionData) => setLinkVirtualMachineId(optionData.optionValue ?? "")} onOptionSelect={(_, optionData) => setLinkTargetId(optionData.optionValue ?? "")}
> >
{unlinkedVirtualMachines.map((virtualMachine) => ( {unlinkedTargets.map((target) => (
<Option key={virtualMachine.id} text={virtualMachine.name} value={virtualMachine.id}> <Option key={target.id} text={target.name} value={target.id}>
{virtualMachine.name} {target.name}
</Option> </Option>
))} ))}
</Combobox> </Combobox>
@@ -289,13 +321,13 @@ export function VirtualMachinesPage() {
</Field> </Field>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button appearance="secondary" onClick={() => setLinkVirtualMachineId("")}> <Button appearance="secondary" onClick={() => setLinkTargetId("")}>
Abbrechen Abbrechen
</Button> </Button>
<Button <Button
appearance="primary" appearance="primary"
disabled={!linkVirtualMachineId || !linkDomainId || linkVirtualMachineToDomain.isPending} disabled={!linkTargetId || !linkDomainId || linkTargetToDomain.isPending}
onClick={() => linkVirtualMachineToDomain.mutate({ virtualMachineId: linkVirtualMachineId, targetDomainId: linkDomainId })} onClick={() => linkTargetToDomain.mutate({ targetId: linkTargetId, targetDomainId: linkDomainId })}
> >
Link Link
</Button> </Button>
@@ -304,33 +336,33 @@ export function VirtualMachinesPage() {
</DialogSurface> </DialogSurface>
</Dialog> </Dialog>
<Dialog open={Boolean(unlinkVirtualMachineId)} onOpenChange={(_, dialogData) => !dialogData.open && setUnlinkVirtualMachineId("")}> <Dialog open={Boolean(unlinkTargetId)} onOpenChange={(_, dialogData) => !dialogData.open && setUnlinkTargetId("")}>
<DialogSurface> <DialogSurface>
<DialogBody> <DialogBody>
<DialogTitle>Unlink Virtual Machine from Domain</DialogTitle> <DialogTitle>Unlink Target from Domain</DialogTitle>
<DialogContent className={styles.form}> <DialogContent className={styles.form}>
<Field label="Virtual Machine" required> <Field label="Target" required>
<Combobox <Combobox
placeholder="Virtual Machine waehlen" placeholder="Target waehlen"
value={(data ?? []).find((virtualMachine) => virtualMachine.id === unlinkVirtualMachineId)?.name ?? ""} value={(data ?? []).find((target) => target.id === unlinkTargetId)?.name ?? ""}
onOptionSelect={(_, optionData) => setUnlinkVirtualMachineId(optionData.optionValue ?? "")} onOptionSelect={(_, optionData) => setUnlinkTargetId(optionData.optionValue ?? "")}
> >
{linkedVirtualMachines.map((virtualMachine) => ( {linkedTargets.map((target) => (
<Option key={virtualMachine.id} text={virtualMachine.name} value={virtualMachine.id}> <Option key={target.id} text={target.name} value={target.id}>
{virtualMachine.name} {target.name}
</Option> </Option>
))} ))}
</Combobox> </Combobox>
</Field> </Field>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button appearance="secondary" onClick={() => setUnlinkVirtualMachineId("")}> <Button appearance="secondary" onClick={() => setUnlinkTargetId("")}>
Abbrechen Abbrechen
</Button> </Button>
<Button <Button
appearance="primary" appearance="primary"
disabled={!unlinkVirtualMachineId || unlinkVirtualMachineFromDomain.isPending} disabled={!unlinkTargetId || unlinkTargetFromDomain.isPending}
onClick={() => unlinkVirtualMachineFromDomain.mutate(unlinkVirtualMachineId)} onClick={() => unlinkTargetFromDomain.mutate(unlinkTargetId)}
> >
Unlink Unlink
</Button> </Button>
@@ -341,13 +373,15 @@ export function VirtualMachinesPage() {
<DataState <DataState
isLoading={isLoading || domainsLoading} isLoading={isLoading || domainsLoading}
error={error ?? domainsError ?? deleteVirtualMachine.error ?? linkVirtualMachineToDomain.error ?? unlinkVirtualMachineFromDomain.error} error={error ?? domainsError ?? deleteTarget.error ?? linkTargetToDomain.error ?? unlinkTargetFromDomain.error}
/> />
{data && ( {data && (
<Table aria-label="Virtual Machines"> <Table aria-label="Targets">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>Name</TableHeaderCell> <TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Type</TableHeaderCell>
<TableHeaderCell>Provider</TableHeaderCell>
<TableHeaderCell>Domain</TableHeaderCell> <TableHeaderCell>Domain</TableHeaderCell>
<TableHeaderCell>External Id</TableHeaderCell> <TableHeaderCell>External Id</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell> <TableHeaderCell>Id</TableHeaderCell>
@@ -355,16 +389,18 @@ export function VirtualMachinesPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.map((virtualMachine) => ( {data.map((target) => (
<TableRow key={virtualMachine.id}> <TableRow key={target.id}>
<TableCell>{virtualMachine.name}</TableCell> <TableCell>{target.name}</TableCell>
<TableCell>{virtualMachine.domainID ? (domainNameById.get(virtualMachine.domainID) ?? virtualMachine.domainID) : "-"}</TableCell> <TableCell>{target.targetType}</TableCell>
<TableCell>{virtualMachine.externalId}</TableCell> <TableCell>{target.providerType}</TableCell>
<TableCell>{virtualMachine.id}</TableCell> <TableCell>{target.domainID ? (domainNameById.get(target.domainID) ?? target.domainID) : "-"}</TableCell>
<TableCell>{target.externalId}</TableCell>
<TableCell>{target.id}</TableCell>
<TableCell> <TableCell>
<div className={styles.actions}> <div className={styles.actions}>
<Tooltip content="Details" relationship="label"> <Tooltip content="Details" relationship="label">
<Link to={`/virtual-machines/${virtualMachine.id}`}> <Link to={`/targets/${target.id}`}>
<Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} /> <Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} />
</Link> </Link>
</Tooltip> </Tooltip>
@@ -373,18 +409,18 @@ export function VirtualMachinesPage() {
appearance="subtle" appearance="subtle"
aria-label="Aendern" aria-label="Aendern"
icon={<EditRegular />} icon={<EditRegular />}
onClick={() => openVirtualMachineDialog("edit", virtualMachine)} onClick={() => openTargetDialog("edit", target)}
/> />
</Tooltip> </Tooltip>
<Tooltip content="Loeschen" relationship="label"> <Tooltip content="Loeschen" relationship="label">
<Button <Button
appearance="subtle" appearance="subtle"
aria-label="Loeschen" aria-label="Loeschen"
disabled={deleteVirtualMachine.isPending} disabled={deleteTarget.isPending}
icon={<DeleteRegular />} icon={<DeleteRegular />}
onClick={() => { onClick={() => {
if (window.confirm(`Virtual Machine "${virtualMachine.name}" wirklich loeschen?`)) { if (window.confirm(`Target "${target.name}" wirklich loeschen?`)) {
deleteVirtualMachine.mutate(virtualMachine.id); deleteTarget.mutate(target.id);
} }
}} }}
/> />
@@ -399,3 +435,4 @@ export function VirtualMachinesPage() {
</> </>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Checkbox, Checkbox,
@@ -390,3 +390,4 @@ export function TemplateCategoriesPage() {
</> </>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Combobox, Combobox,
@@ -582,3 +582,4 @@ export function TemplatesPage() {
</> </>
); );
} }

View File

@@ -1,22 +1,29 @@
export type DeploymentExecution = { export type DeploymentExecution = {
id: string; id: string;
deploymentGroupId?: string; deploymentGroupId?: string;
deploymentBatchId?: string; deploymentBatchId?: string;
virtualMachineId?: string; targetId?: string;
status?: string; status?: string;
jsonData?: string; jsonData?: string;
created?: string;
modified?: string;
target?: {
id: string;
name?: string;
domainID?: string;
};
}; };
export type AddDeploymentExecution = { export type AddDeploymentExecution = {
deploymentBatchId: string; deploymentBatchId: string;
virtualMachineId: string; targetId: string;
status: string; status: string;
jsonData: string; jsonData: string;
}; };
export type AddDeploymentRequest = { export type AddDeploymentRequest = {
deploymentBatchId: string; deploymentBatchId: string;
virtualMachineIds: string[]; targetIds: string[];
jsonData: string; jsonData: string;
}; };
@@ -36,7 +43,7 @@ export type DeploymentJob = {
export type DeploymentJobTarget = { export type DeploymentJobTarget = {
id: string; id: string;
virtualMachineId: string; targetId: string;
deploymentBatchId: string; deploymentBatchId: string;
templateId: string; templateId: string;
status: string; status: string;
@@ -73,17 +80,117 @@ export type DeploymentJobDetails = {
export type DeploymentBatch = { export type DeploymentBatch = {
id: string; id: string;
templateId?: string;
deploymentRuleId?: string;
status?: string; status?: string;
created?: string;
createdBy?: string;
modified?: string;
modifiedBy?: string;
}; };
export type DeploymentBatchDetails = DeploymentBatch & { export type DeploymentBatchDetails = DeploymentBatch & {
deployments?: DeploymentExecution[]; deployments?: DeploymentExecution[];
templateSelections?: DeploymentTemplateSelection[];
parameterValues?: DeploymentParameterValue[];
targetAssignments?: DeploymentTargetAssignment[];
}; };
export type AddDeploymentBatch = { export type AddDeploymentBatch = {
templateId: string; templateId: string;
deploymentRuleId?: string;
status: string; status: string;
virtualMachineIds?: string[]; targetIds?: string[];
};
export type DeploymentRuleStep = {
id?: string;
sortOrder: number;
name: string;
stepType: string;
requiresApproval: boolean;
metadataJson?: string;
};
export type DeploymentRule = {
id: string;
name: string;
description?: string;
isActive: boolean;
created?: string;
modified?: string;
steps: DeploymentRuleStep[];
};
export type TemplateVersion = {
id: string;
templateId: string;
version: string;
jsonData: string;
jsonHash: string;
schemaVersion: string;
isPublished: boolean;
publishedAt?: string;
publishedBy?: string;
};
export type DeploymentTemplateSelection = {
id: string;
deploymentGroupId: string;
templateVersionId: string;
templateRole: string;
sortOrder: number;
alias?: string;
templateVersion?: TemplateVersion;
};
export type AddDeploymentTemplateSelection = {
templateVersionId: string;
templateRole: string;
sortOrder?: number;
alias?: string;
};
export type DeploymentParameterValue = {
id: string;
deploymentGroupId: string;
deploymentTemplateSelectionId?: string;
name: string;
valueJson: string;
isSecretReference: boolean;
isOverride: boolean;
};
export type AddDeploymentParameterValue = {
deploymentTemplateSelectionId?: string;
name: string;
valueJson: string;
isSecretReference: boolean;
isOverride: boolean;
};
export type DeploymentTargetAssignment = {
id: string;
deploymentGroupId: string;
targetId: string;
roleKey: string;
sortOrder: number;
nodeDataJson: string;
target?: Target;
};
export type AddDeploymentTargetAssignment = {
targetId: string;
roleKey: string;
sortOrder?: number;
nodeDataJson: string;
};
export type AddDeploymentRule = {
name: string;
description?: string;
isActive: boolean;
steps: DeploymentRuleStep[];
}; };
export type Domain = { export type Domain = {
@@ -99,8 +206,8 @@ export type DomainWithEnvironments = Domain & {
}>; }>;
}; };
export type DomainWithVirtualMachines = Domain & { export type DomainWithTargets = Domain & {
virtualMachines?: VirtualMachine[]; targets?: Target[];
}; };
export type AddDomain = { export type AddDomain = {
@@ -113,6 +220,7 @@ export type EnvironmentItem = {
id: string; id: string;
name: string; name: string;
environmentType?: string; environmentType?: string;
hostingType?: string;
providerType?: string; providerType?: string;
tenantId?: string; tenantId?: string;
subscriptionId?: string; subscriptionId?: string;
@@ -128,6 +236,7 @@ export type EnvironmentWithDomains = EnvironmentItem & {
export type AddEnvironment = { export type AddEnvironment = {
name: string; name: string;
environmentType: string; environmentType: string;
hostingType: string;
providerType?: string; providerType?: string;
tenantId?: string; tenantId?: string;
subscriptionId?: string; subscriptionId?: string;
@@ -209,17 +318,22 @@ export type AddTemplateCategory = {
color?: string; color?: string;
}; };
export type VirtualMachine = { export type Target = {
id: string; id: string;
domainID?: string; domainID?: string;
name: string; name: string;
targetType: string;
providerType: string;
externalId?: string; externalId?: string;
metadataJson?: string; metadataJson?: string;
}; };
export type AddVirtualMachine = { export type AddTarget = {
domainID?: string; domainID?: string;
name: string; name: string;
targetType: string;
providerType: string;
externalId?: string; externalId?: string;
metadataJson?: string; metadataJson?: string;
}; };

3
src/vite-env.d.ts vendored
View File

@@ -1 +1,2 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />