Initial commit

This commit is contained in:
Torsten Brendgen
2026-05-14 21:43:50 +02:00
commit fdf294cac0
31 changed files with 6321 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_BASE_URL=/api
VITE_API_PROXY_TARGET=https://localhost:7260

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules
dist
.env
.env.local
.env.*.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

44
README.md Normal file
View File

@@ -0,0 +1,44 @@
# Microsoft Self Service Portal Web
Separate React/TypeScript frontend for `Microsoft.SelfService.Portal.Core.API`.
## Stack
- React
- TypeScript
- Vite
- Fluent UI React
- TanStack Query
- React Router
## Setup
Start the API first:
```powershell
cd F:\Projekte\Coding\.Net\Microsoft.SelfService.Portal.Core.API
$env:ASPNETCORE_ENVIRONMENT="Development"
dotnet run --launch-profile https
```
Then start the frontend:
```powershell
npm install
npm run dev
```
The development server proxies `/api` calls to `https://localhost:7260`.
Adjust `VITE_API_PROXY_TARGET` in `.env.local` if the API runs on a different port.
## Project Layout
```text
src/
api/ HTTP client and API resource services
components/ Shared UI components
layout/ App shell and navigation
pages/ Route pages
styles/ Global styles
types/ Shared TypeScript types
```

25
eslint.config.js Normal file
View File

@@ -0,0 +1,25 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
},
},
);

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Self Service Portal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5049
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "microsoft-selfservice-portal-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@fluentui/react-components": "^9.56.0",
"@fluentui/react-icons": "^2.0.245",
"@tanstack/react-query": "^5.59.16",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@types/node": "^25.8.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"eslint": "^9.13.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"globals": "^15.11.0",
"typescript": "^5.6.3",
"typescript-eslint": "^8.11.0",
"vite": "^5.4.10"
}
}

58
src/api/httpClient.ts Normal file
View File

@@ -0,0 +1,58 @@
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "/api";
export class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
this.name = "ApiError";
}
}
export async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
credentials: "include",
headers: {
Accept: "application/json",
},
signal,
});
if (!response.ok) {
throw new ApiError(`API request failed: ${response.status} ${response.statusText}`, response.status);
}
return response.json() as Promise<T>;
}
export async function postJson<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: "POST",
});
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;
}
}

33
src/api/portalApi.ts Normal file
View File

@@ -0,0 +1,33 @@
import { getJson, postJson } from "./httpClient";
import type {
AddDeployment,
AddDeploymentGroup,
AddDomain,
AddEnvironment,
AddRunbook,
Deployment,
DeploymentGroup,
Domain,
EnvironmentItem,
Runbook,
ServiceItem,
Template,
} from "../types/portal";
export const portalApi = {
getDeployments: (signal?: AbortSignal) => getJson<Deployment[]>("/Deployment", signal),
addDeployment: (deployment: AddDeployment) => postJson<AddDeployment, string>("/Deployment", deployment),
getDeploymentGroups: (signal?: AbortSignal) =>
getJson<DeploymentGroup[]>("/DeploymentGroup", signal),
addDeploymentGroup: (deploymentGroup: AddDeploymentGroup) =>
postJson<AddDeploymentGroup, string>("/DeploymentGroup", deploymentGroup),
getDomains: (signal?: AbortSignal) => getJson<Domain[]>("/Domain", signal),
addDomain: (domain: AddDomain) => postJson<AddDomain, string>("/Domain", domain),
getEnvironments: (signal?: AbortSignal) => getJson<EnvironmentItem[]>("/Environment", signal),
addEnvironment: (environment: AddEnvironment) =>
postJson<AddEnvironment, string>("/Environment", environment),
getRunbooks: (signal?: AbortSignal) => getJson<Runbook[]>("/Runbook", signal),
addRunbook: (runbook: AddRunbook) => postJson<AddRunbook, string>("/Runbook", runbook),
getTemplates: (signal?: AbortSignal) => getJson<Template[]>("/Template", signal),
getServices: (signal?: AbortSignal) => getJson<ServiceItem[]>("/Service", signal),
};

View File

@@ -0,0 +1,23 @@
import { MessageBar, MessageBarBody, Spinner } from "@fluentui/react-components";
type DataStateProps = {
isLoading: boolean;
error: unknown;
};
export function DataState({ isLoading, error }: DataStateProps) {
if (isLoading) {
return <Spinner label="Daten werden geladen" />;
}
if (error) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
return (
<MessageBar intent="error">
<MessageBarBody>{message}</MessageBarBody>
</MessageBar>
);
}
return null;
}

View File

@@ -0,0 +1,60 @@
import { makeStyles, shorthands, tokens } from "@fluentui/react-components";
import type { PropsWithChildren } from "react";
const useStyles = makeStyles({
root: {
backgroundColor: tokens.colorNeutralBackground1,
display: "grid",
gap: "14px",
marginBottom: "22px",
maxWidth: "760px",
...shorthands.border("1px", "solid", tokens.colorNeutralStroke2),
...shorthands.borderRadius("8px"),
...shorthands.padding("18px"),
},
grid: {
display: "grid",
gap: "14px",
gridTemplateColumns: "repeat(2, minmax(220px, 1fr))",
},
wide: {
gridColumn: "1 / -1",
},
actions: {
display: "flex",
gap: "10px",
justifyContent: "flex-start",
},
});
type FormSectionProps = PropsWithChildren<{
onSubmit: React.FormEventHandler<HTMLFormElement>;
}>;
export function FormSection({ children, onSubmit }: FormSectionProps) {
const styles = useStyles();
return (
<form className={styles.root} onSubmit={onSubmit}>
{children}
</form>
);
}
export function FormGrid({ children }: PropsWithChildren) {
const styles = useStyles();
return <div className={styles.grid}>{children}</div>;
}
export function FormWide({ children }: PropsWithChildren) {
const styles = useStyles();
return <div className={styles.wide}>{children}</div>;
}
export function FormActions({ children }: PropsWithChildren) {
const styles = useStyles();
return <div className={styles.actions}>{children}</div>;
}

View File

@@ -0,0 +1,25 @@
import { makeStyles, Text, Title1 } from "@fluentui/react-components";
const useStyles = makeStyles({
root: {
display: "grid",
gap: "6px",
marginBottom: "24px",
},
});
type PageHeaderProps = {
title: string;
description: string;
};
export function PageHeader({ title, description }: PageHeaderProps) {
const styles = useStyles();
return (
<div className={styles.root}>
<Title1>{title}</Title1>
<Text>{description}</Text>
</div>
);
}

120
src/layout/AppShell.tsx Normal file
View File

@@ -0,0 +1,120 @@
import { Outlet, NavLink } from "react-router-dom";
import {
Button,
makeStyles,
shorthands,
Text,
Title2,
tokens,
} from "@fluentui/react-components";
import {
AppsListDetail24Regular,
BoxMultiple24Regular,
CloudFlow24Regular,
DatabasePlugConnectedRegular,
Globe24Regular,
Home24Regular,
PlayCircle24Regular,
ServerMultipleRegular,
} from "@fluentui/react-icons";
const useStyles = makeStyles({
root: {
minHeight: "100vh",
display: "grid",
gridTemplateColumns: "280px minmax(0, 1fr)",
backgroundColor: tokens.colorNeutralBackground2,
color: tokens.colorNeutralForeground1,
},
sidebar: {
display: "flex",
flexDirection: "column",
gap: "20px",
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRight("1px", "solid", tokens.colorNeutralStroke2),
...shorthands.padding("24px", "18px"),
},
brand: {
display: "grid",
gap: "4px",
...shorthands.padding("0", "6px"),
},
nav: {
display: "grid",
gap: "6px",
},
navLink: {
textDecorationLine: "none",
},
navButton: {
width: "100%",
justifyContent: "flex-start",
},
content: {
minWidth: 0,
display: "flex",
flexDirection: "column",
},
topbar: {
height: "64px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderBottom("1px", "solid", tokens.colorNeutralStroke2),
...shorthands.padding("0", "28px"),
},
main: {
...shorthands.padding("28px"),
},
});
const links = [
{ to: "/", label: "Dashboard", icon: <Home24Regular /> },
{ to: "/deployments", label: "Deployments", icon: <CloudFlow24Regular /> },
{ to: "/deployment-groups", label: "Deployment Groups", icon: <ServerMultipleRegular /> },
{ to: "/domains", label: "Domains", icon: <Globe24Regular /> },
{ to: "/environments", label: "Environments", icon: <DatabasePlugConnectedRegular /> },
{ to: "/runbooks", label: "Runbooks", icon: <PlayCircle24Regular /> },
{ to: "/templates", label: "Templates", icon: <BoxMultiple24Regular /> },
{ to: "/services", label: "Services", icon: <AppsListDetail24Regular /> },
];
export function AppShell() {
const styles = useStyles();
return (
<div className={styles.root}>
<aside className={styles.sidebar}>
<div className={styles.brand}>
<Title2>Self Service Portal</Title2>
<Text size={200}>Core API Frontend</Text>
</div>
<nav className={styles.nav}>
{links.map((link) => (
<NavLink className={styles.navLink} end={link.to === "/"} key={link.to} to={link.to}>
{({ isActive }) => (
<Button
appearance={isActive ? "primary" : "subtle"}
className={styles.navButton}
icon={link.icon}
>
{link.label}
</Button>
)}
</NavLink>
))}
</nav>
</aside>
<section className={styles.content}>
<header className={styles.topbar}>
<Text weight="semibold">Portal workspace</Text>
<Text size={200}>API: {import.meta.env.VITE_API_BASE_URL ?? "/api"}</Text>
</header>
<main className={styles.main}>
<Outlet />
</main>
</section>
</div>
);
}

51
src/main.tsx Normal file
View File

@@ -0,0 +1,51 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { AppShell } from "./layout/AppShell";
import { DashboardPage } from "./pages/DashboardPage";
import { DeploymentsPage } from "./pages/DeploymentsPage";
import { DeploymentGroupsPage } from "./pages/DeploymentGroupsPage";
import { DomainsPage } from "./pages/DomainsPage";
import { EnvironmentsPage } from "./pages/EnvironmentsPage";
import { RunbooksPage } from "./pages/RunbooksPage";
import { TemplatesPage } from "./pages/TemplatesPage";
import { ServicesPage } from "./pages/ServicesPage";
import "./styles/global.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});
const router = createBrowserRouter([
{
path: "/",
element: <AppShell />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: "deployments", element: <DeploymentsPage /> },
{ path: "deployment-groups", element: <DeploymentGroupsPage /> },
{ path: "domains", element: <DomainsPage /> },
{ path: "environments", element: <EnvironmentsPage /> },
{ path: "runbooks", element: <RunbooksPage /> },
{ path: "templates", element: <TemplatesPage /> },
{ path: "services", element: <ServicesPage /> },
],
},
]);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<FluentProvider theme={webLightTheme}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</FluentProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,74 @@
import { useQuery } from "@tanstack/react-query";
import {
Card,
CardHeader,
makeStyles,
Text,
tokens,
} from "@fluentui/react-components";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { PageHeader } from "../components/PageHeader";
const useStyles = makeStyles({
grid: {
display: "grid",
gridTemplateColumns: "repeat(4, minmax(160px, 1fr))",
gap: "16px",
},
metric: {
fontSize: "32px",
fontWeight: tokens.fontWeightSemibold,
lineHeight: "40px",
},
});
export function DashboardPage() {
const styles = useStyles();
const deployments = useQuery({
queryKey: ["deployments"],
queryFn: ({ signal }) => portalApi.getDeployments(signal),
});
const environments = useQuery({
queryKey: ["environments"],
queryFn: ({ signal }) => portalApi.getEnvironments(signal),
});
const templates = useQuery({
queryKey: ["templates"],
queryFn: ({ signal }) => portalApi.getTemplates(signal),
});
const services = useQuery({
queryKey: ["services"],
queryFn: ({ signal }) => portalApi.getServices(signal),
});
const error = deployments.error ?? environments.error ?? templates.error ?? services.error;
const isLoading =
deployments.isLoading || environments.isLoading || templates.isLoading || services.isLoading;
return (
<>
<PageHeader title="Dashboard" description="Uebersicht ueber die wichtigsten Portalobjekte." />
<DataState isLoading={isLoading} error={error} />
{!isLoading && !error && (
<div className={styles.grid}>
<MetricCard label="Deployments" value={deployments.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} />
</div>
)}
</>
);
}
function MetricCard({ label, value }: { label: string; value: number }) {
const styles = useStyles();
return (
<Card>
<CardHeader header={<Text weight="semibold">{label}</Text>} />
<Text className={styles.metric}>{value}</Text>
</Card>
);
}

View File

@@ -0,0 +1,84 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Field,
Input,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "@fluentui/react-components";
import { 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 DeploymentGroupsPage() {
const queryClient = useQueryClient();
const [templateId, setTemplateId] = useState("");
const [status, setStatus] = useState("New");
const { data, error, isLoading } = useQuery({
queryKey: ["deploymentGroups"],
queryFn: ({ signal }) => portalApi.getDeploymentGroups(signal),
});
const addDeploymentGroup = useMutation({
mutationFn: portalApi.addDeploymentGroup,
onSuccess: async () => {
setTemplateId("");
setStatus("New");
await queryClient.invalidateQueries({ queryKey: ["deploymentGroups"] });
},
});
return (
<>
<PageHeader title="Deployment Groups" description="Gruppen von Bereitstellungen je Template." />
<FormSection
onSubmit={(event) => {
event.preventDefault();
addDeploymentGroup.mutate({ status, templateId });
}}
>
<FormGrid>
<Field label="Template Id" required>
<Input value={templateId} onChange={(_, data) => setTemplateId(data.value)} />
</Field>
<Field label="Status" required validationMessage={addDeploymentGroup.error?.message}>
<Input value={status} onChange={(_, data) => setStatus(data.value)} />
</Field>
</FormGrid>
<FormActions>
<Button
appearance="primary"
disabled={!templateId || !status || addDeploymentGroup.isPending}
type="submit"
>
Add deployment group
</Button>
</FormActions>
</FormSection>
<DataState isLoading={isLoading} error={error} />
{data && (
<Table aria-label="Deployment groups">
<TableHeader>
<TableRow>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{data.map((deploymentGroup) => (
<TableRow key={deploymentGroup.id}>
<TableCell>{deploymentGroup.status ?? "Unknown"}</TableCell>
<TableCell>{deploymentGroup.id}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
);
}

View File

@@ -0,0 +1,98 @@
import { useQuery } from "@tanstack/react-query";
import {
Button,
Field,
Input,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
Textarea,
} from "@fluentui/react-components";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { FormActions, FormGrid, FormSection, FormWide } from "../components/FormSection";
import { PageHeader } from "../components/PageHeader";
export function DeploymentsPage() {
const queryClient = useQueryClient();
const [deploymentGroupId, setDeploymentGroupId] = useState("");
const [virtualMachineId, setVirtualMachineId] = useState("");
const [status, setStatus] = useState("New");
const [jsonData, setJsonData] = useState("{}");
const { data, error, isLoading } = useQuery({
queryKey: ["deployments"],
queryFn: ({ signal }) => portalApi.getDeployments(signal),
});
const addDeployment = useMutation({
mutationFn: portalApi.addDeployment,
onSuccess: async () => {
setDeploymentGroupId("");
setVirtualMachineId("");
setStatus("New");
setJsonData("{}");
await queryClient.invalidateQueries({ queryKey: ["deployments"] });
},
});
return (
<>
<PageHeader title="Deployments" description="Aktuelle Bereitstellungen aus der Core API." />
<FormSection
onSubmit={(event) => {
event.preventDefault();
addDeployment.mutate({ deploymentGroupId, jsonData, status, virtualMachineId });
}}
>
<FormGrid>
<Field label="Deployment Group Id" required>
<Input value={deploymentGroupId} onChange={(_, data) => setDeploymentGroupId(data.value)} />
</Field>
<Field label="Virtual Machine Id" required>
<Input value={virtualMachineId} onChange={(_, data) => setVirtualMachineId(data.value)} />
</Field>
<Field label="Status" required validationMessage={addDeployment.error?.message}>
<Input value={status} onChange={(_, data) => setStatus(data.value)} />
</Field>
<FormWide>
<Field label="JSON data" required>
<Textarea value={jsonData} onChange={(_, data) => setJsonData(data.value)} />
</Field>
</FormWide>
</FormGrid>
<FormActions>
<Button
appearance="primary"
disabled={!deploymentGroupId || !virtualMachineId || !status || addDeployment.isPending}
type="submit"
>
Add deployment
</Button>
</FormActions>
</FormSection>
<DataState isLoading={isLoading} error={error} />
{data && (
<Table aria-label="Deployments">
<TableHeader>
<TableRow>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Deployment Id</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{data.map((deployment) => (
<TableRow key={deployment.id}>
<TableCell>{deployment.status ?? "Unknown"}</TableCell>
<TableCell>{deployment.id}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
);
}

93
src/pages/DomainsPage.tsx Normal file
View File

@@ -0,0 +1,93 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Field,
Input,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "@fluentui/react-components";
import { 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 DomainsPage() {
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [fqdn, setFqdn] = useState("");
const [netBIOS, setNetBIOS] = useState("");
const { data, error, isLoading } = useQuery({
queryKey: ["domains"],
queryFn: ({ signal }) => portalApi.getDomains(signal),
});
const addDomain = useMutation({
mutationFn: portalApi.addDomain,
onSuccess: async () => {
setName("");
setFqdn("");
setNetBIOS("");
await queryClient.invalidateQueries({ queryKey: ["domains"] });
},
});
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} />
{data && (
<Table aria-label="Domains">
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>FQDN</TableHeaderCell>
<TableHeaderCell>NetBIOS</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{data.map((domain) => (
<TableRow key={domain.id}>
<TableCell>{domain.name}</TableCell>
<TableCell>{domain.fqdn}</TableCell>
<TableCell>{domain.netBIOS}</TableCell>
<TableCell>{domain.id}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
);
}

View File

@@ -0,0 +1,76 @@
import { useQuery } from "@tanstack/react-query";
import {
Button,
Field,
Input,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "@fluentui/react-components";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { FormActions, FormGrid, FormSection } from "../components/FormSection";
import { PageHeader } from "../components/PageHeader";
export function EnvironmentsPage() {
const queryClient = useQueryClient();
const [name, setName] = useState("");
const { data, error, isLoading } = useQuery({
queryKey: ["environments"],
queryFn: ({ signal }) => portalApi.getEnvironments(signal),
});
const addEnvironment = useMutation({
mutationFn: portalApi.addEnvironment,
onSuccess: async () => {
setName("");
await queryClient.invalidateQueries({ queryKey: ["environments"] });
},
});
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} />
{data && (
<Table aria-label="Environments">
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{data.map((environment) => (
<TableRow key={environment.id}>
<TableCell>{environment.name}</TableCell>
<TableCell>{environment.id}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
);
}

View File

@@ -0,0 +1,89 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Field,
Input,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
Textarea,
} from "@fluentui/react-components";
import { useState } from "react";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { FormActions, FormGrid, FormSection, FormWide } from "../components/FormSection";
import { PageHeader } from "../components/PageHeader";
export function RunbooksPage() {
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const { data, error, isLoading } = useQuery({
queryKey: ["runbooks"],
queryFn: ({ signal }) => portalApi.getRunbooks(signal),
});
const addRunbook = useMutation({
mutationFn: portalApi.addRunbook,
onSuccess: async () => {
setName("");
setDescription("");
await queryClient.invalidateQueries({ queryKey: ["runbooks"] });
},
});
return (
<>
<PageHeader title="Runbooks" description="Automatisierungen fuer Portalereignisse." />
<FormSection
onSubmit={(event) => {
event.preventDefault();
addRunbook.mutate({ description, name });
}}
>
<FormGrid>
<Field label="Name" required>
<Input value={name} onChange={(_, data) => setName(data.value)} />
</Field>
<FormWide>
<Field label="Description" required validationMessage={addRunbook.error?.message}>
<Textarea value={description} onChange={(_, data) => setDescription(data.value)} />
</Field>
</FormWide>
</FormGrid>
<FormActions>
<Button
appearance="primary"
disabled={!name || !description || addRunbook.isPending}
type="submit"
>
Add runbook
</Button>
</FormActions>
</FormSection>
<DataState isLoading={isLoading} error={error} />
{data && (
<Table aria-label="Runbooks">
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Description</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{data.map((runbook) => (
<TableRow key={runbook.id}>
<TableCell>{runbook.name}</TableCell>
<TableCell>{runbook.decription ?? "-"}</TableCell>
<TableCell>{runbook.id}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
);
}

View File

@@ -0,0 +1,44 @@
import { useQuery } from "@tanstack/react-query";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "@fluentui/react-components";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { PageHeader } from "../components/PageHeader";
export function ServicesPage() {
const { data, error, isLoading } = useQuery({
queryKey: ["services"],
queryFn: ({ signal }) => portalApi.getServices(signal),
});
return (
<>
<PageHeader title="Services" description="Service-Katalog aus der Core API." />
<DataState isLoading={isLoading} error={error} />
{data && (
<Table aria-label="Services">
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{data.map((service) => (
<TableRow key={service.id}>
<TableCell>{service.name}</TableCell>
<TableCell>{service.id}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
);
}

View File

@@ -0,0 +1,44 @@
import { useQuery } from "@tanstack/react-query";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "@fluentui/react-components";
import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState";
import { PageHeader } from "../components/PageHeader";
export function TemplatesPage() {
const { data, error, isLoading } = useQuery({
queryKey: ["templates"],
queryFn: ({ signal }) => portalApi.getTemplates(signal),
});
return (
<>
<PageHeader title="Templates" description="Vorlagen fuer Portal-Bereitstellungen." />
<DataState isLoading={isLoading} error={error} />
{data && (
<Table aria-label="Templates">
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{data.map((template) => (
<TableRow key={template.id}>
<TableCell>{template.name}</TableCell>
<TableCell>{template.id}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
);
}

20
src/styles/global.css Normal file
View File

@@ -0,0 +1,20 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family:
"Segoe UI",
system-ui,
-apple-system,
BlinkMacSystemFont,
sans-serif;
}
button,
input,
textarea,
select {
font: inherit;
}

64
src/types/portal.ts Normal file
View File

@@ -0,0 +1,64 @@
export type Deployment = {
id: string;
status?: string;
};
export type AddDeployment = {
deploymentGroupId: string;
virtualMachineId: string;
status: string;
jsonData: string;
};
export type DeploymentGroup = {
id: string;
status?: string;
};
export type AddDeploymentGroup = {
templateId: string;
status: string;
};
export type Domain = {
id: string;
name: string;
fqdn: string;
netBIOS: string;
};
export type AddDomain = {
name: string;
fqdn: string;
netBIOS: string;
};
export type EnvironmentItem = {
id: string;
name: string;
};
export type AddEnvironment = {
name: string;
};
export type Runbook = {
id: string;
name: string;
decription?: string;
};
export type AddRunbook = {
name: string;
description: string;
};
export type Template = {
id: string;
name: string;
};
export type ServiceItem = {
id: string;
name: string;
};

1
src/vite-env.d.ts vendored Normal file
View File

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

22
tsconfig.app.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}

11
tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}

12
tsconfig.node.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts", "eslint.config.js"]
}

2
vite.config.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfigFnObject;
export default _default;

21
vite.config.js Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(function (_a) {
var _b;
var mode = _a.mode;
var env = loadEnv(mode, process.cwd(), "");
var apiTarget = (_b = env.VITE_API_PROXY_TARGET) !== null && _b !== void 0 ? _b : "https://localhost:7260";
return {
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: apiTarget,
changeOrigin: true,
secure: false,
},
},
},
};
});

21
vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const apiTarget = env.VITE_API_PROXY_TARGET ?? "https://localhost:7260";
return {
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: apiTarget,
changeOrigin: true,
secure: false,
},
},
},
};
});