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

438
src/pages/TargetsPage.tsx Normal file
View File

@@ -0,0 +1,438 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Combobox,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field,
Input,
makeStyles,
Option,
shorthands,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
Textarea,
Tooltip,
} from "@fluentui/react-components";
import { AddRegular, DeleteRegular, EditRegular, LinkDismissRegular, LinkRegular, OpenRegular } from "@fluentui/react-icons";
import { useState } from "react";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { PageHeader } from "../components/PageHeader";
import type { Domain, Target } from "../types/portal";
import { Link } from "react-router-dom";
const useStyles = makeStyles({
toolbar: {
display: "flex",
justifyContent: "flex-start",
marginBottom: "18px",
},
form: {
display: "grid",
gap: "14px",
},
actions: {
display: "flex",
gap: "4px",
...shorthands.padding("2px", "0"),
},
value: {
overflowWrap: "anywhere",
},
});
type DialogMode = "add" | "edit" | null;
export function TargetsPage() {
const styles = useStyles();
const queryClient = useQueryClient();
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
const [selectedTarget, setSelectedTarget] = useState<Target | null>(null);
const [domainID, setDomainID] = useState<string | undefined>(undefined);
const [linkTargetId, setLinkTargetId] = useState("");
const [linkDomainId, setLinkDomainId] = useState("");
const [unlinkTargetId, setUnlinkTargetId] = useState("");
const [name, setName] = useState("");
const [targetType, setTargetType] = useState("VirtualMachine");
const [providerType, setProviderType] = useState("OnPrem");
const [externalId, setExternalId] = useState("");
const [metadataJson, setMetadataJson] = useState("");
const { data, error, isLoading } = useQuery({
queryKey: ["targets"],
queryFn: ({ signal }) => portalApi.getTargets(signal),
});
const { data: domains, error: domainsError, isLoading: domainsLoading } = useQuery({
queryKey: ["domains"],
queryFn: ({ signal }) => portalApi.getDomains(signal),
});
const closeDialog = () => {
setDialogMode(null);
setSelectedTarget(null);
setDomainID(undefined);
setName("");
setTargetType("VirtualMachine");
setProviderType("OnPrem");
setExternalId("");
setMetadataJson("");
};
const openAddDialog = () => {
setSelectedTarget(null);
setDomainID(undefined);
setName("");
setTargetType("VirtualMachine");
setProviderType("OnPrem");
setExternalId("");
setMetadataJson("");
setDialogMode("add");
};
const openTargetDialog = (mode: "edit", target: Target) => {
setSelectedTarget(target);
setDomainID(target.domainID);
setName(target.name);
setTargetType(target.targetType ?? "VirtualMachine");
setProviderType(target.providerType ?? "OnPrem");
setExternalId(target.externalId ?? "");
setMetadataJson(target.metadataJson ?? "");
setDialogMode(mode);
};
const addTarget = useMutation({
mutationFn: portalApi.addTarget,
onSuccess: async () => {
closeDialog();
await queryClient.invalidateQueries({ queryKey: ["targets"] });
},
});
const updateTarget = useMutation({
mutationFn: ({ id, target }: { id: string; target: { name: string; targetType: string; providerType: string; externalId?: string; metadataJson?: string } }) =>
portalApi.updateTarget(id, target),
onSuccess: async () => {
closeDialog();
await queryClient.invalidateQueries({ queryKey: ["targets"] });
},
});
const deleteTarget = useMutation({
mutationFn: portalApi.deleteTarget,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["targets"] });
},
});
const linkTargetToDomain = useMutation({
mutationFn: ({ targetId, targetDomainId }: { targetId: string; targetDomainId: string }) =>
portalApi.linkTargetToDomain(targetId, targetDomainId),
onSuccess: async () => {
setLinkTargetId("");
setLinkDomainId("");
await queryClient.invalidateQueries({ queryKey: ["targets"] });
},
});
const unlinkTargetFromDomain = useMutation({
mutationFn: (targetId: string) => portalApi.unlinkTargetFromDomain(targetId),
onSuccess: async () => {
setUnlinkTargetId("");
await queryClient.invalidateQueries({ queryKey: ["targets"] });
},
});
const submitTarget = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const target = { domainID, name, targetType, providerType, externalId, metadataJson };
if (dialogMode === "edit" && selectedTarget) {
updateTarget.mutate({ id: selectedTarget.id, target: { name, targetType, providerType, externalId, metadataJson } });
return;
}
addTarget.mutate(target);
};
const formError =
addTarget.error?.message ??
updateTarget.error?.message ??
linkTargetToDomain.error?.message ??
unlinkTargetFromDomain.error?.message;
const isSaving = addTarget.isPending || updateTarget.isPending;
const domainNameById = new Map((domains ?? []).map((domain: Domain) => [domain.id, domain.name]));
const linkedTargets = (data ?? []).filter((target) => Boolean(target.domainID));
const unlinkedTargets = (data ?? []).filter((target) => !target.domainID);
return (
<>
<PageHeader title="Targets" description="Generische Ziele fuer On-Prem-, Hybrid- und Cloud-Deployments verwalten." />
<div className={styles.toolbar}>
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
Target hinzufuegen
</Button>
<Button
appearance="secondary"
icon={<LinkRegular />}
onClick={() => {
setLinkTargetId(unlinkedTargets[0]?.id ?? "");
setLinkDomainId(domains?.[0]?.id ?? "");
}}
disabled={!unlinkedTargets.length || !domains?.length}
style={{ marginLeft: "10px" }}
>
Link to Domain
</Button>
<Button
appearance="secondary"
icon={<LinkDismissRegular />}
onClick={() => {
setUnlinkTargetId(linkedTargets[0]?.id ?? "");
}}
disabled={!linkedTargets.length}
style={{ marginLeft: "10px" }}
>
Unlink Domain
</Button>
</div>
<Dialog open={dialogMode !== null} onOpenChange={(_, dialogData) => !dialogData.open && closeDialog()}>
<DialogSurface>
<form onSubmit={submitTarget}>
<DialogBody>
<DialogTitle>
{dialogMode === "edit"
? "Target aendern"
: "Target hinzufuegen"}
</DialogTitle>
<DialogContent className={styles.form}>
<Field label="Domain (optional)" validationMessage={formError}>
<Combobox
disabled={domainsLoading}
placeholder="Domain waehlen"
value={domainID ? (domainNameById.get(domainID) ?? "") : ""}
onOptionSelect={(_, optionData) => setDomainID(optionData.optionValue || undefined)}
>
{(domains ?? []).map((domain) => (
<Option key={domain.id} text={domain.name} value={domain.id}>
{domain.name}
</Option>
))}
</Combobox>
</Field>
<Field label="Name" required>
<Input value={name} onChange={(_, inputData) => setName(inputData.value)} />
</Field>
<Field label="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">
<Input
value={externalId}
onChange={(_, inputData) => setExternalId(inputData.value)}
/>
</Field>
<Field label="Metadata JSON">
<Textarea
resize="vertical"
value={metadataJson}
onChange={(_, inputData) => setMetadataJson(inputData.value)}
/>
</Field>
{selectedTarget && (
<Field label="Id">
<div className={styles.value}>{selectedTarget.id}</div>
</Field>
)}
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={closeDialog}>
Abbrechen
</Button>
<Button appearance="primary" disabled={!name || isSaving} type="submit">
Speichern
</Button>
</DialogActions>
</DialogBody>
</form>
</DialogSurface>
</Dialog>
<Dialog open={Boolean(linkTargetId)} onOpenChange={(_, dialogData) => !dialogData.open && setLinkTargetId("")}>
<DialogSurface>
<DialogBody>
<DialogTitle>Link Target to Domain</DialogTitle>
<DialogContent className={styles.form}>
<Field label="Target" required>
<Combobox
placeholder="Target waehlen"
value={(data ?? []).find((target) => target.id === linkTargetId)?.name ?? ""}
onOptionSelect={(_, optionData) => setLinkTargetId(optionData.optionValue ?? "")}
>
{unlinkedTargets.map((target) => (
<Option key={target.id} text={target.name} value={target.id}>
{target.name}
</Option>
))}
</Combobox>
</Field>
<Field label="Domain" required>
<Combobox
placeholder="Domain waehlen"
value={domains?.find((domain) => domain.id === linkDomainId)?.name ?? ""}
onOptionSelect={(_, optionData) => setLinkDomainId(optionData.optionValue ?? "")}
>
{(domains ?? []).map((domain) => (
<Option key={domain.id} text={domain.name} value={domain.id}>
{domain.name}
</Option>
))}
</Combobox>
</Field>
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={() => setLinkTargetId("")}>
Abbrechen
</Button>
<Button
appearance="primary"
disabled={!linkTargetId || !linkDomainId || linkTargetToDomain.isPending}
onClick={() => linkTargetToDomain.mutate({ targetId: linkTargetId, targetDomainId: linkDomainId })}
>
Link
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
<Dialog open={Boolean(unlinkTargetId)} onOpenChange={(_, dialogData) => !dialogData.open && setUnlinkTargetId("")}>
<DialogSurface>
<DialogBody>
<DialogTitle>Unlink Target from Domain</DialogTitle>
<DialogContent className={styles.form}>
<Field label="Target" required>
<Combobox
placeholder="Target waehlen"
value={(data ?? []).find((target) => target.id === unlinkTargetId)?.name ?? ""}
onOptionSelect={(_, optionData) => setUnlinkTargetId(optionData.optionValue ?? "")}
>
{linkedTargets.map((target) => (
<Option key={target.id} text={target.name} value={target.id}>
{target.name}
</Option>
))}
</Combobox>
</Field>
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={() => setUnlinkTargetId("")}>
Abbrechen
</Button>
<Button
appearance="primary"
disabled={!unlinkTargetId || unlinkTargetFromDomain.isPending}
onClick={() => unlinkTargetFromDomain.mutate(unlinkTargetId)}
>
Unlink
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
<DataState
isLoading={isLoading || domainsLoading}
error={error ?? domainsError ?? deleteTarget.error ?? linkTargetToDomain.error ?? unlinkTargetFromDomain.error}
/>
{data && (
<Table aria-label="Targets">
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Type</TableHeaderCell>
<TableHeaderCell>Provider</TableHeaderCell>
<TableHeaderCell>Domain</TableHeaderCell>
<TableHeaderCell>External Id</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
<TableHeaderCell>Aktionen</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{data.map((target) => (
<TableRow key={target.id}>
<TableCell>{target.name}</TableCell>
<TableCell>{target.targetType}</TableCell>
<TableCell>{target.providerType}</TableCell>
<TableCell>{target.domainID ? (domainNameById.get(target.domainID) ?? target.domainID) : "-"}</TableCell>
<TableCell>{target.externalId}</TableCell>
<TableCell>{target.id}</TableCell>
<TableCell>
<div className={styles.actions}>
<Tooltip content="Details" relationship="label">
<Link to={`/targets/${target.id}`}>
<Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} />
</Link>
</Tooltip>
<Tooltip content="Aendern" relationship="label">
<Button
appearance="subtle"
aria-label="Aendern"
icon={<EditRegular />}
onClick={() => openTargetDialog("edit", target)}
/>
</Tooltip>
<Tooltip content="Loeschen" relationship="label">
<Button
appearance="subtle"
aria-label="Loeschen"
disabled={deleteTarget.isPending}
icon={<DeleteRegular />}
onClick={() => {
if (window.confirm(`Target "${target.name}" wirklich loeschen?`)) {
deleteTarget.mutate(target.id);
}
}}
/>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
);
}