Merge pull request 'add join clan page' (#300) from feat/join-ui into main

This commit is contained in:
clan-bot
2023-09-16 14:31:33 +00:00
9 changed files with 296 additions and 6 deletions

View File

@@ -1,13 +1,25 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from .assets import asset_path from .assets import asset_path
from .routers import health, machines, root, vms from .routers import health, machines, root, vms
origins = [
"http://localhost:3000",
]
def setup_app() -> FastAPI: def setup_app() -> FastAPI:
app = FastAPI() app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health.router) app.include_router(health.router)
app.include_router(machines.router) app.include_router(machines.router)
app.include_router(root.router) app.include_router(root.router)

View File

@@ -62,11 +62,18 @@ def start_server(args: argparse.Namespace) -> None:
if ":" in host: if ":" in host:
host = f"[{host}]" host = f"[{host}]"
headers = [ headers = [
( # (
"Access-Control-Allow-Origin", # "Access-Control-Allow-Origin",
f"http://{host}:{args.dev_port}", # f"http://{host}:{args.dev_port}",
), # ),
("Access-Control-Allow-Methods", "HEAD, POST, GET, OPTIONS"), # (
# "Access-Control-Allow-Methods",
# "DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT"
# ),
# (
# "Allow",
# "DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT"
# )
] ]
else: else:
open_url = f"http://[{args.host}]:{args.port}" open_url = f"http://[{args.host}]:{args.port}"

View File

@@ -1,4 +1,7 @@
{ {
# Use this path to our repo root e.g. for UI test
# inputs.clan-core.url = "../../../../.";
# this placeholder is replaced by the path to nixpkgs # this placeholder is replaced by the path to nixpkgs
inputs.clan-core.url = "__CLAN_CORE__"; inputs.clan-core.url = "__CLAN_CORE__";

View File

@@ -10986,6 +10986,11 @@
descriptor = "^0.4.1"; descriptor = "^0.4.1";
pin = "0.4.1"; pin = "0.4.1";
}; };
pretty-bytes = {
descriptor = "^6.1.1";
pin = "6.1.1";
runtime = true;
};
react = { react = {
descriptor = "18.2.0"; descriptor = "18.2.0";
pin = "18.2.0"; pin = "18.2.0";
@@ -13086,6 +13091,9 @@
dev = true; dev = true;
key = "prettier-plugin-tailwindcss/0.4.1"; key = "prettier-plugin-tailwindcss/0.4.1";
}; };
"node_modules/pretty-bytes" = {
key = "pretty-bytes/6.1.1";
};
"node_modules/printable-characters" = { "node_modules/printable-characters" = {
dev = true; dev = true;
key = "printable-characters/1.0.42"; key = "printable-characters/1.0.42";
@@ -15195,6 +15203,19 @@
version = "0.4.1"; version = "0.4.1";
}; };
}; };
pretty-bytes = {
"6.1.1" = {
fetchInfo = {
narHash = "sha256-ERXqMD/9tkPebbHVL3n/9EQRz7mFs5VYO6k/wo5JDzQ=";
type = "tarball";
url = "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz";
};
ident = "pretty-bytes";
ltype = "file";
treeInfo = { };
version = "6.1.1";
};
};
printable-characters = { printable-characters = {
"1.0.42" = { "1.0.42" = {
fetchInfo = { fetchInfo = {

View File

@@ -1,5 +1,5 @@
const config = { const config = {
petstore: { clan: {
output: { output: {
mode: "tags-split", mode: "tags-split",
target: "src/api", target: "src/api",

View File

@@ -22,6 +22,7 @@
"hex-rgb": "^5.0.0", "hex-rgb": "^5.0.0",
"next": "13.4.12", "next": "13.4.12",
"postcss": "8.4.27", "postcss": "8.4.27",
"pretty-bytes": "^6.1.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "^7.45.4", "react-hook-form": "^7.45.4",
@@ -6810,6 +6811,17 @@
} }
} }
}, },
"node_modules/pretty-bytes": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
"integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==",
"engines": {
"node": "^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/printable-characters": { "node_modules/printable-characters": {
"version": "1.0.42", "version": "1.0.42",
"resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz",

View File

@@ -26,6 +26,7 @@
"hex-rgb": "^5.0.0", "hex-rgb": "^5.0.0",
"next": "13.4.12", "next": "13.4.12",
"postcss": "8.4.27", "postcss": "8.4.27",
"pretty-bytes": "^6.1.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "^7.45.4", "react-hook-form": "^7.45.4",

View File

@@ -0,0 +1,184 @@
"use client";
import React, { useState } from "react";
import { VmConfig } from "@/api/model";
import { useVms } from "@/components/hooks/useVms";
import prettyBytes from "pretty-bytes";
import {
Alert,
AlertTitle,
Button,
Chip,
LinearProgress,
ListSubheader,
Switch,
Typography,
} from "@mui/material";
import { useSearchParams } from "next/navigation";
import { toast } from "react-hot-toast";
import { Error, Numbers } from "@mui/icons-material";
import { createVm, inspectVm } from "@/api/default/default";
interface FlakeBadgeProps {
flakeUrl: string;
flakeAttr: string;
}
const FlakeBadge = (props: FlakeBadgeProps) => (
<Chip
color="secondary"
label={`${props.flakeUrl}#${props.flakeAttr}`}
sx={{ p: 2 }}
/>
);
interface VmPropLabelProps {
children: React.ReactNode;
}
const VmPropLabel = (props: VmPropLabelProps) => (
<div className="col-span-4 flex items-center sm:col-span-1">
{props.children}
</div>
);
interface VmPropContentProps {
children: React.ReactNode;
}
const VmPropContent = (props: VmPropContentProps) => (
<div className="col-span-4 font-bold sm:col-span-3">{props.children}</div>
);
interface VmDetailsProps {
vmConfig: VmConfig;
}
const VmDetails = (props: VmDetailsProps) => {
const { vmConfig } = props;
const { cores, flake_attr, flake_url, graphics, memory_size } = vmConfig;
const [isStarting, setStarting] = useState(false);
const handleStartVm = async () => {
setStarting(true);
const response = await createVm(vmConfig);
setStarting(false);
if (response.statusText === "OK") {
toast.success(("VM created @ " + response?.data) as string);
} else {
toast.error("Could not create VM");
}
};
return (
<div className="grid grid-cols-4 gap-y-10">
<div className="col-span-4">
<ListSubheader>General</ListSubheader>
</div>
<VmPropLabel>Flake</VmPropLabel>
<VmPropContent>
<FlakeBadge flakeAttr={flake_attr} flakeUrl={flake_url} />
</VmPropContent>
<VmPropLabel>Machine</VmPropLabel>
<VmPropContent>{flake_attr}</VmPropContent>
<div className="col-span-4">
<ListSubheader>VM</ListSubheader>
</div>
<VmPropLabel>CPU Cores</VmPropLabel>
<VmPropContent>
<Numbers fontSize="inherit" />
<span className="font-bold text-black">{cores}</span>
</VmPropContent>
<VmPropLabel>Graphics</VmPropLabel>
<VmPropContent>
<Switch checked={graphics} />
</VmPropContent>
<VmPropLabel>Memory Size</VmPropLabel>
<VmPropContent>{prettyBytes(memory_size * 1024 * 1024)}</VmPropContent>
<div className="col-span-4 grid items-center">
{isStarting && <LinearProgress />}
<Button
disabled={isStarting}
variant="contained"
onClick={handleStartVm}
>
Spin up VM
</Button>
</div>
</div>
);
};
interface ErrorLogOptions {
lines: string[];
}
const ErrorLog = (props: ErrorLogOptions) => {
const { lines } = props;
return (
<div className="w-full bg-slate-800 p-4 text-white shadow-inner shadow-black">
<div className="mb-1 text-slate-400">Log</div>
{lines.map((item, idx) => (
<span key={`${idx}`} className="mb-2 block break-words">
{item}
<br />
</span>
))}
</div>
);
};
export default function Page() {
const queryParams = useSearchParams();
const flakeUrl = queryParams.get("flake") || "";
const flakeAttribute = queryParams.get("attr") || "default";
const { config, error, isLoading } = useVms({
url: flakeUrl,
attr: flakeAttribute,
});
const clanName = "Lassul.us";
return (
<div className="grid h-[70vh] w-full place-items-center gap-y-4">
<Typography variant="h4" className="w-full text-center">
Join{" "}
<Typography variant="h4" className="font-bold" component={"span"}>
{clanName}
</Typography>
{"' "}
Clan
</Typography>
{error && (
<Alert severity="error" className="w-full max-w-xl">
<AlertTitle>Error</AlertTitle>
An Error occurred - See details below
</Alert>
)}
<div className="w-full max-w-xl">
{isLoading && (
<div className="w-full">
<Typography variant="subtitle2">Loading Flake</Typography>
<LinearProgress className="mb-2 w-full" />
<div className="grid w-full place-items-center">
<FlakeBadge flakeUrl={flakeUrl} flakeAttr={flakeAttribute} />
</div>
<Typography variant="subtitle1"></Typography>
</div>
)}
{(!flakeUrl || !flakeAttribute) && <div>Invalid URL</div>}
{config && <VmDetails vmConfig={config} />}
{error && (
<ErrorLog
lines={
error?.response?.data?.detail
?.map((err, idx) => err.msg.split("\n"))
?.flat()
.filter(Boolean) || []
}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { inspectVm } from "@/api/default/default";
import { HTTPValidationError, VmConfig } from "@/api/model";
import { AxiosError } from "axios";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
interface UseVmsOptions {
url: string;
attr: string;
}
export const useVms = (options: UseVmsOptions) => {
const { url, attr } = options;
const [isLoading, setIsLoading] = useState(true);
const [config, setConfig] = useState<VmConfig>();
const [error, setError] = useState<AxiosError<HTTPValidationError>>();
useEffect(() => {
const getVmInfo = async (url: string, attr: string) => {
if (url === "") {
toast.error("Flake url is missing", { id: "missing.flake.url" });
return undefined;
}
try {
const response = await inspectVm({
flake_attr: attr,
flake_url: url,
});
const {
data: { config },
} = response;
setError(undefined);
return config;
} catch (e) {
const err = e as AxiosError<HTTPValidationError>;
setError(err);
toast.error(err.message);
return undefined;
} finally {
setIsLoading(false);
}
};
getVmInfo(url, attr).then((c) => setConfig(c));
}, [url, attr]);
return {
error,
isLoading,
config,
};
};