feat: Enhance Domains, Environments, Services, and Templates management

- Implemented dialog-based forms for adding and editing Domains and Environments.
- Added delete functionality for Domains and Environments with confirmation prompts.
- Introduced EnvironmentDetailsPage to display details of selected environments and their linked domains.
- Created EnvironmentDomainsPage for linking domains to environments.
- Enhanced ServicesPage with dialog support for adding, editing, and viewing service details.
- Updated TemplatesPage to manage templates with comprehensive form fields and validation.
- Improved type definitions in portal.ts to support new features and ensure type safety.
This commit is contained in:
Torsten Brendgen
2026-05-15 00:05:09 +02:00
parent fdf294cac0
commit 7080d659ef
13 changed files with 1283 additions and 86 deletions

View File

@@ -56,3 +56,63 @@ export async function postJson<TBody, TResult = unknown>(path: string, body: TBo
return text as TResult;
}
}
export async function putJson<TBody, TResult = unknown>(path: string, body: TBody): Promise<TResult> {
const response = await fetch(`${apiBaseUrl}${path}`, {
body: JSON.stringify(body),
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
method: "PUT",
});
if (!response.ok) {
const message = await response.text();
throw new ApiError(
message || `API request failed: ${response.status} ${response.statusText}`,
response.status,
);
}
const text = await response.text();
if (!text) {
return undefined as TResult;
}
try {
return JSON.parse(text) as TResult;
} catch {
return text as TResult;
}
}
export async function deleteJson<TResult = unknown>(path: string): Promise<TResult> {
const response = await fetch(`${apiBaseUrl}${path}`, {
credentials: "include",
headers: {
Accept: "application/json",
},
method: "DELETE",
});
if (!response.ok) {
const message = await response.text();
throw new ApiError(
message || `API request failed: ${response.status} ${response.statusText}`,
response.status,
);
}
const text = await response.text();
if (!text) {
return undefined as TResult;
}
try {
return JSON.parse(text) as TResult;
} catch {
return text as TResult;
}
}

View File

@@ -1,14 +1,18 @@
import { getJson, postJson } from "./httpClient";
import { deleteJson, getJson, postJson, putJson } from "./httpClient";
import type {
AddDeployment,
AddDeploymentGroup,
AddDomain,
AddEnvironment,
AddRunbook,
AddService,
AddTemplate,
Deployment,
DeploymentGroup,
Domain,
DomainWithEnvironments,
EnvironmentItem,
EnvironmentWithDomains,
Runbook,
ServiceItem,
Template,
@@ -17,17 +21,27 @@ import type {
export const portalApi = {
getDeployments: (signal?: AbortSignal) => getJson<Deployment[]>("/Deployment", signal),
addDeployment: (deployment: AddDeployment) => postJson<AddDeployment, string>("/Deployment", deployment),
getDeploymentGroups: (signal?: AbortSignal) =>
getJson<DeploymentGroup[]>("/DeploymentGroup", signal),
addDeploymentGroup: (deploymentGroup: AddDeploymentGroup) =>
postJson<AddDeploymentGroup, string>("/DeploymentGroup", deploymentGroup),
getDeploymentGroups: (signal?: AbortSignal) => getJson<DeploymentGroup[]>("/DeploymentGroup", signal),
addDeploymentGroup: (deploymentGroup: AddDeploymentGroup) => postJson<AddDeploymentGroup, string>("/DeploymentGroup", deploymentGroup),
getDomains: (signal?: AbortSignal) => getJson<Domain[]>("/Domain", signal),
getDomainEnvironments: (domainId: string, signal?: AbortSignal) => getJson<DomainWithEnvironments>(`/Domain/${domainId}/Environments`, signal),
addDomain: (domain: AddDomain) => postJson<AddDomain, string>("/Domain", domain),
updateDomain: (domainId: string, domain: AddDomain) => putJson<AddDomain, void>(`/Domain/${domainId}`, domain),
deleteDomain: (domainId: string) => deleteJson<void>(`/Domain/${domainId}`),
linkDomainToEnvironment: (domainId: string, environmentId: string) => postJson<undefined, string>(`/Domain/${domainId}/Environment/${environmentId}`, undefined),
getEnvironments: (signal?: AbortSignal) => getJson<EnvironmentItem[]>("/Environment", signal),
addEnvironment: (environment: AddEnvironment) =>
postJson<AddEnvironment, string>("/Environment", environment),
getEnvironmentDomains: (environmentId: string, signal?: AbortSignal) => getJson<EnvironmentWithDomains>(`/Environment/${environmentId}/Domains`, signal),
addEnvironment: (environment: AddEnvironment) => postJson<AddEnvironment, string>("/Environment", environment),
updateEnvironment: (environmentId: string, environment: AddEnvironment) => putJson<AddEnvironment, void>(`/Environment/${environmentId}`, environment),
deleteEnvironment: (environmentId: string) => deleteJson<void>(`/Environment/${environmentId}`),
getRunbooks: (signal?: AbortSignal) => getJson<Runbook[]>("/Runbook", signal),
addRunbook: (runbook: AddRunbook) => postJson<AddRunbook, string>("/Runbook", runbook),
getTemplates: (signal?: AbortSignal) => getJson<Template[]>("/Template", signal),
addTemplate: (template: AddTemplate) => postJson<AddTemplate, string>("/Template", template),
updateTemplate: (templateId: string, template: AddTemplate) => putJson<AddTemplate, void>(`/Template/${templateId}`, template),
deleteTemplate: (templateId: string) => deleteJson<void>(`/Template/${templateId}`),
getServices: (signal?: AbortSignal) => getJson<ServiceItem[]>("/Service", signal),
addService: (service: AddService) => postJson<AddService, string>("/Service", service),
updateService: (serviceId: string, service: AddService) => putJson<AddService, void>(`/Service/${serviceId}`, service),
deleteService: (serviceId: string) => deleteJson<void>(`/Service/${serviceId}`),
};

View File

@@ -12,6 +12,7 @@ import {
BoxMultiple24Regular,
CloudFlow24Regular,
DatabasePlugConnectedRegular,
LinkMultiple24Regular,
Globe24Regular,
Home24Regular,
PlayCircle24Regular,
@@ -71,11 +72,12 @@ const useStyles = makeStyles({
const links = [
{ to: "/", label: "Dashboard", icon: <Home24Regular /> },
{ to: "/deployments", label: "Deployments", icon: <CloudFlow24Regular /> },
{ to: "/deployment-groups", label: "Deployment Groups", icon: <ServerMultipleRegular /> },
//{ to: "/deployments", label: "Deployments", icon: <CloudFlow24Regular /> },
//{ to: "/deployment-groups", label: "Deployment Groups", icon: <ServerMultipleRegular /> },
{ to: "/domains", label: "Domains", icon: <Globe24Regular /> },
{ to: "/environment-domains", label: "Environment Domains", icon: <LinkMultiple24Regular /> },
{ to: "/environments", label: "Environments", icon: <DatabasePlugConnectedRegular /> },
{ to: "/runbooks", label: "Runbooks", icon: <PlayCircle24Regular /> },
//{ to: "/runbooks", label: "Runbooks", icon: <PlayCircle24Regular /> },
{ to: "/templates", label: "Templates", icon: <BoxMultiple24Regular /> },
{ to: "/services", label: "Services", icon: <AppsListDetail24Regular /> },
];

View File

@@ -7,7 +7,10 @@ import { AppShell } from "./layout/AppShell";
import { DashboardPage } from "./pages/DashboardPage";
import { DeploymentsPage } from "./pages/DeploymentsPage";
import { DeploymentGroupsPage } from "./pages/DeploymentGroupsPage";
import { DomainDetailsPage } from "./pages/DomainDetailsPage";
import { DomainsPage } from "./pages/DomainsPage";
import { EnvironmentDomainsPage } from "./pages/EnvironmentDomainsPage";
import { EnvironmentDetailsPage } from "./pages/EnvironmentDetailsPage";
import { EnvironmentsPage } from "./pages/EnvironmentsPage";
import { RunbooksPage } from "./pages/RunbooksPage";
import { TemplatesPage } from "./pages/TemplatesPage";
@@ -32,7 +35,10 @@ const router = createBrowserRouter([
{ path: "deployments", element: <DeploymentsPage /> },
{ path: "deployment-groups", element: <DeploymentGroupsPage /> },
{ path: "domains", element: <DomainsPage /> },
{ path: "domains/:id", element: <DomainDetailsPage /> },
{ path: "environment-domains", element: <EnvironmentDomainsPage /> },
{ path: "environments", element: <EnvironmentsPage /> },
{ path: "environments/:id", element: <EnvironmentDetailsPage /> },
{ path: "runbooks", element: <RunbooksPage /> },
{ path: "templates", element: <TemplatesPage /> },
{ path: "services", element: <ServicesPage /> },

View File

@@ -25,9 +25,9 @@ const useStyles = makeStyles({
export function DashboardPage() {
const styles = useStyles();
const deployments = useQuery({
queryKey: ["deployments"],
queryFn: ({ signal }) => portalApi.getDeployments(signal),
const domains = useQuery({
queryKey: ["domains"],
queryFn: ({ signal }) => portalApi.getDomains(signal),
});
const environments = useQuery({
queryKey: ["environments"],
@@ -42,9 +42,9 @@ export function DashboardPage() {
queryFn: ({ signal }) => portalApi.getServices(signal),
});
const error = deployments.error ?? environments.error ?? templates.error ?? services.error;
const error = domains.error ?? environments.error ?? templates.error ?? services.error;
const isLoading =
deployments.isLoading || environments.isLoading || templates.isLoading || services.isLoading;
domains.isLoading || environments.isLoading || templates.isLoading || services.isLoading;
return (
<>
@@ -52,7 +52,7 @@ export function DashboardPage() {
<DataState isLoading={isLoading} error={error} />
{!isLoading && !error && (
<div className={styles.grid}>
<MetricCard label="Deployments" value={deployments.data?.length ?? 0} />
<MetricCard label="Domains" value={domains.data?.length ?? 0} />
<MetricCard label="Environments" value={environments.data?.length ?? 0} />
<MetricCard label="Templates" value={templates.data?.length ?? 0} />
<MetricCard label="Services" value={services.data?.length ?? 0} />

View File

@@ -0,0 +1,139 @@
import { useQuery } from "@tanstack/react-query";
import {
Badge,
Button,
makeStyles,
MessageBar,
MessageBarBody,
shorthands,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
Text,
Title3,
tokens,
} from "@fluentui/react-components";
import { ArrowLeftRegular } from "@fluentui/react-icons";
import { Link, useParams } from "react-router-dom";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { PageHeader } from "../components/PageHeader";
const useStyles = makeStyles({
backLink: {
display: "inline-flex",
marginBottom: "18px",
textDecorationLine: "none",
},
details: {
display: "grid",
gap: "14px",
gridTemplateColumns: "repeat(4, minmax(160px, 1fr))",
marginBottom: "24px",
maxWidth: "980px",
...shorthands.border("1px", "solid", tokens.colorNeutralStroke2),
...shorthands.borderRadius("8px"),
...shorthands.padding("18px"),
},
field: {
display: "grid",
gap: "4px",
minWidth: 0,
},
value: {
overflowWrap: "anywhere",
},
sectionTitle: {
marginBottom: "12px",
},
});
export function DomainDetailsPage() {
const styles = useStyles();
const { id } = useParams();
const { data, error, isLoading } = useQuery({
enabled: Boolean(id),
queryKey: ["domain", id, "environments"],
queryFn: ({ signal }) => portalApi.getDomainEnvironments(id!, signal),
});
const links = data?.environmentDomains?.filter((link) => link.environment) ?? [];
return (
<>
<Link className={styles.backLink} to="/domains">
<Button appearance="subtle" icon={<ArrowLeftRegular />}>
Domains
</Button>
</Link>
<PageHeader title={data?.name ?? "Domain"} description="Details und verknuepfte Environments." />
<DataState isLoading={isLoading} error={error} />
{data && (
<>
<section className={styles.details} aria-label="Domain details">
<div className={styles.field}>
<Text size={200}>Name</Text>
<Text className={styles.value} weight="semibold">
{data.name}
</Text>
</div>
<div className={styles.field}>
<Text size={200}>FQDN</Text>
<Text className={styles.value} weight="semibold">
{data.fqdn}
</Text>
</div>
<div className={styles.field}>
<Text size={200}>NetBIOS</Text>
<Text className={styles.value} weight="semibold">
{data.netBIOS}
</Text>
</div>
<div className={styles.field}>
<Text size={200}>Id</Text>
<Text className={styles.value} weight="semibold">
{data.id}
</Text>
</div>
</section>
<Title3 className={styles.sectionTitle}>Linked Environments</Title3>
{links.length === 0 ? (
<MessageBar>
<MessageBarBody>Diese Domain ist noch mit keinem Environment verknuepft.</MessageBarBody>
</MessageBar>
) : (
<Table aria-label="Linked environments">
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Details</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{links.map((link) => (
<TableRow key={link.environment!.id}>
<TableCell>{link.environment!.name}</TableCell>
<TableCell>{link.environment!.id}</TableCell>
<TableCell>
<Badge appearance="filled" color="success">
Linked
</Badge>
</TableCell>
<TableCell>
<Link to={`/environments/${link.environment!.id}`}>Oeffnen</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
)}
</>
);
}

View File

@@ -1,23 +1,56 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field,
Input,
makeStyles,
shorthands,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
Tooltip,
} from "@fluentui/react-components";
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons";
import { useState } from "react";
import { Link } from "react-router-dom";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { FormActions, FormGrid, FormSection } from "../components/FormSection";
import { PageHeader } from "../components/PageHeader";
import type { Domain } from "../types/portal";
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"),
},
});
type DialogMode = "add" | "edit" | null;
export function DomainsPage() {
const styles = useStyles();
const queryClient = useQueryClient();
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
const [selectedDomain, setSelectedDomain] = useState<Domain | null>(null);
const [name, setName] = useState("");
const [fqdn, setFqdn] = useState("");
const [netBIOS, setNetBIOS] = useState("");
@@ -25,47 +58,109 @@ export function DomainsPage() {
queryKey: ["domains"],
queryFn: ({ signal }) => portalApi.getDomains(signal),
});
const closeDialog = () => {
setDialogMode(null);
setSelectedDomain(null);
setName("");
setFqdn("");
setNetBIOS("");
};
const openAddDialog = () => {
setSelectedDomain(null);
setName("");
setFqdn("");
setNetBIOS("");
setDialogMode("add");
};
const openEditDialog = (domain: Domain) => {
setSelectedDomain(domain);
setName(domain.name);
setFqdn(domain.fqdn);
setNetBIOS(domain.netBIOS);
setDialogMode("edit");
};
const addDomain = useMutation({
mutationFn: portalApi.addDomain,
onSuccess: async () => {
setName("");
setFqdn("");
setNetBIOS("");
closeDialog();
await queryClient.invalidateQueries({ queryKey: ["domains"] });
},
});
const updateDomain = useMutation({
mutationFn: ({ id, domain }: { id: string; domain: { name: string; fqdn: string; netBIOS: string } }) =>
portalApi.updateDomain(id, domain),
onSuccess: async () => {
closeDialog();
await queryClient.invalidateQueries({ queryKey: ["domains"] });
},
});
const deleteDomain = useMutation({
mutationFn: portalApi.deleteDomain,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["domains"] });
},
});
const submitDomain = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const domain = { fqdn, name, netBIOS };
if (dialogMode === "edit" && selectedDomain) {
updateDomain.mutate({ id: selectedDomain.id, domain });
return;
}
addDomain.mutate(domain);
};
const formError = addDomain.error?.message ?? updateDomain.error?.message;
const isSaving = addDomain.isPending || updateDomain.isPending;
return (
<>
<PageHeader title="Domains" description="Verfuegbare Active Directory Domaenen." />
<FormSection
onSubmit={(event) => {
event.preventDefault();
addDomain.mutate({ fqdn, name, netBIOS });
}}
>
<FormGrid>
<Field label="Name" required>
<Input value={name} onChange={(_, data) => setName(data.value)} />
</Field>
<Field label="FQDN" required>
<Input value={fqdn} onChange={(_, data) => setFqdn(data.value)} />
</Field>
<Field label="NetBIOS" required validationMessage={addDomain.error?.message}>
<Input value={netBIOS} onChange={(_, data) => setNetBIOS(data.value)} />
</Field>
</FormGrid>
<FormActions>
<Button
appearance="primary"
disabled={!name || !fqdn || !netBIOS || addDomain.isPending}
type="submit"
>
Add domain
</Button>
</FormActions>
</FormSection>
<DataState isLoading={isLoading} error={error} />
<div className={styles.toolbar}>
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
Domain hinzufuegen
</Button>
</div>
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
<DialogSurface>
<form onSubmit={submitDomain}>
<DialogBody>
<DialogTitle>{dialogMode === "edit" ? "Domain aendern" : "Domain hinzufuegen"}</DialogTitle>
<DialogContent className={styles.form}>
<Field label="Name" required>
<Input value={name} onChange={(_, data) => setName(data.value)} />
</Field>
<Field label="FQDN" required>
<Input value={fqdn} onChange={(_, data) => setFqdn(data.value)} />
</Field>
<Field label="NetBIOS" required validationMessage={formError}>
<Input value={netBIOS} onChange={(_, data) => setNetBIOS(data.value)} />
</Field>
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={closeDialog}>
Abbrechen
</Button>
<Button appearance="primary" disabled={!name || !fqdn || !netBIOS || isSaving} type="submit">
Speichern
</Button>
</DialogActions>
</DialogBody>
</form>
</DialogSurface>
</Dialog>
<DataState isLoading={isLoading} error={error ?? deleteDomain.error} />
{data && (
<Table aria-label="Domains">
<TableHeader>
@@ -74,6 +169,7 @@ export function DomainsPage() {
<TableHeaderCell>FQDN</TableHeaderCell>
<TableHeaderCell>NetBIOS</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
<TableHeaderCell>Aktionen</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
@@ -83,6 +179,36 @@ export function DomainsPage() {
<TableCell>{domain.fqdn}</TableCell>
<TableCell>{domain.netBIOS}</TableCell>
<TableCell>{domain.id}</TableCell>
<TableCell>
<div className={styles.actions}>
<Tooltip content="Details" relationship="label">
<Link to={`/domains/${domain.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={() => openEditDialog(domain)}
/>
</Tooltip>
<Tooltip content="Loeschen" relationship="label">
<Button
appearance="subtle"
aria-label="Loeschen"
disabled={deleteDomain.isPending}
icon={<DeleteRegular />}
onClick={() => {
if (window.confirm(`Domain "${domain.name}" wirklich loeschen?`)) {
deleteDomain.mutate(domain.id);
}
}}
/>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>

View File

@@ -0,0 +1,129 @@
import { useQuery } from "@tanstack/react-query";
import {
Badge,
Button,
makeStyles,
MessageBar,
MessageBarBody,
shorthands,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
Text,
Title3,
tokens,
} from "@fluentui/react-components";
import { ArrowLeftRegular } from "@fluentui/react-icons";
import { Link, useParams } from "react-router-dom";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { PageHeader } from "../components/PageHeader";
const useStyles = makeStyles({
backLink: {
display: "inline-flex",
marginBottom: "18px",
textDecorationLine: "none",
},
details: {
display: "grid",
gap: "14px",
gridTemplateColumns: "repeat(2, minmax(180px, 1fr))",
marginBottom: "24px",
maxWidth: "720px",
...shorthands.border("1px", "solid", tokens.colorNeutralStroke2),
...shorthands.borderRadius("8px"),
...shorthands.padding("18px"),
},
field: {
display: "grid",
gap: "4px",
minWidth: 0,
},
value: {
overflowWrap: "anywhere",
},
sectionTitle: {
marginBottom: "12px",
},
});
export function EnvironmentDetailsPage() {
const styles = useStyles();
const { id } = useParams();
const { data, error, isLoading } = useQuery({
enabled: Boolean(id),
queryKey: ["environment", id, "domains"],
queryFn: ({ signal }) => portalApi.getEnvironmentDomains(id!, signal),
});
const links = data?.environmentDomains?.filter((link) => link.domain) ?? [];
return (
<>
<Link className={styles.backLink} to="/environments">
<Button appearance="subtle" icon={<ArrowLeftRegular />}>
Environments
</Button>
</Link>
<PageHeader title={data?.name ?? "Environment"} description="Details und verknuepfte Domains." />
<DataState isLoading={isLoading} error={error} />
{data && (
<>
<section className={styles.details} aria-label="Environment details">
<div className={styles.field}>
<Text size={200}>Name</Text>
<Text className={styles.value} weight="semibold">
{data.name}
</Text>
</div>
<div className={styles.field}>
<Text size={200}>Id</Text>
<Text className={styles.value} weight="semibold">
{data.id}
</Text>
</div>
</section>
<Title3 className={styles.sectionTitle}>Linked Domains</Title3>
{links.length === 0 ? (
<MessageBar>
<MessageBarBody>Dieses Environment ist noch mit keiner Domain verknuepft.</MessageBarBody>
</MessageBar>
) : (
<Table aria-label="Linked domains">
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>FQDN</TableHeaderCell>
<TableHeaderCell>NetBIOS</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Details</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{links.map((link) => (
<TableRow key={link.domain!.id}>
<TableCell>{link.domain!.name}</TableCell>
<TableCell>{link.domain!.fqdn}</TableCell>
<TableCell>{link.domain!.netBIOS}</TableCell>
<TableCell>
<Badge appearance="filled" color="success">
Linked
</Badge>
</TableCell>
<TableCell>
<Link to={`/domains/${link.domain!.id}`}>Oeffnen</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
)}
</>
);
}

View File

@@ -0,0 +1,104 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Button,
Combobox,
Field,
MessageBar,
MessageBarBody,
Option,
} from "@fluentui/react-components";
import { useMemo, useState } from "react";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { FormActions, FormGrid, FormSection } from "../components/FormSection";
import { PageHeader } from "../components/PageHeader";
export function EnvironmentDomainsPage() {
const [domainId, setDomainId] = useState("");
const [environmentId, setEnvironmentId] = useState("");
const domains = useQuery({
queryKey: ["domains"],
queryFn: ({ signal }) => portalApi.getDomains(signal),
});
const environments = useQuery({
queryKey: ["environments"],
queryFn: ({ signal }) => portalApi.getEnvironments(signal),
});
const linkMutation = useMutation({
mutationFn: () => portalApi.linkDomainToEnvironment(domainId, environmentId),
});
const selectedDomain = useMemo(
() => domains.data?.find((domain) => domain.id === domainId),
[domainId, domains.data],
);
const selectedEnvironment = useMemo(
() => environments.data?.find((environment) => environment.id === environmentId),
[environmentId, environments.data],
);
const isLoading = domains.isLoading || environments.isLoading;
const error = domains.error ?? environments.error;
return (
<>
<PageHeader
title="Environment Domains"
description="Domains mit Environments verknuepfen."
/>
<FormSection
onSubmit={(event) => {
event.preventDefault();
linkMutation.mutate();
}}
>
<FormGrid>
<Field label="Environment" required>
<Combobox
disabled={!environments.data?.length}
onOptionSelect={(_, data) => setEnvironmentId(data.optionValue ?? "")}
placeholder="Select environment"
value={selectedEnvironment?.name ?? ""}
>
{environments.data?.map((environment) => (
<Option key={environment.id} text={environment.name} value={environment.id}>
{environment.name}
</Option>
))}
</Combobox>
</Field>
<Field label="Domain" required validationMessage={linkMutation.error?.message}>
<Combobox
disabled={!domains.data?.length}
onOptionSelect={(_, data) => setDomainId(data.optionValue ?? "")}
placeholder="Select domain"
value={selectedDomain?.name ?? ""}
>
{domains.data?.map((domain) => (
<Option key={domain.id} text={domain.name} value={domain.id}>
{domain.name} ({domain.fqdn})
</Option>
))}
</Combobox>
</Field>
</FormGrid>
<FormActions>
<Button
appearance="primary"
disabled={!domainId || !environmentId || linkMutation.isPending}
type="submit"
>
Link domain
</Button>
</FormActions>
</FormSection>
<DataState isLoading={isLoading} error={error} />
{linkMutation.isSuccess && (
<MessageBar intent="success">
<MessageBarBody>Domain wurde mit dem Environment verknuepft.</MessageBarBody>
</MessageBar>
)}
</>
);
}

View File

@@ -1,71 +1,198 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field,
Input,
makeStyles,
shorthands,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
Tooltip,
} from "@fluentui/react-components";
import { AddRegular, DeleteRegular, EditRegular, OpenRegular } from "@fluentui/react-icons";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { FormActions, FormGrid, FormSection } from "../components/FormSection";
import { PageHeader } from "../components/PageHeader";
import type { EnvironmentItem } from "../types/portal";
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"),
},
});
type DialogMode = "add" | "edit" | null;
export function EnvironmentsPage() {
const styles = useStyles();
const queryClient = useQueryClient();
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
const [selectedEnvironment, setSelectedEnvironment] = useState<EnvironmentItem | null>(null);
const [name, setName] = useState("");
const { data, error, isLoading } = useQuery({
queryKey: ["environments"],
queryFn: ({ signal }) => portalApi.getEnvironments(signal),
});
const closeDialog = () => {
setDialogMode(null);
setSelectedEnvironment(null);
setName("");
};
const openAddDialog = () => {
setSelectedEnvironment(null);
setName("");
setDialogMode("add");
};
const openEditDialog = (environment: EnvironmentItem) => {
setSelectedEnvironment(environment);
setName(environment.name);
setDialogMode("edit");
};
const addEnvironment = useMutation({
mutationFn: portalApi.addEnvironment,
onSuccess: async () => {
setName("");
closeDialog();
await queryClient.invalidateQueries({ queryKey: ["environments"] });
},
});
const updateEnvironment = useMutation({
mutationFn: ({ id, environment }: { id: string; environment: { name: string } }) =>
portalApi.updateEnvironment(id, environment),
onSuccess: async () => {
closeDialog();
await queryClient.invalidateQueries({ queryKey: ["environments"] });
},
});
const deleteEnvironment = useMutation({
mutationFn: portalApi.deleteEnvironment,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["environments"] });
},
});
const submitEnvironment = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const environment = { name };
if (dialogMode === "edit" && selectedEnvironment) {
updateEnvironment.mutate({ id: selectedEnvironment.id, environment });
return;
}
addEnvironment.mutate(environment);
};
const formError = addEnvironment.error?.message ?? updateEnvironment.error?.message;
const isSaving = addEnvironment.isPending || updateEnvironment.isPending;
return (
<>
<PageHeader title="Environments" description="Umgebungen und Cloud-Faehigkeit." />
<FormSection
onSubmit={(event) => {
event.preventDefault();
addEnvironment.mutate({ name });
}}
>
<FormGrid>
<Field label="Name" required validationMessage={addEnvironment.error?.message}>
<Input value={name} onChange={(_, data) => setName(data.value)} />
</Field>
</FormGrid>
<FormActions>
<Button appearance="primary" disabled={!name || addEnvironment.isPending} type="submit">
Add environment
</Button>
</FormActions>
</FormSection>
<DataState isLoading={isLoading} error={error} />
<div className={styles.toolbar}>
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
Environment hinzufuegen
</Button>
</div>
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
<DialogSurface>
<form onSubmit={submitEnvironment}>
<DialogBody>
<DialogTitle>
{dialogMode === "edit" ? "Environment aendern" : "Environment hinzufuegen"}
</DialogTitle>
<DialogContent className={styles.form}>
<Field label="Name" required validationMessage={formError}>
<Input value={name} onChange={(_, data) => setName(data.value)} />
</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>
<DataState isLoading={isLoading} error={error ?? deleteEnvironment.error} />
{data && (
<Table aria-label="Environments">
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
</TableRow>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
<TableHeaderCell>Aktionen</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{data.map((environment) => (
<TableRow key={environment.id}>
<TableCell>{environment.name}</TableCell>
<TableCell>{environment.id}</TableCell>
<TableCell>
<div className={styles.actions}>
<Tooltip content="Details" relationship="label">
<Link to={`/environments/${environment.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={() => openEditDialog(environment)}
/>
</Tooltip>
<Tooltip content="Loeschen" relationship="label">
<Button
appearance="subtle"
aria-label="Loeschen"
disabled={deleteEnvironment.isPending}
icon={<DeleteRegular />}
onClick={() => {
if (window.confirm(`Environment "${environment.name}" wirklich loeschen?`)) {
deleteEnvironment.mutate(environment.id);
}
}}
/>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>

View File

@@ -1,39 +1,230 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field,
Input,
makeStyles,
shorthands,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
Textarea,
Tooltip,
} from "@fluentui/react-components";
import { AddRegular, DeleteRegular, EditRegular, 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 { ServiceItem } from "../types/portal";
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" | "details" | null;
export function ServicesPage() {
const styles = useStyles();
const queryClient = useQueryClient();
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
const [selectedService, setSelectedService] = useState<ServiceItem | null>(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const { data, error, isLoading } = useQuery({
queryKey: ["services"],
queryFn: ({ signal }) => portalApi.getServices(signal),
});
const closeDialog = () => {
setDialogMode(null);
setSelectedService(null);
setName("");
setDescription("");
};
const openAddDialog = () => {
setName("");
setDescription("");
setSelectedService(null);
setDialogMode("add");
};
const openServiceDialog = (mode: "edit" | "details", service: ServiceItem) => {
setSelectedService(service);
setName(service.name);
setDescription(service.description ?? "");
setDialogMode(mode);
};
const addService = useMutation({
mutationFn: portalApi.addService,
onSuccess: async () => {
closeDialog();
await queryClient.invalidateQueries({ queryKey: ["services"] });
},
});
const updateService = useMutation({
mutationFn: ({ id, service }: { id: string; service: { name: string; description: string } }) =>
portalApi.updateService(id, service),
onSuccess: async () => {
closeDialog();
await queryClient.invalidateQueries({ queryKey: ["services"] });
},
});
const deleteService = useMutation({
mutationFn: portalApi.deleteService,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["services"] });
},
});
const submitService = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const service = { description, name };
if (dialogMode === "edit" && selectedService) {
updateService.mutate({ id: selectedService.id, service });
return;
}
addService.mutate(service);
};
const formError = addService.error?.message ?? updateService.error?.message;
const isSaving = addService.isPending || updateService.isPending;
const isDetails = dialogMode === "details";
return (
<>
<PageHeader title="Services" description="Service-Katalog aus der Core API." />
<DataState isLoading={isLoading} error={error} />
<div className={styles.toolbar}>
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
Service hinzufuegen
</Button>
</div>
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
<DialogSurface>
<form onSubmit={submitService}>
<DialogBody>
<DialogTitle>
{dialogMode === "edit"
? "Service aendern"
: dialogMode === "details"
? "Service Details"
: "Service hinzufuegen"}
</DialogTitle>
<DialogContent className={styles.form}>
<Field label="Name" required>
<Input disabled={isDetails} value={name} onChange={(_, data) => setName(data.value)} />
</Field>
<Field label="Description" validationMessage={formError}>
<Textarea
disabled={isDetails}
resize="vertical"
value={description}
onChange={(_, data) => setDescription(data.value)}
/>
</Field>
{selectedService && (
<Field label="Id">
<div className={styles.value}>{selectedService.id}</div>
</Field>
)}
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={closeDialog}>
{isDetails ? "Schliessen" : "Abbrechen"}
</Button>
{!isDetails && (
<Button appearance="primary" disabled={!name || isSaving} type="submit">
Speichern
</Button>
)}
</DialogActions>
</DialogBody>
</form>
</DialogSurface>
</Dialog>
<DataState isLoading={isLoading} error={error ?? deleteService.error} />
{data && (
<Table aria-label="Services">
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
</TableRow>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Description</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
<TableHeaderCell>Aktionen</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{data.map((service) => (
<TableRow key={service.id}>
<TableCell>{service.name}</TableCell>
<TableCell>{service.description}</TableCell>
<TableCell>{service.id}</TableCell>
<TableCell>
<div className={styles.actions}>
<Tooltip content="Details" relationship="label">
<Button
appearance="subtle"
aria-label="Details"
icon={<OpenRegular />}
onClick={() => openServiceDialog("details", service)}
/>
</Tooltip>
<Tooltip content="Aendern" relationship="label">
<Button
appearance="subtle"
aria-label="Aendern"
icon={<EditRegular />}
onClick={() => openServiceDialog("edit", service)}
/>
</Tooltip>
<Tooltip content="Loeschen" relationship="label">
<Button
appearance="subtle"
aria-label="Loeschen"
disabled={deleteService.isPending}
icon={<DeleteRegular />}
onClick={() => {
if (window.confirm(`Service "${service.name}" wirklich loeschen?`)) {
deleteService.mutate(service.id);
}
}}
/>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>

View File

@@ -1,39 +1,305 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Checkbox,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field,
Input,
makeStyles,
shorthands,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
Textarea,
Tooltip,
} from "@fluentui/react-components";
import { AddRegular, DeleteRegular, EditRegular, 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 { Template } from "../types/portal";
const useStyles = makeStyles({
toolbar: {
display: "flex",
justifyContent: "flex-start",
marginBottom: "18px",
},
form: {
display: "grid",
gap: "14px",
},
grid: {
display: "grid",
gap: "14px",
gridTemplateColumns: "repeat(2, minmax(180px, 1fr))",
},
wide: {
gridColumn: "1 / -1",
},
actions: {
display: "flex",
gap: "4px",
...shorthands.padding("2px", "0"),
},
value: {
overflowWrap: "anywhere",
},
});
type DialogMode = "add" | "edit" | "details" | null;
function getTemplateJsonData(template: Template) {
return template.jsonData ?? template.jSONData ?? "";
}
export function TemplatesPage() {
const styles = useStyles();
const queryClient = useQueryClient();
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const [templateCategoryId, setTemplateCategoryId] = useState("");
const [name, setName] = useState("");
const [cloudTemplate, setCloudTemplate] = useState(false);
const [version, setVersion] = useState("");
const [description, setDescription] = useState("");
const [jsonData, setJsonData] = useState("");
const { data, error, isLoading } = useQuery({
queryKey: ["templates"],
queryFn: ({ signal }) => portalApi.getTemplates(signal),
});
const closeDialog = () => {
setDialogMode(null);
setSelectedTemplate(null);
setTemplateCategoryId("");
setName("");
setCloudTemplate(false);
setVersion("");
setDescription("");
setJsonData("");
};
const openAddDialog = () => {
closeDialog();
setDialogMode("add");
};
const openTemplateDialog = (mode: "edit" | "details", template: Template) => {
setSelectedTemplate(template);
setTemplateCategoryId(template.templateCategoryId ?? "");
setName(template.name);
setCloudTemplate(Boolean(template.cloudTemplate));
setVersion(template.version ?? "");
setDescription(template.description ?? "");
setJsonData(getTemplateJsonData(template));
setDialogMode(mode);
};
const addTemplate = useMutation({
mutationFn: portalApi.addTemplate,
onSuccess: async () => {
closeDialog();
await queryClient.invalidateQueries({ queryKey: ["templates"] });
},
});
const updateTemplate = useMutation({
mutationFn: ({
id,
template,
}: {
id: string;
template: {
templateCategoryId: string;
name: string;
cloudTemplate: boolean;
version: string;
description: string;
jsonData: string;
};
}) => portalApi.updateTemplate(id, template),
onSuccess: async () => {
closeDialog();
await queryClient.invalidateQueries({ queryKey: ["templates"] });
},
});
const deleteTemplate = useMutation({
mutationFn: portalApi.deleteTemplate,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["templates"] });
},
});
const submitTemplate = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const template = { cloudTemplate, description, jsonData, name, templateCategoryId, version };
if (dialogMode === "edit" && selectedTemplate) {
updateTemplate.mutate({ id: selectedTemplate.id, template });
return;
}
addTemplate.mutate(template);
};
const formError = addTemplate.error?.message ?? updateTemplate.error?.message;
const isSaving = addTemplate.isPending || updateTemplate.isPending;
const isDetails = dialogMode === "details";
return (
<>
<PageHeader title="Templates" description="Vorlagen fuer Portal-Bereitstellungen." />
<DataState isLoading={isLoading} error={error} />
<div className={styles.toolbar}>
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
Template hinzufuegen
</Button>
</div>
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
<DialogSurface>
<form onSubmit={submitTemplate}>
<DialogBody>
<DialogTitle>
{dialogMode === "edit"
? "Template aendern"
: dialogMode === "details"
? "Template Details"
: "Template hinzufuegen"}
</DialogTitle>
<DialogContent className={styles.form}>
<div className={styles.grid}>
<Field label="Name" required>
<Input disabled={isDetails} value={name} onChange={(_, data) => setName(data.value)} />
</Field>
<Field label="Version" required>
<Input
disabled={isDetails}
value={version}
onChange={(_, data) => setVersion(data.value)}
/>
</Field>
<Field className={styles.wide} label="TemplateCategoryId" required validationMessage={formError}>
<Input
disabled={isDetails}
value={templateCategoryId}
onChange={(_, data) => setTemplateCategoryId(data.value)}
/>
</Field>
<Field className={styles.wide}>
<Checkbox
checked={cloudTemplate}
disabled={isDetails}
label="Cloud Template"
onChange={(_, data) => setCloudTemplate(Boolean(data.checked))}
/>
</Field>
<Field className={styles.wide} label="Description">
<Textarea
disabled={isDetails}
resize="vertical"
value={description}
onChange={(_, data) => setDescription(data.value)}
/>
</Field>
<Field className={styles.wide} label="JSONData" required>
<Textarea
disabled={isDetails}
resize="vertical"
value={jsonData}
onChange={(_, data) => setJsonData(data.value)}
/>
</Field>
{selectedTemplate && (
<Field className={styles.wide} label="Id">
<div className={styles.value}>{selectedTemplate.id}</div>
</Field>
)}
</div>
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={closeDialog}>
{isDetails ? "Schliessen" : "Abbrechen"}
</Button>
{!isDetails && (
<Button
appearance="primary"
disabled={!name || !templateCategoryId || !version || !jsonData || isSaving}
type="submit"
>
Speichern
</Button>
)}
</DialogActions>
</DialogBody>
</form>
</DialogSurface>
</Dialog>
<DataState isLoading={isLoading} error={error ?? deleteTemplate.error} />
{data && (
<Table aria-label="Templates">
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
</TableRow>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Cloud</TableHeaderCell>
<TableHeaderCell>TemplateCategoryId</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
<TableHeaderCell>Aktionen</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{data.map((template) => (
<TableRow key={template.id}>
<TableCell>{template.name}</TableCell>
<TableCell>{template.version}</TableCell>
<TableCell>{template.cloudTemplate ? "Ja" : "Nein"}</TableCell>
<TableCell>{template.templateCategoryId}</TableCell>
<TableCell>{template.id}</TableCell>
<TableCell>
<div className={styles.actions}>
<Tooltip content="Details" relationship="label">
<Button
appearance="subtle"
aria-label="Details"
icon={<OpenRegular />}
onClick={() => openTemplateDialog("details", template)}
/>
</Tooltip>
<Tooltip content="Aendern" relationship="label">
<Button
appearance="subtle"
aria-label="Aendern"
icon={<EditRegular />}
onClick={() => openTemplateDialog("edit", template)}
/>
</Tooltip>
<Tooltip content="Loeschen" relationship="label">
<Button
appearance="subtle"
aria-label="Loeschen"
disabled={deleteTemplate.isPending}
icon={<DeleteRegular />}
onClick={() => {
if (window.confirm(`Template "${template.name}" wirklich loeschen?`)) {
deleteTemplate.mutate(template.id);
}
}}
/>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>

View File

@@ -27,6 +27,12 @@ export type Domain = {
netBIOS: string;
};
export type DomainWithEnvironments = Domain & {
environmentDomains?: Array<{
environment?: EnvironmentItem;
}>;
};
export type AddDomain = {
name: string;
fqdn: string;
@@ -38,6 +44,12 @@ export type EnvironmentItem = {
name: string;
};
export type EnvironmentWithDomains = EnvironmentItem & {
environmentDomains?: Array<{
domain?: Domain;
}>;
};
export type AddEnvironment = {
name: string;
};
@@ -56,9 +68,30 @@ export type AddRunbook = {
export type Template = {
id: string;
name: string;
templateCategoryId?: string;
cloudTemplate?: boolean;
version?: string;
description?: string;
jsonData?: string;
jSONData?: string;
};
export type AddTemplate = {
templateCategoryId: string;
name: string;
cloudTemplate: boolean;
version: string;
description: string;
jsonData: string;
};
export type ServiceItem = {
id: string;
name: string;
description?: string;
};
export type AddService = {
name: string;
description: string;
};