Merge pull request 'inspect flake before configure VM' (#335) from feat/inspect-flake into main
This commit is contained in:
@@ -4,7 +4,7 @@ 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 flake, health, machines, root, vms
|
||||||
|
|
||||||
origins = [
|
origins = [
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
@@ -20,6 +20,7 @@ def setup_app() -> FastAPI:
|
|||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
app.include_router(flake.router)
|
||||||
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)
|
||||||
|
|||||||
46
pkgs/clan-cli/clan_cli/webui/routers/flake.py
Normal file
46
pkgs/clan-cli/clan_cli/webui/routers/flake.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
|
from clan_cli.webui.schemas import FlakeAction, FlakeResponse
|
||||||
|
|
||||||
|
from ...nix import nix_command
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/flake")
|
||||||
|
async def inspect_flake(
|
||||||
|
url: str,
|
||||||
|
) -> FlakeResponse:
|
||||||
|
actions = []
|
||||||
|
# Extract the flake from the given URL
|
||||||
|
# We do this by running 'nix flake prefetch {url} --json'
|
||||||
|
cmd = nix_command(["flake", "prefetch", url, "--json"])
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
cmd[0],
|
||||||
|
*cmd[1:],
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(stderr))
|
||||||
|
|
||||||
|
data: dict[str, str] = json.loads(stdout)
|
||||||
|
|
||||||
|
if data.get("storePath") is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Could not load flake")
|
||||||
|
|
||||||
|
content: str
|
||||||
|
with open(Path(data.get("storePath", "")) / Path("flake.nix")) as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# TODO: Figure out some measure when it is insecure to inspect or create a VM
|
||||||
|
actions.append(FlakeAction(id="vms/inspect", uri="api/vms/inspect"))
|
||||||
|
actions.append(FlakeAction(id="vms/create", uri="api/vms/create"))
|
||||||
|
|
||||||
|
return FlakeResponse(content=content, actions=actions)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -45,3 +46,13 @@ class VmConfig(BaseModel):
|
|||||||
|
|
||||||
class VmInspectResponse(BaseModel):
|
class VmInspectResponse(BaseModel):
|
||||||
config: VmConfig
|
config: VmConfig
|
||||||
|
|
||||||
|
|
||||||
|
class FlakeAction(BaseModel):
|
||||||
|
id: str
|
||||||
|
uri: str
|
||||||
|
|
||||||
|
|
||||||
|
class FlakeResponse(BaseModel):
|
||||||
|
content: str
|
||||||
|
actions: List[FlakeAction]
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
// prettier.config.js
|
|
||||||
module.exports = {
|
|
||||||
plugins: ["prettier-plugin-tailwindcss"],
|
|
||||||
tailwindFunctions: ["clsx", "tw"],
|
|
||||||
};
|
|
||||||
@@ -1,143 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { VmConfig } from "@/api/model";
|
import { Button, Paper, Typography } from "@mui/material";
|
||||||
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 { useSearchParams } from "next/navigation";
|
||||||
import { toast } from "react-hot-toast";
|
import GppMaybeIcon from "@mui/icons-material/GppMaybe";
|
||||||
import { Error, Numbers } from "@mui/icons-material";
|
import { useInspectFlake } from "@/api/default/default";
|
||||||
import { createVm, inspectVm } from "@/api/default/default";
|
import { ConfirmVM } from "@/components/join/join";
|
||||||
|
import { LoadingOverlay } from "@/components/join/loadingOverlay";
|
||||||
interface FlakeBadgeProps {
|
import { FlakeBadge } from "@/components/flakeBadge/flakeBadge";
|
||||||
flakeUrl: string;
|
import { Log } from "@/components/join/log";
|
||||||
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() {
|
export default function Page() {
|
||||||
const queryParams = useSearchParams();
|
const queryParams = useSearchParams();
|
||||||
const flakeUrl = queryParams.get("flake") || "";
|
const flakeUrl = queryParams.get("flake") || "";
|
||||||
const flakeAttribute = queryParams.get("attr") || "default";
|
const flakeAttribute = queryParams.get("attr") || "default";
|
||||||
|
const [userConfirmed, setUserConfirmed] = useState(false);
|
||||||
|
|
||||||
const { config, error, isLoading } = useVms({
|
|
||||||
url: flakeUrl,
|
|
||||||
attr: flakeAttribute,
|
|
||||||
});
|
|
||||||
const clanName = "Lassul.us";
|
const clanName = "Lassul.us";
|
||||||
|
|
||||||
|
const { data, error, isLoading } = useInspectFlake({ url: flakeUrl });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-[70vh] w-full place-items-center gap-y-4">
|
<div className="grid h-[70vh] w-full place-items-center gap-y-4">
|
||||||
<Typography variant="h4" className="w-full text-center">
|
<Typography variant="h4" className="w-full text-center">
|
||||||
@@ -148,37 +29,46 @@ export default function Page() {
|
|||||||
{"' "}
|
{"' "}
|
||||||
Clan
|
Clan
|
||||||
</Typography>
|
</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>
|
{flakeUrl && flakeAttribute ? (
|
||||||
|
userConfirmed ? (
|
||||||
|
<ConfirmVM url={flakeUrl} attr={flakeAttribute} clanName={clanName} />
|
||||||
|
) : (
|
||||||
|
<div className="mb-2 flex w-full max-w-xl flex-col items-center pb-2">
|
||||||
|
{isLoading && (
|
||||||
|
<LoadingOverlay
|
||||||
|
title={"Loading Flake"}
|
||||||
|
subtitle={
|
||||||
|
<FlakeBadge flakeUrl={flakeUrl} flakeAttr={flakeAttribute} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<Typography variant="subtitle1">
|
||||||
|
To build the VM you must trust the Author of this Flake
|
||||||
|
</Typography>
|
||||||
|
<GppMaybeIcon sx={{ height: "10rem", width: "10rem", mb: 5 }} />
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
color="warning"
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => setUserConfirmed(true)}
|
||||||
|
sx={{ mb: 10 }}
|
||||||
|
>
|
||||||
|
Trust Flake Author
|
||||||
|
</Button>
|
||||||
|
<Log
|
||||||
|
title="What's about to be built"
|
||||||
|
lines={data.data.content.split("\n")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
{(!flakeUrl || !flakeAttribute) && <div>Invalid URL</div>}
|
) : (
|
||||||
{config && <VmDetails vmConfig={config} />}
|
<div>Invalid URL</div>
|
||||||
{error && (
|
)}
|
||||||
<ErrorLog
|
|
||||||
lines={
|
|
||||||
error?.response?.data?.detail
|
|
||||||
?.map((err, idx) => err.msg.split("\n"))
|
|
||||||
?.flat()
|
|
||||||
.filter(Boolean) || []
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
13
pkgs/ui/src/components/flakeBadge/flakeBadge.tsx
Normal file
13
pkgs/ui/src/components/flakeBadge/flakeBadge.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Chip } from "@mui/material";
|
||||||
|
|
||||||
|
interface FlakeBadgeProps {
|
||||||
|
flakeUrl: string;
|
||||||
|
flakeAttr: string;
|
||||||
|
}
|
||||||
|
export const FlakeBadge = (props: FlakeBadgeProps) => (
|
||||||
|
<Chip
|
||||||
|
color="secondary"
|
||||||
|
label={`${props.flakeUrl}#${props.flakeAttr}`}
|
||||||
|
sx={{ p: 2 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
147
pkgs/ui/src/components/join/join.tsx
Normal file
147
pkgs/ui/src/components/join/join.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"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";
|
||||||
|
import { LoadingOverlay } from "./loadingOverlay";
|
||||||
|
import { FlakeBadge } from "../flakeBadge/flakeBadge";
|
||||||
|
import { Log } from "./log";
|
||||||
|
|
||||||
|
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 ConfirmVMProps {
|
||||||
|
url: string;
|
||||||
|
attr: string;
|
||||||
|
clanName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmVM(props: ConfirmVMProps) {
|
||||||
|
const { url, attr, clanName } = props;
|
||||||
|
|
||||||
|
const { config, error, isLoading } = useVms({
|
||||||
|
url,
|
||||||
|
attr,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" className="w-full max-w-xl">
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
An Error occurred - See details below
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<div className="mb-2 w-full max-w-xl">
|
||||||
|
{isLoading && (
|
||||||
|
<LoadingOverlay
|
||||||
|
title={"Loading VM Configuration"}
|
||||||
|
subtitle={<FlakeBadge flakeUrl={url} flakeAttr={url} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{config && <VmDetails vmConfig={config} />}
|
||||||
|
{error && (
|
||||||
|
<Log
|
||||||
|
title="Log"
|
||||||
|
lines={
|
||||||
|
error?.response?.data?.detail
|
||||||
|
?.map((err, idx) => err.msg.split("\n"))
|
||||||
|
?.flat()
|
||||||
|
.filter(Boolean) || []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
pkgs/ui/src/components/join/loadingOverlay.tsx
Normal file
17
pkgs/ui/src/components/join/loadingOverlay.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { LinearProgress, Typography } from "@mui/material";
|
||||||
|
|
||||||
|
interface LoadingOverlayProps {
|
||||||
|
title: React.ReactNode;
|
||||||
|
subtitle: React.ReactNode;
|
||||||
|
}
|
||||||
|
export const LoadingOverlay = (props: LoadingOverlayProps) => {
|
||||||
|
const { title, subtitle } = props;
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Typography variant="subtitle2">{title}</Typography>
|
||||||
|
<LinearProgress className="mb-2 w-full" />
|
||||||
|
<div className="grid w-full place-items-center">{subtitle}</div>
|
||||||
|
<Typography variant="subtitle1"></Typography>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
pkgs/ui/src/components/join/log.tsx
Normal file
19
pkgs/ui/src/components/join/log.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
interface LogOptions {
|
||||||
|
lines: string[];
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
export const Log = (props: LogOptions) => {
|
||||||
|
const { lines, title } = props;
|
||||||
|
return (
|
||||||
|
<div className="max-h-[70vh] min-h-[9rem] w-full overflow-scroll bg-slate-800 p-4 text-white shadow-inner shadow-black">
|
||||||
|
<div className="mb-1 text-slate-400">{title}</div>
|
||||||
|
<pre className="max-w-[90vw] text-xs">
|
||||||
|
{lines.map((item, idx) => (
|
||||||
|
<code key={`${idx}`} className="mb-2 block break-words">
|
||||||
|
{item}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user