docs(reference): import Dune: Awakening server-manager references
Phase 2 references for the host-agent Dune adapter, moved out of volatile /tmp
into docs/reference-repos/ (per Commander). Three upstream projects, .git +
node_modules + compiled binaries stripped (16MB source). Nested AI-instruction
files (.claude/, CLAUDE.md) removed so they don't pollute Corrosion sessions.
- icehunter/ dune-admin (Go+React) — 4 control planes; SETUP_DOCKER.md is the
closest analog to our agent's Dune docker control plane (compose
lifecycle, docker logs, RabbitMQ-via-exec, dune Postgres schema)
- adainrivers/ Rust/Tauri desktop — SSH+k8s BattleGroup control, maintenance
daemon, in-game admin console (Rust idiom reference)
- the4rchangel/ Node web UI replacing battlegroup.bat — matches the Commander's
Hyper-V self-host path + game-config schema
See docs/reference-repos/README.md for the full index + how we use each.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { Card, Flex, Heading, Text } from "@radix-ui/themes";
|
||||
|
||||
export type AppErrorBoundaryProps = {
|
||||
onError: (message: string) => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type AppErrorBoundaryState = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export default class AppErrorBoundary extends Component<AppErrorBoundaryProps, AppErrorBoundaryState> {
|
||||
state: AppErrorBoundaryState = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): AppErrorBoundaryState {
|
||||
return { error: error.message };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
this.props.onError(`${error.message}\n${info.componentStack}`);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<Card size="3" variant="surface" className="pane page-pane">
|
||||
<Flex direction="column" gap="3">
|
||||
<Heading size="4">UI Error</Heading>
|
||||
<Text size="2" color="gray">
|
||||
The view failed to render. Details were written to the log window.
|
||||
</Text>
|
||||
<Text size="2" className="mono">
|
||||
{this.state.error}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { InfoCircledIcon } from "@radix-ui/react-icons";
|
||||
import { Button, Dialog, Flex, IconButton, Link, Text } from "@radix-ui/themes";
|
||||
|
||||
import { openExternal } from "../../services/tauri";
|
||||
|
||||
const REPO_URL = "https://github.com/adainrivers/dune-dedicated-server-manager";
|
||||
const ISSUES_URL = `${REPO_URL}/issues`;
|
||||
|
||||
/**
|
||||
* Small info button (sits next to "Check for updates") that opens an About
|
||||
* modal showing the app version and links back to the project. Self-contained:
|
||||
* owns its open state so it can be dropped into the header without prop
|
||||
* threading.
|
||||
*/
|
||||
export default function AboutDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
|
||||
// The bundled dune-server-service ships with the same version as the app,
|
||||
// so this number identifies both. Fetched from the Tauri runtime rather than
|
||||
// package.json so it reflects the actually-installed build.
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void getVersion()
|
||||
.then((v) => {
|
||||
if (active) setVersion(v);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setVersion(null);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openLink = (url: string) => () => {
|
||||
void openExternal(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger>
|
||||
<IconButton size="1" variant="surface" aria-label="About this app" title="About">
|
||||
<InfoCircledIcon />
|
||||
</IconButton>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content maxWidth="460px">
|
||||
<Dialog.Title>About</Dialog.Title>
|
||||
<Dialog.Description size="2" style={{ color: "var(--color-text-muted)" }}>
|
||||
Dune Dedicated Server Manager
|
||||
</Dialog.Description>
|
||||
|
||||
<Flex direction="column" gap="3" mt="4">
|
||||
<Flex justify="between" align="center">
|
||||
<Text size="2" color="gray">
|
||||
Version
|
||||
</Text>
|
||||
<Text size="2" className="mono">
|
||||
{version ?? "—"}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex justify="between" align="center">
|
||||
<Text size="2" color="gray">
|
||||
Repository
|
||||
</Text>
|
||||
<Link size="2" href={REPO_URL} onClick={(e) => { e.preventDefault(); openLink(REPO_URL)(); }}>
|
||||
GitHub
|
||||
</Link>
|
||||
</Flex>
|
||||
|
||||
<Flex justify="between" align="center">
|
||||
<Text size="2" color="gray">
|
||||
Found a bug?
|
||||
</Text>
|
||||
<Link size="2" href={ISSUES_URL} onClick={(e) => { e.preventDefault(); openLink(ISSUES_URL)(); }}>
|
||||
Report an issue
|
||||
</Link>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex gap="3" justify="end" mt="5">
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray">
|
||||
Close
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Button, Callout, Dialog, Flex, Grid, TextField } from "@radix-ui/themes";
|
||||
import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
|
||||
|
||||
import { openFileDialog, type PreflightCheck } from "../../services/tauri";
|
||||
import type { RemoteAttachForm } from "../../types/ui";
|
||||
import ActionButton from "../ui/ActionButton";
|
||||
import Field from "../ui/Field";
|
||||
|
||||
export type RemoteAttachDialogProps = {
|
||||
open: boolean;
|
||||
form: RemoteAttachForm;
|
||||
running: boolean;
|
||||
errorMessage?: string | null;
|
||||
preflight?: PreflightCheck | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onChange: (form: RemoteAttachForm) => void;
|
||||
onAttach: () => void;
|
||||
};
|
||||
|
||||
export default function RemoteAttachDialog({
|
||||
open,
|
||||
form,
|
||||
running,
|
||||
errorMessage,
|
||||
preflight,
|
||||
onOpenChange,
|
||||
onChange,
|
||||
onAttach,
|
||||
}: RemoteAttachDialogProps) {
|
||||
const canAttach =
|
||||
form.host.trim().length > 0 &&
|
||||
form.user.trim().length > 0 &&
|
||||
form.keyPath.trim().length > 0 &&
|
||||
form.port > 0 &&
|
||||
!running;
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Content maxWidth="540px">
|
||||
<Dialog.Title>Add Remote Server</Dialog.Title>
|
||||
<Dialog.Description size="2" style={{ color: "var(--color-text-muted)" }}>
|
||||
Connect over SSH and detect existing Dune battlegroups. Vendor wrapper commands
|
||||
always execute as <code>dune</code>; if you log in as root we drop into dune via
|
||||
sudo automatically.
|
||||
</Dialog.Description>
|
||||
<Flex direction="column" gap="3" mt="4">
|
||||
<Field label="Host or IP">
|
||||
<TextField.Root
|
||||
placeholder="203.0.113.10"
|
||||
disabled={running}
|
||||
value={form.host}
|
||||
onChange={(event) => onChange({ ...form, host: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Grid columns="3fr 1fr" gap="3">
|
||||
<Field label="SSH User">
|
||||
<TextField.Root
|
||||
placeholder="dune"
|
||||
disabled={running}
|
||||
value={form.user}
|
||||
onChange={(event) => onChange({ ...form, user: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="SSH Port">
|
||||
<TextField.Root
|
||||
placeholder="22"
|
||||
disabled={running}
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={String(form.port)}
|
||||
onChange={(event) => {
|
||||
const parsed = parseInt(event.target.value, 10);
|
||||
onChange({ ...form, port: isNaN(parsed) ? 22 : parsed });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</Grid>
|
||||
<Field label="Private Key">
|
||||
<Grid columns="1fr auto" gap="2">
|
||||
<TextField.Root
|
||||
placeholder="Choose SSH private key"
|
||||
value={form.keyPath}
|
||||
disabled={running}
|
||||
onChange={(event) => onChange({ ...form, keyPath: event.target.value })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="surface"
|
||||
disabled={running}
|
||||
onClick={async () => {
|
||||
const selected = await openFileDialog("Choose SSH private key");
|
||||
if (selected) onChange({ ...form, keyPath: selected });
|
||||
}}
|
||||
>
|
||||
Choose
|
||||
</Button>
|
||||
</Grid>
|
||||
</Field>
|
||||
{errorMessage ? (
|
||||
<Callout.Root color="red" variant="surface">
|
||||
<Callout.Icon>
|
||||
<ExclamationTriangleIcon />
|
||||
</Callout.Icon>
|
||||
<Callout.Text style={{ whiteSpace: "pre-wrap" }}>{errorMessage}</Callout.Text>
|
||||
</Callout.Root>
|
||||
) : null}
|
||||
{preflight && !errorMessage ? (
|
||||
<Callout.Root color="green" variant="surface">
|
||||
<Callout.Text>
|
||||
Preflight passed: SSH ok, sudo to dune ok, dune passwordless sudo ok.
|
||||
</Callout.Text>
|
||||
</Callout.Root>
|
||||
) : null}
|
||||
</Flex>
|
||||
<Flex gap="3" justify="end" mt="5">
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray" disabled={running}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
<ActionButton
|
||||
onClick={onAttach}
|
||||
busy={running}
|
||||
disabled={!canAttach}
|
||||
tone="accent"
|
||||
pendingLabel="Checking"
|
||||
>
|
||||
Detect and Add
|
||||
</ActionButton>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { AlertDialog, Box, Button, Flex } from "@radix-ui/themes";
|
||||
|
||||
import type { RemoteServerRecord } from "../../types/server";
|
||||
import Metric from "../ui/Metric";
|
||||
|
||||
export type RemoveRemoteServerDialogProps = {
|
||||
server: RemoteServerRecord | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onRemove: (server: RemoteServerRecord) => void;
|
||||
};
|
||||
|
||||
export default function RemoveRemoteServerDialog({
|
||||
server,
|
||||
onOpenChange,
|
||||
onRemove,
|
||||
}: RemoveRemoteServerDialogProps) {
|
||||
return (
|
||||
<AlertDialog.Root open={!!server} onOpenChange={onOpenChange}>
|
||||
<AlertDialog.Content maxWidth="520px">
|
||||
<AlertDialog.Title>Forget Remote Server</AlertDialog.Title>
|
||||
<AlertDialog.Description size="2" color="gray">
|
||||
This only removes the saved server entry from this app. The remote host and Dune battlegroup will not be changed.
|
||||
</AlertDialog.Description>
|
||||
{server ? (
|
||||
<Box className="info-card" mt="4">
|
||||
<Metric label="Host" value={server.host} />
|
||||
<Metric label="Battlegroup" value={server.battlegroupName || "unknown"} />
|
||||
</Box>
|
||||
) : null}
|
||||
<Flex gap="3" justify="end" mt="5">
|
||||
<AlertDialog.Cancel>
|
||||
<Button variant="soft" color="gray">
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action>
|
||||
<Button color="red" onClick={() => server && onRemove(server)}>
|
||||
Forget Server
|
||||
</Button>
|
||||
</AlertDialog.Action>
|
||||
</Flex>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import Markdown from "markdown-to-jsx";
|
||||
import { AlertDialog, Box, Button, Flex, Link, Text } from "@radix-ui/themes";
|
||||
|
||||
import { openExternal } from "../../services/tauri";
|
||||
import type { Update } from "../../services/updater";
|
||||
import type { UpdateStatus } from "../../types/update";
|
||||
|
||||
const RELEASES_URL = "https://github.com/adainrivers/dune-dedicated-server-manager/releases";
|
||||
|
||||
// Links inside the release notes must open in the system browser, not navigate
|
||||
// the Tauri webview away from the app.
|
||||
function NotesLink({ href, children }: ComponentPropsWithoutRef<"a">) {
|
||||
return (
|
||||
<Link
|
||||
size="2"
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (href) void openExternal(href);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export type UpdateDialogProps = {
|
||||
open: boolean;
|
||||
update: Update | null;
|
||||
status: UpdateStatus;
|
||||
progress: string | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onInstall: () => void;
|
||||
};
|
||||
|
||||
export default function UpdateDialog({
|
||||
open,
|
||||
update,
|
||||
status,
|
||||
progress,
|
||||
onOpenChange,
|
||||
onInstall,
|
||||
}: UpdateDialogProps) {
|
||||
const busy = status === "installing" || status === "relaunching";
|
||||
return (
|
||||
<AlertDialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialog.Content maxWidth="520px">
|
||||
<AlertDialog.Title>Install app update?</AlertDialog.Title>
|
||||
<AlertDialog.Description size="2">
|
||||
{update
|
||||
? `Version ${update.version} is available. The app will download the signed installer, install it, and relaunch.`
|
||||
: "No update is currently selected."}
|
||||
</AlertDialog.Description>
|
||||
{update?.body ? (
|
||||
<Box mt="3">
|
||||
<Text size="2" weight="medium">
|
||||
What's new
|
||||
</Text>
|
||||
{/* Render the release notes as markdown, bounded with scroll so a
|
||||
long changelog can never push the dialog past the viewport. */}
|
||||
<Box className="release-notes-md">
|
||||
<Markdown options={{ forceBlock: true, overrides: { a: NotesLink } }}>
|
||||
{update.body}
|
||||
</Markdown>
|
||||
</Box>
|
||||
<Flex mt="2">
|
||||
<Link
|
||||
size="1"
|
||||
href={RELEASES_URL}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openExternal(RELEASES_URL);
|
||||
}}
|
||||
>
|
||||
Full release notes
|
||||
</Link>
|
||||
</Flex>
|
||||
</Box>
|
||||
) : null}
|
||||
{progress ? (
|
||||
<Text as="p" size="2" color="gray" mt="3" className="mono">
|
||||
{progress}
|
||||
</Text>
|
||||
) : null}
|
||||
<Flex gap="3" mt="4" justify="end">
|
||||
<AlertDialog.Cancel disabled={busy}>
|
||||
<Button variant="soft" color="gray" disabled={busy}>
|
||||
Later
|
||||
</Button>
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action disabled={!update || busy}>
|
||||
<Button disabled={!update || busy} onClick={onInstall}>
|
||||
{busy ? "Installing..." : "Install update"}
|
||||
</Button>
|
||||
</AlertDialog.Action>
|
||||
</Flex>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Flex } from "@radix-ui/themes";
|
||||
|
||||
import type { Update } from "../../services/updater";
|
||||
import type { RemoteServerRecord, RemoteServerStatus } from "../../types/server";
|
||||
import type { ActivePage } from "../../types/ui";
|
||||
import type { UpdateStatus } from "../../types/update";
|
||||
import TopNav from "./TopNav";
|
||||
import UpdateHeaderControl from "./UpdateHeaderControl";
|
||||
|
||||
export type HeaderProps = {
|
||||
activePage: ActivePage;
|
||||
servers: RemoteServerRecord[];
|
||||
statuses: Record<string, RemoteServerStatus>;
|
||||
statusErrors: Record<string, string>;
|
||||
busyMap: Record<string, string>;
|
||||
onOpenServersList: () => void;
|
||||
onOpenServer: (serverId: string) => void;
|
||||
onAddServer: () => void;
|
||||
updateStatus: UpdateStatus;
|
||||
update: Update | null;
|
||||
updateProgress: string | null;
|
||||
onCheckUpdate: () => void;
|
||||
onOpenUpdate: () => void;
|
||||
};
|
||||
|
||||
export default function Header({
|
||||
activePage,
|
||||
servers,
|
||||
statuses,
|
||||
statusErrors,
|
||||
busyMap,
|
||||
onOpenServersList,
|
||||
onOpenServer,
|
||||
onAddServer,
|
||||
updateStatus,
|
||||
update,
|
||||
updateProgress,
|
||||
onCheckUpdate,
|
||||
onOpenUpdate,
|
||||
}: HeaderProps) {
|
||||
return (
|
||||
<Flex asChild align="center" justify="between" px="4" py="3" className="app-header">
|
||||
<header>
|
||||
<Flex align="center" gap="4">
|
||||
<Flex align="center" gap="3">
|
||||
<span className="app-glyph" aria-hidden>
|
||||
D
|
||||
</span>
|
||||
<Flex direction="column" gap="0">
|
||||
<span className="app-title">Dune Dedicated Server Manager</span>
|
||||
<span className="app-title-sub">Operator console</span>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<TopNav
|
||||
activePage={activePage}
|
||||
servers={servers}
|
||||
statuses={statuses}
|
||||
statusErrors={statusErrors}
|
||||
busyMap={busyMap}
|
||||
onOpenServersList={onOpenServersList}
|
||||
onOpenServer={onOpenServer}
|
||||
onAddServer={onAddServer}
|
||||
/>
|
||||
</Flex>
|
||||
<UpdateHeaderControl
|
||||
status={updateStatus}
|
||||
update={update}
|
||||
progress={updateProgress}
|
||||
onCheck={onCheckUpdate}
|
||||
onOpenUpdate={onOpenUpdate}
|
||||
/>
|
||||
</header>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { TabNav, Tooltip } from "@radix-ui/themes";
|
||||
import { PlusIcon } from "@radix-ui/react-icons";
|
||||
|
||||
import type { RemoteServerRecord, RemoteServerStatus } from "../../types/server";
|
||||
import type { ActivePage } from "../../types/ui";
|
||||
import { resolveServerStatus } from "../../utils/remote-server";
|
||||
|
||||
export type TopNavProps = {
|
||||
activePage: ActivePage;
|
||||
servers: RemoteServerRecord[];
|
||||
statuses: Record<string, RemoteServerStatus>;
|
||||
statusErrors: Record<string, string>;
|
||||
busyMap: Record<string, string>;
|
||||
onOpenServersList: () => void;
|
||||
onOpenServer: (serverId: string) => void;
|
||||
onAddServer: () => void;
|
||||
};
|
||||
|
||||
export default function TopNav({
|
||||
activePage,
|
||||
servers,
|
||||
statuses,
|
||||
statusErrors,
|
||||
busyMap,
|
||||
onOpenServersList,
|
||||
onOpenServer,
|
||||
onAddServer,
|
||||
}: TopNavProps) {
|
||||
const serversActive = activePage.kind === "servers";
|
||||
const activeServerId = activePage.kind === "server" ? activePage.serverId : null;
|
||||
return (
|
||||
<nav aria-label="Primary navigation" className="top-nav">
|
||||
<TabNav.Root size="2" color="bronze" className="server-tab-strip">
|
||||
<TabNav.Link
|
||||
href="#"
|
||||
active={serversActive}
|
||||
aria-current={serversActive ? "page" : undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onOpenServersList();
|
||||
}}
|
||||
>
|
||||
Servers ({servers.length})
|
||||
</TabNav.Link>
|
||||
{servers.map((server) => {
|
||||
const status = statuses[server.id];
|
||||
const resolved = resolveServerStatus(
|
||||
statusErrors[server.id],
|
||||
status,
|
||||
!!busyMap[server.id],
|
||||
server,
|
||||
);
|
||||
const isActive = activeServerId === server.id;
|
||||
return (
|
||||
<TabNav.Link
|
||||
key={server.id}
|
||||
href="#"
|
||||
active={isActive}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onOpenServer(server.id);
|
||||
}}
|
||||
>
|
||||
<span className="server-tab-dot" data-tone={resolved.tone} aria-hidden />
|
||||
<span className="server-tab-label">{server.name}</span>
|
||||
</TabNav.Link>
|
||||
);
|
||||
})}
|
||||
<Tooltip content="Add remote server">
|
||||
<button
|
||||
type="button"
|
||||
className="server-tab-add"
|
||||
aria-label="Add remote server"
|
||||
onClick={onAddServer}
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</TabNav.Root>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Badge, Button, Flex } from "@radix-ui/themes";
|
||||
|
||||
import type { Update } from "../../services/updater";
|
||||
import type { UpdateStatus } from "../../types/update";
|
||||
import { updateLabel, updateTone } from "../../utils/formatting";
|
||||
import AboutDialog from "../dialogs/AboutDialog";
|
||||
|
||||
export type UpdateHeaderControlProps = {
|
||||
status: UpdateStatus;
|
||||
update: Update | null;
|
||||
progress: string | null;
|
||||
onCheck: () => void;
|
||||
onOpenUpdate: () => void;
|
||||
};
|
||||
|
||||
export default function UpdateHeaderControl({
|
||||
status,
|
||||
update,
|
||||
progress,
|
||||
onCheck,
|
||||
onOpenUpdate,
|
||||
}: UpdateHeaderControlProps) {
|
||||
const busy = status === "checking" || status === "installing" || status === "relaunching";
|
||||
const hasUpdate = Boolean(update);
|
||||
return (
|
||||
<Flex align="center" gap="2" className="header-update">
|
||||
<Badge color={updateTone(status)} variant="soft">
|
||||
{updateLabel(status, update, progress)}
|
||||
</Badge>
|
||||
<Button size="1" variant={hasUpdate ? "solid" : "surface"} disabled={busy} onClick={hasUpdate ? onOpenUpdate : onCheck}>
|
||||
{busy ? "Working..." : hasUpdate ? "Install" : "Check for updates"}
|
||||
</Button>
|
||||
<AboutDialog />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Box, Flex, Grid, Select, Text, Tooltip } from "@radix-ui/themes";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
FilePlusIcon,
|
||||
TrashIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
|
||||
import { getLogsFolder, openLogsFolder } from "../../services/tauri";
|
||||
import type { LogLevelFilter, LogRow } from "../../types/log";
|
||||
|
||||
export type LogWindowProps = {
|
||||
rows: LogRow[];
|
||||
level: LogLevelFilter;
|
||||
collapsed: boolean;
|
||||
scopedToServer: boolean;
|
||||
canScopeToServer: boolean;
|
||||
onLevelChange: (level: LogLevelFilter) => void;
|
||||
onClear: () => void;
|
||||
onToggleCollapsed: () => void;
|
||||
onToggleScope: (next: boolean) => void;
|
||||
};
|
||||
|
||||
export default function LogWindow({
|
||||
rows,
|
||||
level,
|
||||
collapsed,
|
||||
scopedToServer,
|
||||
canScopeToServer,
|
||||
onLevelChange,
|
||||
onClear,
|
||||
onToggleCollapsed,
|
||||
onToggleScope,
|
||||
}: LogWindowProps) {
|
||||
const bodyRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickToBottomRef = useRef(true);
|
||||
const [logsFolder, setLogsFolder] = useState<string>("");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const body = bodyRef.current;
|
||||
if (!body) return;
|
||||
if (stickToBottomRef.current) {
|
||||
body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
}, [rows]);
|
||||
|
||||
useEffect(() => {
|
||||
void getLogsFolder()
|
||||
.then(setLogsFolder)
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const latestLevel = rows.length > 0 ? rows[rows.length - 1].level : "info";
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<aside className="log-sidebar" data-collapsed="true">
|
||||
<Tooltip content="Expand logs">
|
||||
<button
|
||||
type="button"
|
||||
className="log-sidebar-toggle"
|
||||
aria-label="Expand logs"
|
||||
onClick={onToggleCollapsed}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className="log-sidebar-rail">
|
||||
<span className="log-sidebar-rail-label">LOGS</span>
|
||||
<span className="log-sidebar-rail-count">{rows.length}</span>
|
||||
<span className={`log-sidebar-rail-dot log-${latestLevel}`} aria-hidden />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="log-sidebar" data-collapsed="false">
|
||||
<Flex direction="column" height="100%" minHeight="0" gap="2" p="3">
|
||||
<Flex align="center" justify="between" gap="2" wrap="wrap">
|
||||
<Tooltip content={logsFolder ? `Persisted at ${logsFolder}` : "Operation log"}>
|
||||
<Box>
|
||||
<Text as="div" size="2" weight="medium">
|
||||
Logs
|
||||
</Text>
|
||||
<Text as="div" size="1" style={{ color: "var(--color-text-muted)" }}>
|
||||
{rows.length} entries
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Tooltip content="Collapse logs">
|
||||
<button
|
||||
type="button"
|
||||
className="log-sidebar-toggle"
|
||||
aria-label="Collapse logs"
|
||||
onClick={onToggleCollapsed}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
<Flex align="center" gap="2" wrap="wrap">
|
||||
<Select.Root size="1" value={level} onValueChange={(value) => onLevelChange(value as LogLevelFilter)}>
|
||||
<Select.Trigger variant="surface" aria-label="Minimum log level" />
|
||||
<Select.Content>
|
||||
<Select.Item value="debug">Debug</Select.Item>
|
||||
<Select.Item value="info">Info</Select.Item>
|
||||
<Select.Item value="warn">Warn</Select.Item>
|
||||
<Select.Item value="error">Error</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{canScopeToServer ? (
|
||||
<Tooltip
|
||||
content={
|
||||
scopedToServer
|
||||
? "Showing rows for the active server only. Click to show all."
|
||||
: "Showing all rows. Click to scope to the active server."
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="log-scope-toggle"
|
||||
data-scoped={scopedToServer ? "true" : "false"}
|
||||
aria-pressed={scopedToServer}
|
||||
onClick={() => onToggleScope(!scopedToServer)}
|
||||
>
|
||||
{scopedToServer ? "This server" : "All"}
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<div style={{ flex: 1 }} />
|
||||
<Tooltip content="Open logs folder">
|
||||
<button
|
||||
type="button"
|
||||
className="log-sidebar-icon-btn"
|
||||
aria-label="Open logs folder"
|
||||
onClick={() => void openLogsFolder().catch(() => undefined)}
|
||||
>
|
||||
<FilePlusIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Clear in-memory log">
|
||||
<button
|
||||
type="button"
|
||||
className="log-sidebar-icon-btn"
|
||||
aria-label="Clear logs"
|
||||
disabled={rows.length === 0}
|
||||
onClick={onClear}
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
<Box
|
||||
className="log-body"
|
||||
ref={bodyRef}
|
||||
onScroll={(event) => {
|
||||
const body = event.currentTarget;
|
||||
const distanceFromBottom = body.scrollHeight - body.scrollTop - body.clientHeight;
|
||||
stickToBottomRef.current = distanceFromBottom < 80;
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" gap="0">
|
||||
{rows.map((row) => (
|
||||
<Grid
|
||||
key={row.id}
|
||||
columns="68px 44px 1fr"
|
||||
gap="2"
|
||||
align="baseline"
|
||||
className={`log-line log-${row.level}`}
|
||||
>
|
||||
<Text className="log-meta mono">{row.timestamp}</Text>
|
||||
<Text className="log-meta log-level mono">{row.level}</Text>
|
||||
<Text className="log-text mono">{row.message}</Text>
|
||||
</Grid>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Flex,
|
||||
Select,
|
||||
Table,
|
||||
Text,
|
||||
TextArea,
|
||||
TextField,
|
||||
} from "@radix-ui/themes";
|
||||
|
||||
import { managementApi } from "../../services/management";
|
||||
import type {
|
||||
Category,
|
||||
CommandSpec,
|
||||
FieldSpec,
|
||||
HistoryDto,
|
||||
PublishResultDto,
|
||||
} from "../../types/management";
|
||||
import { formatTime } from "../../utils/formatting";
|
||||
import Combobox from "./Combobox";
|
||||
|
||||
export type AdminTabPrefill = {
|
||||
commandId: string;
|
||||
values: Record<string, unknown>;
|
||||
} | null;
|
||||
|
||||
export type AdminTabProps = {
|
||||
tunnelId: string;
|
||||
prefill?: AdminTabPrefill;
|
||||
onPrefillConsumed?: () => void;
|
||||
};
|
||||
|
||||
const CATEGORY_LABEL: Record<Category, string> = {
|
||||
items: "Inventory",
|
||||
player: "Player ops",
|
||||
progression: "Progression",
|
||||
movement: "Teleport & spawn",
|
||||
broadcast: "Broadcast",
|
||||
journey: "Story journey",
|
||||
exec: "Server scripts",
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: Category[] = [
|
||||
"broadcast",
|
||||
"items",
|
||||
"player",
|
||||
"progression",
|
||||
"movement",
|
||||
"journey",
|
||||
"exec",
|
||||
];
|
||||
|
||||
const CLIENT_DEFAULTS: Record<string, unknown> = {
|
||||
Quantity: 1,
|
||||
Durability: 1.0,
|
||||
WaterAmount: 1_000_000,
|
||||
Experience: 1000,
|
||||
Level: 1,
|
||||
SkillPoints: 0,
|
||||
BroadcastType: "Generic",
|
||||
BroadcastDuration: 30,
|
||||
Persistent: 1.0,
|
||||
};
|
||||
|
||||
function applyDefaults(spec: CommandSpec): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const field of spec.fields) {
|
||||
if (CLIENT_DEFAULTS[field.key] !== undefined) {
|
||||
out[field.key] = CLIENT_DEFAULTS[field.key];
|
||||
} else if (field.default !== undefined && field.default !== null) {
|
||||
out[field.key] = field.default;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function AdminTab({ tunnelId, prefill, onPrefillConsumed }: AdminTabProps) {
|
||||
const [commands, setCommands] = useState<CommandSpec[]>([]);
|
||||
const [selected, setSelected] = useState<CommandSpec | null>(null);
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [history, setHistory] = useState<HistoryDto[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<PublishResultDto | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const appliedRef = useRef<{ selectedId: string; prefillFp: string | null } | null>(null);
|
||||
// Templates available for the currently-picked vehicle (SpawnVehicleAt).
|
||||
// Populated whenever values.ClassName changes so TemplateName renders as a
|
||||
// proper combobox of valid options instead of a free-text field.
|
||||
const [vehicleTemplates, setVehicleTemplates] = useState<string[]>([]);
|
||||
|
||||
const refreshHistory = useCallback(async () => {
|
||||
try {
|
||||
const list = await managementApi.history(tunnelId, 30);
|
||||
setHistory(list);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}, [tunnelId]);
|
||||
|
||||
useEffect(() => {
|
||||
managementApi
|
||||
.listCommands(tunnelId)
|
||||
.then(setCommands)
|
||||
.catch((err) => setError(String(err)));
|
||||
void refreshHistory();
|
||||
}, [tunnelId, refreshHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset values + apply prefill exactly once per (selected, prefill) pair.
|
||||
// The earlier two-effect version raced; the single-effect version still
|
||||
// clobbered prefill on the next render after onPrefillConsumed cleared it
|
||||
// because the [prefill] dep change re-ran the defaults reset. Track what
|
||||
// we've already applied so post-consumption re-renders are a no-op.
|
||||
if (!selected) {
|
||||
appliedRef.current = null;
|
||||
return;
|
||||
}
|
||||
const prefillFp =
|
||||
prefill && prefill.commandId === selected.id ? JSON.stringify(prefill) : null;
|
||||
const current = appliedRef.current;
|
||||
|
||||
if (!current || current.selectedId !== selected.id) {
|
||||
// Brand new command pick (sidebar click or first prefill into a new command).
|
||||
if (prefillFp) {
|
||||
setValues({ ...applyDefaults(selected), ...(prefill?.values ?? {}) });
|
||||
onPrefillConsumed?.();
|
||||
} else {
|
||||
setValues(applyDefaults(selected));
|
||||
}
|
||||
setResult(null);
|
||||
appliedRef.current = { selectedId: selected.id, prefillFp };
|
||||
return;
|
||||
}
|
||||
|
||||
// Same command. Only act if a NEW prefill arrived for it.
|
||||
if (prefillFp && prefillFp !== current.prefillFp) {
|
||||
setValues((prev) => ({ ...prev, ...(prefill?.values ?? {}) }));
|
||||
setResult(null);
|
||||
onPrefillConsumed?.();
|
||||
appliedRef.current = { selectedId: selected.id, prefillFp };
|
||||
}
|
||||
// Otherwise the prefill was cleared after we consumed it — leave values alone.
|
||||
}, [selected, prefill, onPrefillConsumed]);
|
||||
|
||||
useEffect(() => {
|
||||
// If a prefill arrives for a command different from what's currently
|
||||
// selected, switch the sidebar to that command. The effect above will
|
||||
// then notice prefill.commandId === selected.id and apply the values.
|
||||
if (!prefill || commands.length === 0) return;
|
||||
if (selected?.id === prefill.commandId) return;
|
||||
const target = commands.find((c) => c.id === prefill.commandId);
|
||||
if (!target) return;
|
||||
setSelected(target);
|
||||
}, [prefill, commands, selected?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
// For SpawnVehicleAt, look up the templates of the picked vehicle so the
|
||||
// TemplateName field can render its real options.
|
||||
const cls =
|
||||
selected?.id === "SpawnVehicleAt" && typeof values.ClassName === "string"
|
||||
? (values.ClassName as string).trim()
|
||||
: "";
|
||||
if (!cls) {
|
||||
setVehicleTemplates([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const matches = await managementApi.searchVehicles(tunnelId, cls, 10);
|
||||
const hit = matches.find((v) => v.id === cls || v.actor_class === cls);
|
||||
const templates = hit?.templates ?? [];
|
||||
if (cancelled) return;
|
||||
setVehicleTemplates(templates);
|
||||
// If the current TemplateName isn't valid for this vehicle, auto-pick
|
||||
// the first available one. Keeps the form submittable without the user
|
||||
// having to know that TreadWheel doesn't carry a T0.
|
||||
if (templates.length > 0) {
|
||||
setValues((prev) => {
|
||||
const current = typeof prev.TemplateName === "string" ? prev.TemplateName : "";
|
||||
if (current && templates.includes(current)) return prev;
|
||||
return { ...prev, TemplateName: templates[0] };
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setVehicleTemplates([]);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selected?.id, values.ClassName, tunnelId]);
|
||||
|
||||
const grouped = useMemo(() => groupByCategory(commands), [commands]);
|
||||
|
||||
const doPublish = useCallback(async () => {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const out = await managementApi.publish(tunnelId, selected.id, values);
|
||||
setResult(out);
|
||||
await refreshHistory();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [selected, tunnelId, values, refreshHistory]);
|
||||
|
||||
const publish = useCallback(() => {
|
||||
if (!selected) return;
|
||||
if (selected.destructive) {
|
||||
setConfirmOpen(true);
|
||||
} else {
|
||||
void doPublish();
|
||||
}
|
||||
}, [selected, doPublish]);
|
||||
|
||||
return (
|
||||
<Flex mt="3" gap="3" align="stretch" wrap="wrap">
|
||||
<Box style={{ flex: "0 0 240px", minWidth: 0 }}>
|
||||
<Text size="2" weight="medium">
|
||||
Commands
|
||||
</Text>
|
||||
{CATEGORY_ORDER.map((cat) => {
|
||||
const specs = grouped[cat];
|
||||
if (!specs || specs.length === 0) return null;
|
||||
return (
|
||||
<Box key={cat} mt="2">
|
||||
<Text size="1" color="gray" style={{ textTransform: "uppercase", letterSpacing: 0.5 }}>
|
||||
{CATEGORY_LABEL[cat] ?? cat}
|
||||
</Text>
|
||||
<Flex direction="column" gap="1" mt="1">
|
||||
{specs.map((spec) => (
|
||||
<Button
|
||||
key={spec.id}
|
||||
size="1"
|
||||
variant={selected?.id === spec.id ? "solid" : "surface"}
|
||||
color={spec.destructive ? "red" : undefined}
|
||||
onClick={() => setSelected(spec)}
|
||||
style={{ justifyContent: "flex-start" }}
|
||||
>
|
||||
{spec.label}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box style={{ flex: "1 1 400px", minWidth: 0 }}>
|
||||
{selected ? (
|
||||
<Box>
|
||||
<Flex justify="between" align="baseline" wrap="wrap" gap="2">
|
||||
<Text size="3" weight="medium">
|
||||
{selected.label}
|
||||
</Text>
|
||||
{selected.destructive ? <Badge color="red">destructive</Badge> : null}
|
||||
</Flex>
|
||||
<Text size="1" color="gray">
|
||||
{selected.describe}
|
||||
</Text>
|
||||
<Flex direction="column" gap="3" mt="3">
|
||||
{visibleFields(selected, values).map((field) => (
|
||||
<FieldInput
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={values[field.key]}
|
||||
onChange={(v) => setValues((prev) => ({ ...prev, [field.key]: v }))}
|
||||
tunnelId={tunnelId}
|
||||
vehicleTemplates={vehicleTemplates}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
{selected.id === "SpawnVehicleAt" ? (
|
||||
<UsePlayerPositionButton
|
||||
tunnelId={tunnelId}
|
||||
playerId={values.PlayerId as string | undefined}
|
||||
onLocation={(loc) =>
|
||||
setValues((prev) => ({ ...prev, X: loc.x, Y: loc.y, Z: loc.z }))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Flex mt="3" gap="2" align="center">
|
||||
<Button onClick={publish} disabled={busy} color={selected.destructive ? "red" : undefined}>
|
||||
{busy ? "Publishing…" : selected.destructive ? "Publish (destructive)" : "Publish"}
|
||||
</Button>
|
||||
{result ? (
|
||||
<Badge color={result.ok ? "green" : "red"}>{result.ok ? "ok" : "failed"}</Badge>
|
||||
) : null}
|
||||
</Flex>
|
||||
{result && !result.ok && result.error ? (
|
||||
<Text size="1" color="red" mt="2">
|
||||
{result.error}
|
||||
</Text>
|
||||
) : null}
|
||||
{result?.output ? (
|
||||
<Box
|
||||
mt="2"
|
||||
className="mono"
|
||||
style={{ fontSize: 11, padding: 6, background: "var(--color-panel-translucent)", whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{result.output}
|
||||
</Box>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Text size="1" color="red" mt="2">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
) : (
|
||||
<Text color="gray">Select a command on the left.</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box style={{ flex: "1 1 320px", minWidth: 0 }}>
|
||||
<Text size="2" weight="medium">
|
||||
Recent publishes
|
||||
</Text>
|
||||
<Table.Root variant="surface" size="1" mt="1">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeaderCell>Cmd</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>OK</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>When</Table.ColumnHeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{history.map((h) => (
|
||||
<Table.Row key={h.id}>
|
||||
<Table.Cell className="mono" style={{ fontSize: 11 }}>
|
||||
{h.command}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge color={h.ok ? "green" : "red"}>{h.ok ? "ok" : "fail"}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="mono" style={{ fontSize: 11 }}>
|
||||
{formatTime(h.createdAt)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Box>
|
||||
|
||||
<AlertDialog.Root open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialog.Content maxWidth="460px">
|
||||
<AlertDialog.Title>Run {selected?.label}?</AlertDialog.Title>
|
||||
<AlertDialog.Description size="2">
|
||||
This command is destructive and cannot be undone. {selected?.describe}
|
||||
</AlertDialog.Description>
|
||||
<Flex gap="2" mt="4" justify="end">
|
||||
<AlertDialog.Cancel>
|
||||
<Button variant="soft" color="gray">
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialog.Cancel>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setConfirmOpen(false);
|
||||
void doPublish();
|
||||
}}
|
||||
>
|
||||
Run it
|
||||
</Button>
|
||||
</Flex>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function groupByCategory(specs: CommandSpec[]): Record<string, CommandSpec[]> {
|
||||
const out: Record<string, CommandSpec[]> = {};
|
||||
for (const spec of specs) {
|
||||
if (!out[spec.category]) out[spec.category] = [];
|
||||
out[spec.category].push(spec);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function compareText(a: string | undefined | null, b: string | undefined | null): number {
|
||||
return (a || "").localeCompare(b || "", undefined, { sensitivity: "base", numeric: true });
|
||||
}
|
||||
|
||||
function comparePlayers(a: any, b: any): number {
|
||||
const aOnline = String(a.online || "").toLowerCase() === "online";
|
||||
const bOnline = String(b.online || "").toLowerCase() === "online";
|
||||
if (aOnline !== bOnline) return aOnline ? -1 : 1;
|
||||
return compareText(a.name || a.flsId, b.name || b.flsId);
|
||||
}
|
||||
|
||||
function sortCommandOptions(kind: ComboboxKind, options: any[]): any[] {
|
||||
const rows = [...options];
|
||||
if (kind === "players") return rows.sort(comparePlayers);
|
||||
if (kind === "vehicles") return rows.sort((a, b) => compareText(a.id, b.id));
|
||||
return rows.sort((a, b) => compareText(a.name || a.id, b.name || b.id));
|
||||
}
|
||||
|
||||
function UsePlayerPositionButton({
|
||||
tunnelId,
|
||||
playerId,
|
||||
onLocation,
|
||||
}: {
|
||||
tunnelId: string;
|
||||
playerId: string | undefined;
|
||||
onLocation: (loc: { x: number; y: number; z: number }) => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const enabled = !!playerId && !busy;
|
||||
|
||||
const click = useCallback(async () => {
|
||||
if (!playerId) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const loc = await managementApi.playerLocation(tunnelId, playerId);
|
||||
onLocation(loc);
|
||||
} catch (err) {
|
||||
// Backend wraps proxy errors as `GET /path -> STATUS: {"error":"…"}`.
|
||||
// Pull out the inner `error` field for a readable message; fall back to raw.
|
||||
const raw = String(err);
|
||||
let nice = raw;
|
||||
const bodyStart = raw.indexOf("{");
|
||||
if (bodyStart >= 0) {
|
||||
try {
|
||||
const obj = JSON.parse(raw.slice(bodyStart));
|
||||
if (obj && typeof obj.error === "string") nice = obj.error;
|
||||
} catch {
|
||||
// leave nice as raw
|
||||
}
|
||||
}
|
||||
setError(nice);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [tunnelId, playerId, onLocation]);
|
||||
|
||||
return (
|
||||
<Box mt="2">
|
||||
<Button size="1" variant="soft" disabled={!enabled} onClick={click}>
|
||||
{busy ? "Fetching…" : "Use player's current position"}
|
||||
</Button>
|
||||
{!playerId ? (
|
||||
<Text size="1" color="gray" ml="2">
|
||||
(pick a player first)
|
||||
</Text>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Text size="1" color="red" as="div" mt="1">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type ComboboxKind = "items" | "vehicles" | "players" | "skill-modules";
|
||||
|
||||
function comboboxKindFor(fieldKey: string): ComboboxKind | null {
|
||||
switch (fieldKey) {
|
||||
case "ItemName":
|
||||
return "items";
|
||||
case "ClassName":
|
||||
return "vehicles";
|
||||
case "PlayerId":
|
||||
return "players";
|
||||
case "Module":
|
||||
return "skill-modules";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function FieldInput({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
tunnelId,
|
||||
vehicleTemplates,
|
||||
}: {
|
||||
field: FieldSpec;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
tunnelId: string;
|
||||
vehicleTemplates: string[];
|
||||
}) {
|
||||
const comboKind = comboboxKindFor(field.key);
|
||||
const templateMode = field.key === "TemplateName" && vehicleTemplates.length > 0;
|
||||
return (
|
||||
<Box>
|
||||
<Flex justify="between" align="baseline" gap="2">
|
||||
<Text size="2" weight="medium">
|
||||
{field.label}
|
||||
{field.required ? " *" : ""}
|
||||
</Text>
|
||||
{field.helper ? (
|
||||
<Text size="1" color="gray">
|
||||
{field.helper}
|
||||
</Text>
|
||||
) : null}
|
||||
</Flex>
|
||||
<Box mt="1">
|
||||
{templateMode ? (
|
||||
<TemplateCombobox
|
||||
value={typeof value === "string" ? value : value == null ? "" : String(value)}
|
||||
onPick={onChange}
|
||||
templates={vehicleTemplates}
|
||||
/>
|
||||
) : comboKind ? (
|
||||
<CommandCombobox kind={comboKind} value={value} onPick={onChange} tunnelId={tunnelId} />
|
||||
) : (
|
||||
renderInput(field, value, onChange)
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateCombobox({
|
||||
value,
|
||||
onPick,
|
||||
templates,
|
||||
}: {
|
||||
value: string;
|
||||
onPick: (v: unknown) => void;
|
||||
templates: string[];
|
||||
}) {
|
||||
const loadOptions = useCallback(
|
||||
async (query: string) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? templates.filter((t) => t.toLowerCase().includes(q))
|
||||
: templates;
|
||||
return [...filtered].sort(compareText).map((name) => ({ name }));
|
||||
},
|
||||
[templates],
|
||||
);
|
||||
return (
|
||||
<Combobox
|
||||
value={value}
|
||||
onChange={onPick}
|
||||
loadOptions={loadOptions}
|
||||
getOptionValue={(o: { name: string }) => o.name}
|
||||
resolveLabel={async (id) => id}
|
||||
renderOption={(o: { name: string }) => (
|
||||
<Text size="2" className="mono">{o.name}</Text>
|
||||
)}
|
||||
placeholder="Pick a template…"
|
||||
searchPlaceholder="Filter templates…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/// Filters the spec's field list down to what's relevant for the current
|
||||
/// values. Today only ServiceBroadcast has conditional fields — Generic
|
||||
/// hides the shutdown-specific knobs, ServerShutdown hides Generic-only
|
||||
/// fields, and a `ShouldCancel=true` hides everything except the cancel
|
||||
/// toggle itself.
|
||||
function visibleFields(
|
||||
spec: CommandSpec,
|
||||
values: Record<string, unknown>,
|
||||
): FieldSpec[] {
|
||||
if (spec.id !== "ServiceBroadcast") return [...spec.fields];
|
||||
const broadcastType = (values.BroadcastType as string) || "Generic";
|
||||
const shouldCancel = values.ShouldCancel === true;
|
||||
const GENERIC_ONLY = new Set(["Title", "Body"]);
|
||||
const SHUTDOWN_ONLY = new Set([
|
||||
"ShutdownType",
|
||||
"ShutdownDuration",
|
||||
"BroadcastFrequency",
|
||||
"ShouldCancel",
|
||||
]);
|
||||
return spec.fields.filter((field) => {
|
||||
if (field.key === "BroadcastType") return true;
|
||||
if (broadcastType === "Generic") {
|
||||
if (SHUTDOWN_ONLY.has(field.key)) return false;
|
||||
return true;
|
||||
}
|
||||
// ServerShutdown branch
|
||||
if (GENERIC_ONLY.has(field.key)) return false;
|
||||
if (shouldCancel && field.key !== "ShouldCancel") return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function renderInput(field: FieldSpec, value: unknown, onChange: (v: unknown) => void) {
|
||||
const strValue = value === undefined || value === null ? "" : String(value);
|
||||
if (field.kind === "select" && field.options) {
|
||||
return (
|
||||
<Select.Root value={strValue || field.options[0].value} onValueChange={onChange}>
|
||||
<Select.Trigger />
|
||||
<Select.Content>
|
||||
{field.options.map((opt) => (
|
||||
<Select.Item key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
);
|
||||
}
|
||||
if (field.kind === "text") {
|
||||
return <TextArea value={strValue} onChange={(e) => onChange(e.target.value)} rows={3} />;
|
||||
}
|
||||
if (field.kind === "bool") {
|
||||
const checked = value === true || strValue === "true" || strValue === "1";
|
||||
return (
|
||||
<Checkbox checked={checked} onCheckedChange={(c) => onChange(Boolean(c))} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TextField.Root
|
||||
value={strValue}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (field.kind === "int" || field.kind === "float") {
|
||||
onChange(raw === "" ? "" : Number(raw));
|
||||
} else {
|
||||
onChange(raw);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandCombobox({
|
||||
kind,
|
||||
value,
|
||||
onPick,
|
||||
tunnelId,
|
||||
}: {
|
||||
kind: ComboboxKind;
|
||||
value: unknown;
|
||||
onPick: (v: unknown) => void;
|
||||
tunnelId: string;
|
||||
}) {
|
||||
const strVal = typeof value === "string" ? value : value == null ? "" : String(value);
|
||||
|
||||
const loadOptions = useCallback(
|
||||
async (query: string) => {
|
||||
try {
|
||||
if (kind === "items") {
|
||||
return sortCommandOptions(kind, await managementApi.searchItems(tunnelId, query, 30));
|
||||
}
|
||||
if (kind === "vehicles") {
|
||||
return sortCommandOptions(kind, await managementApi.searchVehicles(tunnelId, query, 30));
|
||||
}
|
||||
if (kind === "skill-modules") {
|
||||
return sortCommandOptions(kind, await managementApi.searchSkillModules(tunnelId, query, 50));
|
||||
}
|
||||
return sortCommandOptions(kind, await managementApi.searchPlayers(tunnelId, query, 30));
|
||||
} catch {
|
||||
return [] as never[];
|
||||
}
|
||||
},
|
||||
[kind, tunnelId],
|
||||
);
|
||||
|
||||
const resolveLabel = useCallback(
|
||||
async (id: string): Promise<string | null> => {
|
||||
if (!id) return null;
|
||||
try {
|
||||
if (kind === "items") {
|
||||
const r = await managementApi.searchItems(tunnelId, id, 5);
|
||||
const hit = r.find((it) => it.id === id);
|
||||
return hit ? `${hit.name} · ${hit.id}` : id;
|
||||
}
|
||||
if (kind === "players") {
|
||||
const r = await managementApi.searchPlayers(tunnelId, id, 5);
|
||||
const hit = r.find((p) => p.flsId === id);
|
||||
return hit ? `${hit.name} (${hit.online}) · ${hit.flsId}` : id;
|
||||
}
|
||||
if (kind === "skill-modules") {
|
||||
const r = await managementApi.searchSkillModules(tunnelId, id, 5);
|
||||
const hit = r.find((m) => m.id === id);
|
||||
return hit ? `${hit.name} · ${hit.id}` : id;
|
||||
}
|
||||
const r = await managementApi.searchVehicles(tunnelId, id, 5);
|
||||
const hit = r.find((v) => v.id === id || v.actor_class === id);
|
||||
if (!hit) return id;
|
||||
const templates = Array.isArray(hit.templates) && hit.templates.length > 0
|
||||
? ` · templates: ${hit.templates.join(", ")}`
|
||||
: "";
|
||||
return `${hit.id}${templates}`;
|
||||
} catch {
|
||||
return id;
|
||||
}
|
||||
},
|
||||
[kind, tunnelId],
|
||||
);
|
||||
|
||||
if (kind === "items") {
|
||||
return (
|
||||
<Combobox
|
||||
value={strVal}
|
||||
onChange={onPick}
|
||||
loadOptions={loadOptions}
|
||||
getOptionValue={(it: any) => it.id}
|
||||
resolveLabel={resolveLabel}
|
||||
renderOption={(it: any) => (
|
||||
<Flex justify="between" gap="2">
|
||||
<Text size="2">{it.name}</Text>
|
||||
<Text size="1" color="gray" className="mono">{it.id}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
placeholder="Pick an item…"
|
||||
searchPlaceholder="Search items…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (kind === "vehicles") {
|
||||
return (
|
||||
<Combobox
|
||||
value={strVal}
|
||||
onChange={onPick}
|
||||
loadOptions={loadOptions}
|
||||
// Server expects the DT_VehicleTemplates row key (e.g. "Sandbike"),
|
||||
// not the full BP actor class path.
|
||||
getOptionValue={(v: any) => v.id}
|
||||
resolveLabel={resolveLabel}
|
||||
renderOption={(v: any) => (
|
||||
<Flex direction="column">
|
||||
<Text size="2">{v.id}</Text>
|
||||
<Text size="1" color="gray">
|
||||
templates: {Array.isArray(v.templates) && v.templates.length > 0 ? v.templates.join(", ") : "—"}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
placeholder="Pick a vehicle…"
|
||||
searchPlaceholder="Search vehicles…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (kind === "skill-modules") {
|
||||
return (
|
||||
<Combobox
|
||||
value={strVal}
|
||||
onChange={onPick}
|
||||
loadOptions={loadOptions}
|
||||
getOptionValue={(m: any) => m.id}
|
||||
resolveLabel={resolveLabel}
|
||||
renderOption={(m: any) => (
|
||||
<Flex justify="between" gap="2">
|
||||
<Box>
|
||||
<Text size="2">{m.name}</Text>
|
||||
<Text size="1" color="gray" as="div">
|
||||
{m.category} · max {m.maxLevel}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text size="1" color="gray" className="mono">{m.id}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
placeholder="Pick a skill module…"
|
||||
searchPlaceholder="Search skill modules…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Combobox
|
||||
value={strVal}
|
||||
onChange={onPick}
|
||||
loadOptions={loadOptions}
|
||||
getOptionValue={(p: any) => p.flsId}
|
||||
resolveLabel={resolveLabel}
|
||||
renderOption={(p: any) => (
|
||||
<Flex justify="between" gap="2" align="center">
|
||||
<Box>
|
||||
<Text size="2">{p.name || "(unnamed)"}</Text>
|
||||
<Text size="1" color="gray" as="div" className="mono">{p.flsId}</Text>
|
||||
</Box>
|
||||
<Badge color={p.online === "online" ? "green" : "gray"}>{p.online || "offline"}</Badge>
|
||||
</Flex>
|
||||
)}
|
||||
placeholder="Pick a player…"
|
||||
searchPlaceholder="Search players…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Callout,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
Flex,
|
||||
Link,
|
||||
Separator,
|
||||
Text,
|
||||
TextArea,
|
||||
TextField,
|
||||
} from "@radix-ui/themes";
|
||||
|
||||
import { managementApi, managementService } from "../../services/management";
|
||||
import { openExternal } from "../../services/tauri";
|
||||
import type { RemoteServerRecord } from "../../types/server";
|
||||
import type {
|
||||
LogDto,
|
||||
RestartNoticeOptions,
|
||||
RunDto,
|
||||
ScheduleConfig,
|
||||
} from "../../types/management";
|
||||
import { formatDateTime, formatTime } from "../../utils/formatting";
|
||||
import Combobox from "./Combobox";
|
||||
import DumpPruneDialog from "./DumpPruneDialog";
|
||||
|
||||
const DIRECT_TASKS: Array<{ id: string; label: string }> = [
|
||||
{ id: "backup", label: "Backup" },
|
||||
{ id: "welcome-package", label: "Welcome package scan" },
|
||||
{ id: "update-check", label: "Check for server update" },
|
||||
{ id: "update-apply", label: "Apply server update" },
|
||||
{ id: "restart", label: "Restart server" },
|
||||
];
|
||||
|
||||
export type AutomatedTasksTabProps = {
|
||||
tunnelId: string;
|
||||
server: RemoteServerRecord;
|
||||
onAfterRestart?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
type LogState = {
|
||||
status: "idle" | "loading" | "ready" | "error";
|
||||
logs: LogDto[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export default function AutomatedTasksTab({
|
||||
tunnelId,
|
||||
server,
|
||||
onAfterRestart,
|
||||
}: AutomatedTasksTabProps) {
|
||||
const [runs, setRuns] = useState<RunDto[]>([]);
|
||||
const [busyTrigger, setBusyTrigger] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [logsByRun, setLogsByRun] = useState<Record<number, LogState>>({});
|
||||
const [noticeOpen, setNoticeOpen] = useState(false);
|
||||
const [dumpPruneOpen, setDumpPruneOpen] = useState(false);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const r = await managementApi.listRuns(tunnelId, 50);
|
||||
setRuns(r);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}, [tunnelId]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
const handle = setInterval(reload, 5000);
|
||||
return () => clearInterval(handle);
|
||||
}, [reload]);
|
||||
|
||||
const fetchLogs = useCallback(
|
||||
async (runId: number) => {
|
||||
setLogsByRun((prev) => ({
|
||||
...prev,
|
||||
[runId]: { status: "loading", logs: prev[runId]?.logs ?? [] },
|
||||
}));
|
||||
try {
|
||||
const l = await managementApi.listLogs(tunnelId, 500, runId);
|
||||
setLogsByRun((prev) => ({ ...prev, [runId]: { status: "ready", logs: l } }));
|
||||
} catch (err) {
|
||||
setLogsByRun((prev) => ({
|
||||
...prev,
|
||||
[runId]: { status: "error", logs: prev[runId]?.logs ?? [], error: String(err) },
|
||||
}));
|
||||
}
|
||||
},
|
||||
[tunnelId],
|
||||
);
|
||||
|
||||
const trigger = useCallback(
|
||||
async (task: string, options?: Record<string, unknown>) => {
|
||||
setBusyTrigger(task);
|
||||
try {
|
||||
await managementApi.triggerRun(tunnelId, task, options);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
alert(`Trigger ${task} failed: ${err}`);
|
||||
} finally {
|
||||
setBusyTrigger(null);
|
||||
}
|
||||
},
|
||||
[reload, tunnelId],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box mt="3">
|
||||
<ScheduleSettings tunnelId={tunnelId} server={server} onAfterRestart={onAfterRestart} />
|
||||
|
||||
<Box mt="4">
|
||||
<Flex justify="between" align="start" gap="3" wrap="wrap">
|
||||
<Text size="2" color="gray">
|
||||
Run the scheduled maintenance tasks manually. Each run records its own log entries below.
|
||||
</Text>
|
||||
<Button size="1" variant="surface" onClick={reload}>
|
||||
Refresh
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex gap="2" wrap="wrap" mt="2" mb="3">
|
||||
{DIRECT_TASKS.map((t) => (
|
||||
<Button
|
||||
key={t.id}
|
||||
size="1"
|
||||
variant="surface"
|
||||
disabled={busyTrigger === t.id}
|
||||
onClick={() => trigger(t.id)}
|
||||
>
|
||||
{busyTrigger === t.id ? `Running ${t.label}…` : t.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
size="1"
|
||||
variant="surface"
|
||||
disabled={busyTrigger === "restart-notice"}
|
||||
onClick={() => setNoticeOpen(true)}
|
||||
>
|
||||
{busyTrigger === "restart-notice"
|
||||
? "Sending restart notice…"
|
||||
: "Send restart notice…"}
|
||||
</Button>
|
||||
<Button
|
||||
size="1"
|
||||
variant="surface"
|
||||
color="red"
|
||||
onClick={() => setDumpPruneOpen(true)}
|
||||
>
|
||||
Clean up database operations…
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{error ? (
|
||||
<Text size="1" color="red">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Box>
|
||||
<Text size="2" weight="medium" mb="2">
|
||||
Recent runs
|
||||
</Text>
|
||||
<Flex direction="column" gap="2" mt="2">
|
||||
{runs.length === 0 ? <Text color="gray">No runs yet.</Text> : null}
|
||||
{runs.map((run) => (
|
||||
<RunRow
|
||||
key={run.id}
|
||||
run={run}
|
||||
logsState={logsByRun[run.id]}
|
||||
onExpand={() => {
|
||||
if (!logsByRun[run.id]) void fetchLogs(run.id);
|
||||
}}
|
||||
onRefreshLogs={() => void fetchLogs(run.id)}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<RestartNoticeDialog
|
||||
open={noticeOpen}
|
||||
onOpenChange={setNoticeOpen}
|
||||
onSubmit={async (options) => {
|
||||
setNoticeOpen(false);
|
||||
await trigger("restart-notice", options as Record<string, unknown>);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DumpPruneDialog
|
||||
open={dumpPruneOpen}
|
||||
onOpenChange={setDumpPruneOpen}
|
||||
tunnelId={tunnelId}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function RestartNoticeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (options: RestartNoticeOptions) => Promise<void>;
|
||||
}) {
|
||||
const [leadSecs, setLeadSecs] = useState(1800);
|
||||
const [frequencySecs, setFrequencySecs] = useState(600);
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Content maxWidth="480px">
|
||||
<Dialog.Title>Send restart notice</Dialog.Title>
|
||||
<Dialog.Description size="2" color="gray" mb="3">
|
||||
Publishes a ServerShutdown countdown to the game. If you provide a
|
||||
title + body, an additional Generic broadcast carries them as a banner.
|
||||
</Dialog.Description>
|
||||
<Flex direction="column" gap="3">
|
||||
<Box>
|
||||
<Text size="2" weight="medium">Lead time (seconds)</Text>
|
||||
<TextField.Root
|
||||
type="number"
|
||||
value={String(leadSecs)}
|
||||
onChange={(e) => setLeadSecs(Number(e.target.value) || 0)}
|
||||
/>
|
||||
<Text size="1" color="gray">
|
||||
How long until the restart fires. 1800 = 30 min.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="2" weight="medium">Warning frequency (seconds)</Text>
|
||||
<TextField.Root
|
||||
type="number"
|
||||
value={String(frequencySecs)}
|
||||
onChange={(e) => setFrequencySecs(Number(e.target.value) || 0)}
|
||||
/>
|
||||
<Text size="1" color="gray">
|
||||
How often the game re-shows the countdown. 600 = every 10 min.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="2" weight="medium">Custom title (optional)</Text>
|
||||
<TextField.Root
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Scheduled maintenance"
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="2" weight="medium">Custom body (optional)</Text>
|
||||
<TextArea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Sent as an in-game banner alongside the countdown."
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Flex gap="2" mt="4" justify="end">
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray">Cancel</Button>
|
||||
</Dialog.Close>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const opts: RestartNoticeOptions = {
|
||||
leadSecs,
|
||||
frequencySecs,
|
||||
durationSecs: leadSecs,
|
||||
};
|
||||
if (title.trim()) opts.title = title.trim();
|
||||
if (body.trim()) opts.body = body.trim();
|
||||
void onSubmit(opts);
|
||||
}}
|
||||
>
|
||||
Send notice
|
||||
</Button>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleSettings({
|
||||
tunnelId,
|
||||
server,
|
||||
onAfterRestart,
|
||||
}: {
|
||||
tunnelId: string;
|
||||
server: RemoteServerRecord;
|
||||
onAfterRestart?: () => Promise<void> | void;
|
||||
}) {
|
||||
const [config, setConfig] = useState<ScheduleConfig | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [busyLabel, setBusyLabel] = useState("Saving…");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Editable form fields, mirroring ScheduleConfig. Reset from `config`
|
||||
// every time we enter edit mode so Cancel reverts cleanly.
|
||||
const [hour, setHour] = useState(5);
|
||||
const [minute, setMinute] = useState(0);
|
||||
const [warnFreq, setWarnFreq] = useState(600);
|
||||
const [warnDur, setWarnDur] = useState(1800);
|
||||
const [updateLead, setUpdateLead] = useState(1800);
|
||||
const [tz, setTz] = useState("UTC");
|
||||
// Master switches. Undefined from older services reads as enabled.
|
||||
const [restartEnabled, setRestartEnabled] = useState(true);
|
||||
const [updateEnabled, setUpdateEnabled] = useState(true);
|
||||
const [backupEnabled, setBackupEnabled] = useState(true);
|
||||
// 5-field cron (min hour dom mon dow); empty string = disabled.
|
||||
const [backupCron, setBackupCron] = useState("");
|
||||
const [backupCronStatus, setBackupCronStatus] = useState<
|
||||
| { state: "idle" }
|
||||
| { state: "validating" }
|
||||
| { state: "ok"; tz: string; next: string[] }
|
||||
| { state: "error"; message: string }
|
||||
>({ state: "idle" });
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const c = await managementApi.getConfig(tunnelId);
|
||||
setConfig(c);
|
||||
setHour(c.restartHour);
|
||||
setMinute(c.restartMinute);
|
||||
setWarnFreq(c.restartWarningFrequencySecs);
|
||||
setWarnDur(c.restartWarningDurationSecs);
|
||||
setUpdateLead(c.updateLeadSecs);
|
||||
setTz(c.restartTz);
|
||||
setRestartEnabled(c.restartEnabled ?? true);
|
||||
setUpdateEnabled(c.updateEnabled ?? true);
|
||||
setBackupEnabled(c.backupEnabled ?? true);
|
||||
setBackupCron(c.backupCron ?? "");
|
||||
setBackupCronStatus({ state: "idle" });
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}, [tunnelId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const startEdit = useCallback(() => {
|
||||
if (!config) return;
|
||||
setHour(config.restartHour);
|
||||
setMinute(config.restartMinute);
|
||||
setWarnFreq(config.restartWarningFrequencySecs);
|
||||
setWarnDur(config.restartWarningDurationSecs);
|
||||
setUpdateLead(config.updateLeadSecs);
|
||||
setTz(config.restartTz);
|
||||
setRestartEnabled(config.restartEnabled ?? true);
|
||||
setUpdateEnabled(config.updateEnabled ?? true);
|
||||
setBackupEnabled(config.backupEnabled ?? true);
|
||||
setBackupCron(config.backupCron ?? "");
|
||||
setBackupCronStatus({ state: "idle" });
|
||||
setEditing(true);
|
||||
setError(null);
|
||||
}, [config]);
|
||||
|
||||
// Live-validate the cron expression while editing. Empty = disabled (no
|
||||
// server round-trip). The service caps `count` at 20 and returns a parse
|
||||
// error string when invalid; we surface either the next-fire preview or
|
||||
// the error inline.
|
||||
useEffect(() => {
|
||||
if (!editing) return;
|
||||
const trimmed = backupCron.trim();
|
||||
if (!trimmed) {
|
||||
setBackupCronStatus({ state: "idle" });
|
||||
return;
|
||||
}
|
||||
setBackupCronStatus({ state: "validating" });
|
||||
const handle = setTimeout(async () => {
|
||||
try {
|
||||
const result = await managementApi.cronPreview(tunnelId, trimmed, 5);
|
||||
if (result.ok) {
|
||||
setBackupCronStatus({ state: "ok", tz: result.tz, next: result.next });
|
||||
} else {
|
||||
setBackupCronStatus({ state: "error", message: result.error });
|
||||
}
|
||||
} catch (err) {
|
||||
setBackupCronStatus({ state: "error", message: String(err) });
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(handle);
|
||||
}, [backupCron, editing, tunnelId]);
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setBusyLabel("Saving…");
|
||||
if (backupEnabled && !backupCron.trim()) {
|
||||
throw new Error("A cron expression is required while auto backup is enabled.");
|
||||
}
|
||||
if (backupCron.trim() && backupCronStatus.state === "error") {
|
||||
throw new Error(`Cron expression invalid: ${backupCronStatus.message}`);
|
||||
}
|
||||
await managementApi.setConfig(tunnelId, {
|
||||
restartHour: hour,
|
||||
restartMinute: minute,
|
||||
restartWarningFrequencySecs: warnFreq,
|
||||
restartWarningDurationSecs: warnDur,
|
||||
updateLeadSecs: updateLead,
|
||||
restartTz: tz,
|
||||
restartEnabled,
|
||||
updateEnabled,
|
||||
backupEnabled,
|
||||
backupCron: backupCron.trim(),
|
||||
});
|
||||
|
||||
setBusyLabel("Restarting service…");
|
||||
await managementService.restart({
|
||||
host: server.host,
|
||||
user: server.user,
|
||||
keyPath: server.keyPath,
|
||||
port: server.port,
|
||||
});
|
||||
|
||||
// Poll until the API is back up. Local SSH tunnel survives the restart;
|
||||
// the remote axum listener just takes ~1s to rebind.
|
||||
const deadline = Date.now() + 15_000;
|
||||
let lastErr: unknown = null;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
try {
|
||||
await managementApi.getConfig(tunnelId);
|
||||
lastErr = null;
|
||||
break;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
if (lastErr) {
|
||||
throw new Error(`service did not come back up: ${lastErr}`);
|
||||
}
|
||||
|
||||
await refresh();
|
||||
await onAfterRestart?.();
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setBusyLabel("Saving…");
|
||||
}
|
||||
}, [
|
||||
tunnelId,
|
||||
hour,
|
||||
minute,
|
||||
warnFreq,
|
||||
warnDur,
|
||||
updateLead,
|
||||
tz,
|
||||
restartEnabled,
|
||||
updateEnabled,
|
||||
backupEnabled,
|
||||
backupCron,
|
||||
backupCronStatus,
|
||||
refresh,
|
||||
server.host,
|
||||
server.user,
|
||||
server.keyPath,
|
||||
server.port,
|
||||
onAfterRestart,
|
||||
]);
|
||||
|
||||
const loadTimezones = useCallback(
|
||||
async (query: string) => {
|
||||
try {
|
||||
const all = await managementApi.listTimezones(tunnelId);
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? all.filter((tz) => tz.toLowerCase().includes(q))
|
||||
: all;
|
||||
return [...filtered]
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }))
|
||||
.slice(0, 200)
|
||||
.map((name) => ({ name }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[tunnelId],
|
||||
);
|
||||
|
||||
const restartRequired = config?.restartRequired ?? false;
|
||||
const displayHour = config ? pad2(config.restartHour) : "—";
|
||||
const displayMinute = config ? pad2(config.restartMinute) : "—";
|
||||
|
||||
return (
|
||||
<Box className="schedule-section">
|
||||
<Flex justify="between" align="baseline" mb="2">
|
||||
<Text size="3" weight="medium">Schedule settings</Text>
|
||||
{!editing && config ? (
|
||||
<Button size="1" variant="surface" onClick={startEdit}>
|
||||
Configure
|
||||
</Button>
|
||||
) : null}
|
||||
</Flex>
|
||||
<Text size="1" color="gray">
|
||||
Stored in the service's sqlite. Saving restarts the service automatically so changes take effect immediately.
|
||||
</Text>
|
||||
|
||||
<Separator size="4" my="3" />
|
||||
|
||||
{editing ? (
|
||||
<Box className="schedule-grid">
|
||||
<Text size="2">Auto restart</Text>
|
||||
<Flex align="center" gap="2">
|
||||
<Checkbox
|
||||
checked={restartEnabled}
|
||||
onCheckedChange={(checked) => setRestartEnabled(Boolean(checked))}
|
||||
/>
|
||||
<Text size="2" color="gray">
|
||||
Run the daily restart and its warning broadcast
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Text size="2">Daily restart (HH:MM)</Text>
|
||||
<Flex gap="2" align="center">
|
||||
<TextField.Root
|
||||
inputMode="numeric"
|
||||
value={pad2(hour)}
|
||||
onChange={(e) => setHour(clampInt(e.target.value, 23))}
|
||||
onFocus={(e) => e.target.select()}
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
<Text>:</Text>
|
||||
<TextField.Root
|
||||
inputMode="numeric"
|
||||
value={pad2(minute)}
|
||||
onChange={(e) => setMinute(clampInt(e.target.value, 59))}
|
||||
onFocus={(e) => e.target.select()}
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Text size="2">Timezone</Text>
|
||||
<Combobox
|
||||
value={tz}
|
||||
onChange={(v) => setTz(v)}
|
||||
loadOptions={loadTimezones}
|
||||
getOptionValue={(o: { name: string }) => o.name}
|
||||
resolveLabel={async (id) => id}
|
||||
renderOption={(o: { name: string }) => (
|
||||
<Text size="2" className="mono">{o.name}</Text>
|
||||
)}
|
||||
placeholder="Pick a timezone…"
|
||||
searchPlaceholder="Search IANA timezones…"
|
||||
/>
|
||||
|
||||
<Text size="2">Warning lead (seconds)</Text>
|
||||
<TextField.Root
|
||||
type="number"
|
||||
value={String(warnDur)}
|
||||
onChange={(e) => setWarnDur(Number(e.target.value) || 0)}
|
||||
/>
|
||||
|
||||
<Text size="2">Warning frequency (seconds)</Text>
|
||||
<TextField.Root
|
||||
type="number"
|
||||
value={String(warnFreq)}
|
||||
onChange={(e) => setWarnFreq(Number(e.target.value) || 0)}
|
||||
/>
|
||||
|
||||
<Text size="2">Auto update</Text>
|
||||
<Flex align="center" gap="2">
|
||||
<Checkbox
|
||||
checked={updateEnabled}
|
||||
onCheckedChange={(checked) => setUpdateEnabled(Boolean(checked))}
|
||||
/>
|
||||
<Text size="2" color="gray">
|
||||
Check Steam for new builds and apply them automatically
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Text size="2">Update apply lead (seconds)</Text>
|
||||
<TextField.Root
|
||||
type="number"
|
||||
value={String(updateLead)}
|
||||
onChange={(e) => setUpdateLead(Number(e.target.value) || 0)}
|
||||
/>
|
||||
|
||||
<Text size="2">Auto backup</Text>
|
||||
<Flex align="center" gap="2">
|
||||
<Checkbox
|
||||
checked={backupEnabled}
|
||||
onCheckedChange={(checked) => setBackupEnabled(Boolean(checked))}
|
||||
/>
|
||||
<Text size="2" color="gray">
|
||||
Run scheduled backups (also requires a cron below)
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Text size="2">
|
||||
Backup cron (5-field){" "}
|
||||
<Link
|
||||
size="1"
|
||||
href="https://crontab.guru/"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openExternal("https://crontab.guru/");
|
||||
}}
|
||||
>
|
||||
crontab.guru
|
||||
</Link>
|
||||
</Text>
|
||||
<Box>
|
||||
<TextField.Root
|
||||
value={backupCron}
|
||||
onChange={(e) => setBackupCron(e.target.value)}
|
||||
placeholder="e.g. 0 4 * * * (every day at 04:00)"
|
||||
/>
|
||||
<Box mt="1">
|
||||
{backupEnabled && !backupCron.trim() ? (
|
||||
<Text size="1" color="red">
|
||||
A cron expression is required while auto backup is enabled.
|
||||
</Text>
|
||||
) : (
|
||||
<CronStatusHint status={backupCronStatus} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
) : (
|
||||
<Box className="schedule-grid">
|
||||
<Text size="2" color="gray">Auto restart</Text>
|
||||
<Text size="2">
|
||||
{config ? ((config.restartEnabled ?? true) ? "enabled" : "disabled") : "—"}
|
||||
</Text>
|
||||
|
||||
<Text size="2" color="gray">Daily restart</Text>
|
||||
<Text size="2">
|
||||
{displayHour}:{displayMinute}
|
||||
</Text>
|
||||
|
||||
<Text size="2" color="gray">Timezone</Text>
|
||||
<Text size="2" className="mono">{config?.restartTz ?? "—"}</Text>
|
||||
|
||||
<Text size="2" color="gray">Warning lead</Text>
|
||||
<Text size="2">
|
||||
{config ? `${config.restartWarningDurationSecs}s` : "—"}
|
||||
</Text>
|
||||
|
||||
<Text size="2" color="gray">Warning frequency</Text>
|
||||
<Text size="2">
|
||||
{config ? `${config.restartWarningFrequencySecs}s` : "—"}
|
||||
</Text>
|
||||
|
||||
<Text size="2" color="gray">Auto update</Text>
|
||||
<Text size="2">
|
||||
{config ? ((config.updateEnabled ?? true) ? "enabled" : "disabled") : "—"}
|
||||
</Text>
|
||||
|
||||
<Text size="2" color="gray">Update apply lead</Text>
|
||||
<Text size="2">
|
||||
{config ? `${config.updateLeadSecs}s` : "—"}
|
||||
</Text>
|
||||
|
||||
<Text size="2" color="gray">Auto backup</Text>
|
||||
<Text size="2">
|
||||
{config ? ((config.backupEnabled ?? true) ? "enabled" : "disabled") : "—"}
|
||||
</Text>
|
||||
|
||||
<Text size="2" color="gray">Backup cron</Text>
|
||||
<Text size="2" className="mono">
|
||||
{config
|
||||
? config.backupCron && config.backupCron.trim()
|
||||
? config.backupCron
|
||||
: "disabled (manual only)"
|
||||
: "—"}
|
||||
</Text>
|
||||
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<Text size="1" color="red" mt="2">{error}</Text>
|
||||
) : null}
|
||||
|
||||
<Flex gap="2" mt="3" align="center" wrap="wrap">
|
||||
{editing ? (
|
||||
<>
|
||||
<Button size="1" onClick={save} disabled={busy}>
|
||||
{busy ? busyLabel : "Save"}
|
||||
</Button>
|
||||
<Button size="1" variant="soft" color="gray" onClick={cancelEdit} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{!editing && restartRequired ? (
|
||||
<Callout.Root color="amber" size="1" style={{ padding: "4px 10px" }}>
|
||||
<Callout.Text>
|
||||
Saved values differ from what the running service loaded — restart the service to apply them.
|
||||
</Callout.Text>
|
||||
</Callout.Root>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function RunRow({
|
||||
run,
|
||||
logsState,
|
||||
onExpand,
|
||||
onRefreshLogs,
|
||||
}: {
|
||||
run: RunDto;
|
||||
logsState: LogState | undefined;
|
||||
onExpand: () => void;
|
||||
onRefreshLogs: () => void;
|
||||
}) {
|
||||
return (
|
||||
<details
|
||||
className="run-row"
|
||||
onToggle={(e) => {
|
||||
if ((e.currentTarget as HTMLDetailsElement).open) onExpand();
|
||||
}}
|
||||
>
|
||||
<summary className="run-row-summary">
|
||||
<Text size="1" className="mono" style={{ minWidth: 40, color: "var(--gray-9)" }}>
|
||||
#{run.id}
|
||||
</Text>
|
||||
<Text size="2" style={{ flex: "0 0 auto", minWidth: 140 }}>
|
||||
{run.taskId}
|
||||
</Text>
|
||||
<Badge color={statusColor(run.status)}>{run.status}</Badge>
|
||||
<Text size="1" className="mono" style={{ color: "var(--gray-10)" }}>
|
||||
{formatDateTime(run.startedAt)}
|
||||
</Text>
|
||||
<Text size="1" className="mono" style={{ color: "var(--gray-10)", marginLeft: "auto" }}>
|
||||
{run.durationMs != null ? `${(run.durationMs / 1000).toFixed(1)}s` : "—"}
|
||||
</Text>
|
||||
</summary>
|
||||
<Box className="run-row-body">
|
||||
<Flex justify="between" align="center" mb="2">
|
||||
<Text size="1" color="gray">
|
||||
{run.trigger}
|
||||
{run.dryRun ? " · dry-run" : ""}
|
||||
{run.error ? ` · error: ${run.error}` : ""}
|
||||
</Text>
|
||||
<Button
|
||||
size="1"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRefreshLogs();
|
||||
}}
|
||||
>
|
||||
{logsState?.status === "loading" ? "Loading…" : "Refresh logs"}
|
||||
</Button>
|
||||
</Flex>
|
||||
<Box className="run-log-box">
|
||||
{logsState === undefined || logsState.status === "loading" ? (
|
||||
<Text color="gray" size="1">
|
||||
Loading logs…
|
||||
</Text>
|
||||
) : logsState.status === "error" ? (
|
||||
<Text color="red" size="1">
|
||||
{logsState.error}
|
||||
</Text>
|
||||
) : logsState.logs.length === 0 ? (
|
||||
<Text color="gray" size="1">
|
||||
No log entries for this run.
|
||||
</Text>
|
||||
) : (
|
||||
logsState.logs.map((log) => (
|
||||
<div key={log.id}>
|
||||
<span className={`log-level-${log.level}`}>{log.level.toUpperCase()}</span>
|
||||
<span className="log-ts">{formatTime(log.createdAt)}</span>
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function CronStatusHint({
|
||||
status,
|
||||
}: {
|
||||
status:
|
||||
| { state: "idle" }
|
||||
| { state: "validating" }
|
||||
| { state: "ok"; tz: string; next: string[] }
|
||||
| { state: "error"; message: string };
|
||||
}) {
|
||||
if (status.state === "idle") {
|
||||
return (
|
||||
<Text size="1" color="gray">
|
||||
Empty = disabled. Standard 5-field cron (min hour day month dow) in your configured timezone.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (status.state === "validating") {
|
||||
return (
|
||||
<Text size="1" color="gray">
|
||||
Checking…
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (status.state === "error") {
|
||||
return (
|
||||
<Text size="1" color="red">
|
||||
{status.message}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<Text size="1" color="green">
|
||||
Valid. Next runs ({status.tz}):
|
||||
</Text>
|
||||
<Flex direction="column" mt="1" gap="1">
|
||||
{status.next.map((time) => (
|
||||
<Text key={time} size="1" className="mono" color="gray">
|
||||
{time}
|
||||
</Text>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function statusColor(s: string): "gray" | "green" | "red" | "amber" {
|
||||
if (s === "success") return "green";
|
||||
if (s === "failed") return "red";
|
||||
if (s === "running") return "amber";
|
||||
return "gray";
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
// Parse a numeric text field, ignoring non-digits, and clamp to [0, max].
|
||||
// Used by the HH:MM restart fields so they can show zero-padded values
|
||||
// (a native number input strips leading zeros).
|
||||
function clampInt(raw: string, max: number): number {
|
||||
const n = Number(raw.replace(/\D/g, ""));
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return Math.min(Math.trunc(n), max);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Box, Button, Flex, Popover, Text, TextField } from "@radix-ui/themes";
|
||||
|
||||
export type ComboboxProps<T> = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
loadOptions: (query: string) => Promise<T[]>;
|
||||
getOptionValue: (option: T) => string;
|
||||
renderOption: (option: T) => ReactNode;
|
||||
/** Resolve the friendly label for the current value (e.g. fetch the item name). */
|
||||
resolveLabel?: (value: string) => Promise<string | null>;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
disabled?: boolean;
|
||||
/** Optional override of the trigger label (e.g. "All online" sentinel). */
|
||||
triggerLabelOverride?: (value: string) => string | null;
|
||||
};
|
||||
|
||||
export default function Combobox<T>({
|
||||
value,
|
||||
onChange,
|
||||
loadOptions,
|
||||
getOptionValue,
|
||||
renderOption,
|
||||
resolveLabel,
|
||||
placeholder = "(none)",
|
||||
searchPlaceholder = "Search…",
|
||||
disabled,
|
||||
triggerLabelOverride,
|
||||
}: ComboboxProps<T>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [options, setOptions] = useState<T[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resolvedLabel, setResolvedLabel] = useState<string | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Resolve the friendly label whenever the value changes (and isn't an override).
|
||||
useEffect(() => {
|
||||
const override = triggerLabelOverride?.(value);
|
||||
if (override !== undefined && override !== null) {
|
||||
setResolvedLabel(override);
|
||||
return;
|
||||
}
|
||||
if (!value || !resolveLabel) {
|
||||
setResolvedLabel(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
resolveLabel(value)
|
||||
.then((label) => {
|
||||
if (!cancelled) setResolvedLabel(label);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setResolvedLabel(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [value, resolveLabel, triggerLabelOverride]);
|
||||
|
||||
// Debounced load on open + on query change.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
const handle = setTimeout(() => {
|
||||
loadOptions(query)
|
||||
.then((opts) => {
|
||||
if (!cancelled) {
|
||||
setOptions(opts);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setOptions([]);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}, 200);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}, [open, query, loadOptions]);
|
||||
|
||||
const handleOpenChange = useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
if (next) {
|
||||
// Reset search and focus the input when opening.
|
||||
setQuery("");
|
||||
setTimeout(() => searchRef.current?.focus(), 30);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pick = useCallback(
|
||||
(option: T) => {
|
||||
onChange(getOptionValue(option));
|
||||
setOpen(false);
|
||||
},
|
||||
[getOptionValue, onChange],
|
||||
);
|
||||
|
||||
const triggerLabel = resolvedLabel ?? value ?? "";
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<Popover.Trigger>
|
||||
<Button
|
||||
variant="surface"
|
||||
color="gray"
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
className="combobox-trigger"
|
||||
>
|
||||
<Flex align="center" justify="between" gap="2" width="100%">
|
||||
{triggerLabel ? (
|
||||
<Text size="2" className="combobox-trigger-label">
|
||||
{triggerLabel}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="2" color="gray">
|
||||
{placeholder}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="1" color="gray" aria-hidden>
|
||||
▾
|
||||
</Text>
|
||||
</Flex>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content size="1" minWidth="320px" maxWidth="480px">
|
||||
<Flex direction="column" gap="2">
|
||||
<TextField.Root
|
||||
ref={searchRef}
|
||||
size="1"
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Box className="combobox-list">
|
||||
{loading ? (
|
||||
<Text size="1" color="gray">
|
||||
Loading…
|
||||
</Text>
|
||||
) : options.length === 0 ? (
|
||||
<Text size="1" color="gray">
|
||||
No matches.
|
||||
</Text>
|
||||
) : (
|
||||
options.map((option) => (
|
||||
<button
|
||||
key={getOptionValue(option)}
|
||||
type="button"
|
||||
className="combobox-option"
|
||||
onClick={() => pick(option)}
|
||||
>
|
||||
{renderOption(option)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
{value ? (
|
||||
<Flex justify="end">
|
||||
<Button
|
||||
size="1"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
Flex,
|
||||
Separator,
|
||||
Text,
|
||||
} from "@radix-ui/themes";
|
||||
|
||||
import { managementApi } from "../../services/management";
|
||||
import type { DumpPruneItem, DumpPruneResult } from "../../types/management";
|
||||
|
||||
type LoadState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "confirming"; items: DumpPruneItem[]; selected: Set<string> }
|
||||
| { status: "deleting"; items: DumpPruneItem[]; selected: Set<string> }
|
||||
| { status: "done"; result: DumpPruneResult };
|
||||
|
||||
function itemKey(item: { namespace: string; name: string }): string {
|
||||
return `${item.namespace}/${item.name}`;
|
||||
}
|
||||
|
||||
export type DumpPruneDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
tunnelId: string;
|
||||
};
|
||||
|
||||
export default function DumpPruneDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
tunnelId,
|
||||
}: DumpPruneDialogProps) {
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setState({ status: "loading" });
|
||||
try {
|
||||
const items = await managementApi.dumpPrunePreview(tunnelId);
|
||||
const selected = new Set(items.map(itemKey));
|
||||
// Drop straight into confirming — every row pre-selected; the operator
|
||||
// ticks off anything they want to keep before clicking Delete.
|
||||
setState({ status: "confirming", items, selected });
|
||||
} catch (err) {
|
||||
setState({ status: "error", message: String(err) });
|
||||
}
|
||||
}, [tunnelId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void load();
|
||||
}, [open, load]);
|
||||
|
||||
const toggle = (key: string) => {
|
||||
setState((prev) => {
|
||||
if (prev.status !== "confirming") return prev;
|
||||
const next = new Set(prev.selected);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return { ...prev, selected: next };
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = (checked: boolean) => {
|
||||
setState((prev) => {
|
||||
if (prev.status !== "confirming") return prev;
|
||||
const next = checked ? new Set(prev.items.map(itemKey)) : new Set<string>();
|
||||
return { ...prev, selected: next };
|
||||
});
|
||||
};
|
||||
|
||||
const runDelete = async () => {
|
||||
if (state.status !== "confirming") return;
|
||||
const targets = state.items
|
||||
.filter((item) => state.selected.has(itemKey(item)))
|
||||
.map((item) => ({ namespace: item.namespace, name: item.name }));
|
||||
if (targets.length === 0) return;
|
||||
setState({ status: "deleting", items: state.items, selected: state.selected });
|
||||
try {
|
||||
const result = await managementApi.dumpPruneExecute(tunnelId, targets);
|
||||
setState({ status: "done", result });
|
||||
} catch (err) {
|
||||
setState({ status: "error", message: String(err) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Content maxWidth="720px">
|
||||
<Dialog.Title>Clean up database operations</Dialog.Title>
|
||||
<Dialog.Description size="2" color="gray" mb="3">
|
||||
Removes terminal <Text className="mono">DatabaseOperation</Text> resources from the
|
||||
cluster — both <Badge color="green">Succeeded</Badge> (artifact on disk, CR is just
|
||||
bookkeeping) and <Badge color="red">Failed</Badge> (no artifact produced, pure
|
||||
clutter). Covers both <Text className="mono">dump</Text> and{" "}
|
||||
<Text className="mono">import</Text> actions. The pod attached to each is
|
||||
garbage-collected by Funcom's operator. The{" "}
|
||||
<Text className="mono">.backup</Text> file on disk is{" "}
|
||||
<Text weight="bold">never</Text> touched. In-progress operations are not listed.
|
||||
</Dialog.Description>
|
||||
|
||||
<Separator size="4" my="2" />
|
||||
|
||||
<Body state={state} onToggle={toggle} onSelectAll={selectAll} />
|
||||
|
||||
<Flex gap="2" mt="4" justify="end">
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray">
|
||||
{state.status === "done" ? "Close" : "Cancel"}
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
{state.status === "confirming" ? (
|
||||
<Button
|
||||
color="red"
|
||||
disabled={state.selected.size === 0}
|
||||
onClick={runDelete}
|
||||
>
|
||||
Delete {state.selected.size} selected
|
||||
</Button>
|
||||
) : null}
|
||||
{state.status === "done" ? (
|
||||
<Button onClick={() => void load()}>Refresh</Button>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function Body({
|
||||
state,
|
||||
onToggle,
|
||||
onSelectAll,
|
||||
}: {
|
||||
state: LoadState;
|
||||
onToggle: (key: string) => void;
|
||||
onSelectAll: (checked: boolean) => void;
|
||||
}) {
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<Text size="2" color="gray">
|
||||
Listing eligible dump operations…
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<Text size="2" color="red">
|
||||
{state.message}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (state.status === "deleting") {
|
||||
return (
|
||||
<Text size="2" color="gray">
|
||||
Deleting {state.selected.size} operation(s)…
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (state.status === "done") {
|
||||
return (
|
||||
<Box>
|
||||
<Text size="2" weight="medium">
|
||||
Deleted: {state.result.deleted.length}
|
||||
</Text>
|
||||
{state.result.deleted.length > 0 ? (
|
||||
<Box mt="1" mb="2">
|
||||
{state.result.deleted.map((name) => (
|
||||
<Text key={name} size="1" className="mono" color="green" as="div">
|
||||
{name}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
{state.result.skipped.length > 0 ? (
|
||||
<Box mt="2">
|
||||
<Text size="2" weight="medium" color="amber">
|
||||
Skipped: {state.result.skipped.length}
|
||||
</Text>
|
||||
{state.result.skipped.map((row) => (
|
||||
<Text
|
||||
key={itemKey(row)}
|
||||
size="1"
|
||||
className="mono"
|
||||
color="amber"
|
||||
as="div"
|
||||
>
|
||||
{row.namespace}/{row.name} — {row.reason}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// confirming
|
||||
if (state.items.length === 0) {
|
||||
return (
|
||||
<Text size="2" color="gray">
|
||||
Nothing to clean up — no terminal dump operations found.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const allSelected = state.selected.size === state.items.length;
|
||||
const succeeded = state.items.filter((i) => i.phase === "Succeeded").length;
|
||||
const failed = state.items.filter((i) => i.phase === "Failed").length;
|
||||
return (
|
||||
<Box>
|
||||
<Flex align="center" gap="3" mb="2" wrap="wrap">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={(checked) => onSelectAll(Boolean(checked))}
|
||||
/>
|
||||
<Text size="2">
|
||||
{allSelected ? "Deselect all" : "Select all"} ({state.items.length} total)
|
||||
</Text>
|
||||
<Flex gap="2" ml="auto">
|
||||
<Badge color="green">{succeeded} succeeded</Badge>
|
||||
<Badge color="red">{failed} failed</Badge>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Box className="dump-prune-list">
|
||||
{state.items.map((item) => {
|
||||
const key = itemKey(item);
|
||||
const checked = state.selected.has(key);
|
||||
const badgeColor: "green" | "red" | "gray" =
|
||||
item.phase === "Succeeded" ? "green" : item.phase === "Failed" ? "red" : "gray";
|
||||
return (
|
||||
<Flex key={key} gap="2" align="center" py="1">
|
||||
<Checkbox checked={checked} onCheckedChange={() => onToggle(key)} />
|
||||
<Badge color={badgeColor}>{item.phase}</Badge>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="2" className="mono" as="div">
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text size="1" color="gray" as="div">
|
||||
ns={item.namespace} · action={item.action} · age={item.ageDays}d ·{" "}
|
||||
{item.backup ? `backup=${item.backup}` : "no backup recorded"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback } from "react";
|
||||
import { Flex, Text } from "@radix-ui/themes";
|
||||
|
||||
import { managementApi } from "../../services/management";
|
||||
import Combobox from "./Combobox";
|
||||
|
||||
export type ItemComboboxProps = {
|
||||
tunnelId: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export default function ItemCombobox({ tunnelId, value, onChange }: ItemComboboxProps) {
|
||||
const loadOptions = useCallback(
|
||||
async (query: string) => {
|
||||
try {
|
||||
const rows = await managementApi.searchItems(tunnelId, query, 30);
|
||||
return [...rows].sort((a, b) =>
|
||||
(a.name || a.id).localeCompare(b.name || b.id, undefined, {
|
||||
sensitivity: "base",
|
||||
numeric: true,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[tunnelId],
|
||||
);
|
||||
|
||||
const resolveLabel = useCallback(
|
||||
async (id: string): Promise<string | null> => {
|
||||
if (!id) return null;
|
||||
try {
|
||||
const rows = await managementApi.searchItems(tunnelId, id, 5);
|
||||
const hit = rows.find((it) => it.id === id);
|
||||
return hit ? `${hit.name} · ${hit.id}` : id;
|
||||
} catch {
|
||||
return id;
|
||||
}
|
||||
},
|
||||
[tunnelId],
|
||||
);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
loadOptions={loadOptions}
|
||||
getOptionValue={(it) => it.id}
|
||||
resolveLabel={resolveLabel}
|
||||
renderOption={(it) => (
|
||||
<Flex justify="between" gap="2">
|
||||
<Text size="2">{it.name}</Text>
|
||||
<Text size="1" color="gray" className="mono">{it.id}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
placeholder="Pick an item..."
|
||||
searchPlaceholder="Search items..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Dialog,
|
||||
Flex,
|
||||
Text,
|
||||
} from "@radix-ui/themes";
|
||||
|
||||
import type { RemoteServerRecord } from "../../types/server";
|
||||
import { managementService } from "../../services/management";
|
||||
import {
|
||||
INSTALL_STEPS,
|
||||
type InstallProgressEvent,
|
||||
} from "../../types/management";
|
||||
import type { LogRow } from "../../types/log";
|
||||
import { listenToEvent } from "../../services/tauri";
|
||||
import { log } from "../../utils/logging";
|
||||
|
||||
import type { ManagementStatusState } from "./useManagementStatus";
|
||||
|
||||
export type ManagementServiceCardProps = {
|
||||
server: RemoteServerRecord;
|
||||
status: ManagementStatusState;
|
||||
onRefresh: () => Promise<void>;
|
||||
appendLogRow: (row: LogRow) => void;
|
||||
};
|
||||
|
||||
type StepStatus = "pending" | "running" | "ok" | "error" | "skipped";
|
||||
|
||||
type InstallPhase =
|
||||
| { kind: "idle" }
|
||||
| { kind: "installing"; steps: Record<string, { status: StepStatus; message?: string }> }
|
||||
| { kind: "done"; ok: boolean; message?: string };
|
||||
|
||||
export default function ManagementServiceCard({
|
||||
server,
|
||||
status,
|
||||
onRefresh,
|
||||
appendLogRow,
|
||||
}: ManagementServiceCardProps) {
|
||||
const [bundledVersion, setBundledVersion] = useState<string | null>(null);
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
const [phase, setPhase] = useState<InstallPhase>({ kind: "idle" });
|
||||
const [uninstallOpen, setUninstallOpen] = useState(false);
|
||||
const [uninstallBusy, setUninstallBusy] = useState(false);
|
||||
const [restartBusy, setRestartBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
managementService.bundledVersion().then(setBundledVersion).catch(() => setBundledVersion(null));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!installOpen) return;
|
||||
let unlisten: (() => void) | null = null;
|
||||
const promise = listenToEvent<InstallProgressEvent>("management-install-progress", (ev) => {
|
||||
setPhase((current) => {
|
||||
if (current.kind !== "installing") return current;
|
||||
const next = { ...current.steps };
|
||||
next[ev.step] = { status: ev.status as StepStatus, message: ev.message ?? undefined };
|
||||
return { kind: "installing", steps: next };
|
||||
});
|
||||
const stepLabel = INSTALL_STEPS.find((s) => s.id === ev.step)?.label ?? ev.step;
|
||||
const detail = ev.message ? ` (${ev.message})` : "";
|
||||
if (ev.status === "error") {
|
||||
appendLogRow(
|
||||
log.error("mgmt.install", `${stepLabel} failed${detail}`, server.id),
|
||||
);
|
||||
} else if (ev.status === "running") {
|
||||
appendLogRow(log.info("mgmt.install", `${stepLabel} started${detail}`, server.id));
|
||||
} else if (ev.status === "ok") {
|
||||
appendLogRow(log.info("mgmt.install", `${stepLabel} ok${detail}`, server.id));
|
||||
}
|
||||
});
|
||||
promise.then((fn) => {
|
||||
unlisten = fn;
|
||||
});
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [installOpen, appendLogRow, server.id]);
|
||||
|
||||
const installed = status.kind === "ok" ? status.value.installed : false;
|
||||
const active = status.kind === "ok" ? status.value.active : false;
|
||||
const installedVersion = status.kind === "ok" ? status.value.installedVersion : null;
|
||||
const remoteBundled = status.kind === "ok" ? status.value.bundledVersion : null;
|
||||
const effectiveBundled = remoteBundled ?? bundledVersion;
|
||||
const updateAvailable =
|
||||
installed && !!installedVersion && !!effectiveBundled && installedVersion !== effectiveBundled;
|
||||
|
||||
const startInstall = useCallback(async () => {
|
||||
const initial: Record<string, { status: StepStatus; message?: string }> = {};
|
||||
for (const step of INSTALL_STEPS) {
|
||||
initial[step.id] = { status: "pending" };
|
||||
}
|
||||
setPhase({ kind: "installing", steps: initial });
|
||||
appendLogRow(
|
||||
log.info(
|
||||
"mgmt.install",
|
||||
`${installed ? "Updating" : "Installing"} dune-server-service on ${server.host}…`,
|
||||
server.id,
|
||||
),
|
||||
);
|
||||
try {
|
||||
const result = await managementService.install({
|
||||
host: server.host,
|
||||
user: server.user,
|
||||
keyPath: server.keyPath,
|
||||
port: server.port,
|
||||
});
|
||||
setPhase({
|
||||
kind: "done",
|
||||
ok: result.started,
|
||||
message: result.message,
|
||||
});
|
||||
appendLogRow(
|
||||
log.info(
|
||||
"mgmt.install",
|
||||
`${installed ? "Update" : "Install"} ${result.started ? "succeeded" : "completed but service not active"}${result.installedVersion ? ` (v${result.installedVersion})` : ""}.`,
|
||||
server.id,
|
||||
),
|
||||
);
|
||||
await onRefresh();
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
setPhase({ kind: "done", ok: false, message });
|
||||
appendLogRow(
|
||||
log.error("mgmt.install", `Install failed: ${message}`, server.id),
|
||||
);
|
||||
}
|
||||
}, [onRefresh, server.host, server.id, server.keyPath, server.port, server.user, installed, appendLogRow]);
|
||||
|
||||
const closeInstall = useCallback(() => {
|
||||
setInstallOpen(false);
|
||||
setPhase({ kind: "idle" });
|
||||
}, []);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setRestartBusy(true);
|
||||
appendLogRow(
|
||||
log.info("mgmt.restart", `Restarting dune-server-service on ${server.host}…`, server.id),
|
||||
);
|
||||
try {
|
||||
await managementService.restart({
|
||||
host: server.host,
|
||||
user: server.user,
|
||||
keyPath: server.keyPath,
|
||||
port: server.port,
|
||||
});
|
||||
appendLogRow(log.info("mgmt.restart", `Restart issued on ${server.host}.`, server.id));
|
||||
// systemd needs a moment to come back up before /api/health responds again.
|
||||
setTimeout(() => void onRefresh(), 1500);
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
appendLogRow(log.error("mgmt.restart", `Restart failed: ${message}`, server.id));
|
||||
alert(`Restart failed: ${message}`);
|
||||
} finally {
|
||||
setRestartBusy(false);
|
||||
}
|
||||
}, [server, appendLogRow, onRefresh]);
|
||||
|
||||
const handleUninstall = useCallback(async () => {
|
||||
setUninstallBusy(true);
|
||||
appendLogRow(
|
||||
log.info("mgmt.uninstall", `Uninstalling dune-server-service from ${server.host}…`, server.id),
|
||||
);
|
||||
try {
|
||||
await managementService.uninstall({
|
||||
host: server.host,
|
||||
user: server.user,
|
||||
keyPath: server.keyPath,
|
||||
port: server.port,
|
||||
});
|
||||
setUninstallOpen(false);
|
||||
appendLogRow(log.info("mgmt.uninstall", `Uninstalled from ${server.host}.`, server.id));
|
||||
await onRefresh();
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
appendLogRow(log.error("mgmt.uninstall", `Uninstall failed: ${message}`, server.id));
|
||||
alert(`Uninstall failed: ${message}`);
|
||||
} finally {
|
||||
setUninstallBusy(false);
|
||||
}
|
||||
}, [onRefresh, server, appendLogRow]);
|
||||
|
||||
const showInstallButton = !installed;
|
||||
const showUpdateButton = installed && updateAvailable;
|
||||
const installInProgress = phase.kind === "installing";
|
||||
|
||||
return (
|
||||
<Card mt="3">
|
||||
<Flex justify="between" align="start" gap="3" wrap="wrap">
|
||||
<Box>
|
||||
<Text size="3" weight="medium">
|
||||
Management service
|
||||
</Text>
|
||||
<Flex align="center" gap="2" mt="1" wrap="wrap">
|
||||
<Badge color={installed ? (active ? "green" : "amber") : "gray"}>
|
||||
{status.kind === "loading"
|
||||
? "checking..."
|
||||
: installed
|
||||
? active
|
||||
? `active${installedVersion ? ` ${installedVersion}` : ""}`
|
||||
: `installed, not running${installedVersion ? ` (${installedVersion})` : ""}`
|
||||
: "not installed"}
|
||||
</Badge>
|
||||
{status.kind === "ok" && status.value.initSystem ? (
|
||||
<Badge color="gray" variant="surface">
|
||||
{status.value.initSystem}
|
||||
</Badge>
|
||||
) : null}
|
||||
{updateAvailable ? (
|
||||
<Badge color="amber" variant="soft">
|
||||
update available: {installedVersion} → {effectiveBundled}
|
||||
</Badge>
|
||||
) : installed && active && installedVersion && effectiveBundled && !updateAvailable ? (
|
||||
<Text size="1" color="gray">
|
||||
Up to date
|
||||
</Text>
|
||||
) : null}
|
||||
{status.kind === "error" ? (
|
||||
<Text size="1" color="red">
|
||||
{status.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Box>
|
||||
<Flex gap="2" wrap="wrap">
|
||||
<Button size="1" variant="surface" onClick={() => void onRefresh()}>
|
||||
Refresh
|
||||
</Button>
|
||||
{showInstallButton ? (
|
||||
<Button size="1" variant="solid" onClick={() => setInstallOpen(true)}>
|
||||
Install
|
||||
</Button>
|
||||
) : null}
|
||||
{showUpdateButton ? (
|
||||
<Button size="1" variant="solid" color="amber" onClick={() => setInstallOpen(true)}>
|
||||
Update
|
||||
</Button>
|
||||
) : null}
|
||||
{installed ? (
|
||||
<Button
|
||||
size="1"
|
||||
variant="surface"
|
||||
onClick={handleRestart}
|
||||
disabled={restartBusy}
|
||||
>
|
||||
{restartBusy ? "Restarting…" : "Restart"}
|
||||
</Button>
|
||||
) : null}
|
||||
{installed ? (
|
||||
<Button size="1" variant="surface" color="red" onClick={() => setUninstallOpen(true)}>
|
||||
Uninstall
|
||||
</Button>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Dialog.Root
|
||||
open={installOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !installInProgress) closeInstall();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content maxWidth="540px">
|
||||
<Dialog.Title>{installed ? "Update management service" : "Install management service"}</Dialog.Title>
|
||||
<Dialog.Description size="2" mb="3" color="gray">
|
||||
Uploads the bundled dune-server-service binary to{" "}
|
||||
<Text className="mono">/opt/dune-server-service/</Text>, installs the unit, and starts the service.
|
||||
</Dialog.Description>
|
||||
|
||||
{phase.kind === "idle" ? (
|
||||
<Text size="2" color="gray">
|
||||
Ready to {installed ? "update" : "install"}. Click {installed ? '"Update"' : '"Install"'} to begin.
|
||||
</Text>
|
||||
) : (
|
||||
<Box className="install-step-list">
|
||||
{INSTALL_STEPS.map((step) => {
|
||||
const s =
|
||||
phase.kind === "installing"
|
||||
? phase.steps[step.id]?.status ?? "pending"
|
||||
: "ok";
|
||||
const msg =
|
||||
phase.kind === "installing" ? phase.steps[step.id]?.message : undefined;
|
||||
return (
|
||||
<Flex key={step.id} align="center" gap="2" className="install-step-row">
|
||||
<span className={`install-step-icon install-step-${s}`} aria-hidden>
|
||||
{iconFor(s)}
|
||||
</span>
|
||||
<Text size="2">{step.label}</Text>
|
||||
{msg ? (
|
||||
<Text size="1" color="gray">
|
||||
— {msg}
|
||||
</Text>
|
||||
) : null}
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
{phase.kind === "done" && !phase.ok ? (
|
||||
<Text size="1" color="red" mt="2">
|
||||
{phase.message ?? "Install failed."}
|
||||
</Text>
|
||||
) : null}
|
||||
{phase.kind === "done" && phase.ok ? (
|
||||
<Text size="1" color="green" mt="2">
|
||||
{phase.message ?? "Install complete."}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Flex gap="2" mt="4" justify="end">
|
||||
{phase.kind === "idle" ? (
|
||||
<>
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray">
|
||||
Cancel
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
<Button onClick={startInstall}>{installed ? "Update" : "Install"}</Button>
|
||||
</>
|
||||
) : phase.kind === "done" ? (
|
||||
<Button onClick={closeInstall}>Close</Button>
|
||||
) : (
|
||||
<Button disabled>Installing…</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<AlertDialog.Root open={uninstallOpen} onOpenChange={setUninstallOpen}>
|
||||
<AlertDialog.Content maxWidth="420px">
|
||||
<AlertDialog.Title>Uninstall management service?</AlertDialog.Title>
|
||||
<AlertDialog.Description size="2">
|
||||
Stops and removes <Text className="mono">dune-server-service</Text> and its unit file from the host.
|
||||
The SQLite history database under <Text className="mono">/opt/dune-server-service</Text> will be deleted.
|
||||
</AlertDialog.Description>
|
||||
<Flex gap="2" mt="4" justify="end">
|
||||
<AlertDialog.Cancel>
|
||||
<Button variant="soft" color="gray" disabled={uninstallBusy}>
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialog.Cancel>
|
||||
<Button color="red" onClick={handleUninstall} disabled={uninstallBusy}>
|
||||
{uninstallBusy ? "Uninstalling…" : "Uninstall"}
|
||||
</Button>
|
||||
</Flex>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function iconFor(status: StepStatus): string {
|
||||
switch (status) {
|
||||
case "ok":
|
||||
return "OK";
|
||||
case "error":
|
||||
return "X";
|
||||
case "running":
|
||||
return "…";
|
||||
case "skipped":
|
||||
return "-";
|
||||
default:
|
||||
return " ";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
Flex,
|
||||
Switch,
|
||||
Table,
|
||||
Text,
|
||||
TextField,
|
||||
} from "@radix-ui/themes";
|
||||
|
||||
import { managementApi } from "../../services/management";
|
||||
import type { PlayerDto } from "../../types/management";
|
||||
import { copyTextToClipboard } from "../../utils/clipboard";
|
||||
import { formatDateTime } from "../../utils/formatting";
|
||||
|
||||
import type { AdminTabPrefill } from "./AdminTab";
|
||||
|
||||
// The service sends last-seen as a UTC wall-clock string with no offset
|
||||
// ("YYYY-MM-DD HH:MM:SS"). Tag it as UTC so it localizes instead of being
|
||||
// parsed as the viewer's local time, then render in their timezone.
|
||||
function formatLastSeen(raw: string): string {
|
||||
const s = raw.trim();
|
||||
if (!s) return "—";
|
||||
return formatDateTime(`${s.replace(" ", "T")}Z`);
|
||||
}
|
||||
|
||||
export type UsersTabProps = {
|
||||
tunnelId: string;
|
||||
onSwitchToAdmin: (prefill: AdminTabPrefill) => void;
|
||||
};
|
||||
|
||||
export default function UsersTab({ tunnelId, onSwitchToAdmin }: UsersTabProps) {
|
||||
const [users, setUsers] = useState<PlayerDto[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [onlineOnly, setOnlineOnly] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(
|
||||
async (q: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const rows = await managementApi.searchPlayers(tunnelId, q, 200);
|
||||
setUsers(rows);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[tunnelId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void reload("");
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
void reload(query.trim());
|
||||
}, 300);
|
||||
return () => clearTimeout(handle);
|
||||
}, [query, reload]);
|
||||
|
||||
// Poll for live player-status changes. Without this the list only refreshed
|
||||
// on mount / manual click, so logins and logouts went unseen until the app
|
||||
// was reopened (#13). Toggleable per #14; on by default.
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const handle = setInterval(() => {
|
||||
void reload(query.trim());
|
||||
}, 5000);
|
||||
return () => clearInterval(handle);
|
||||
}, [autoRefresh, query, reload]);
|
||||
|
||||
const visible = useMemo(
|
||||
() => (onlineOnly ? users.filter((u) => u.online.toLowerCase() === "online") : users),
|
||||
[users, onlineOnly],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box mt="3">
|
||||
<Flex gap="3" align="center" wrap="wrap" mb="3">
|
||||
<TextField.Root
|
||||
placeholder="Search name or FLS id…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
style={{ flex: "1 1 280px", minWidth: 0 }}
|
||||
/>
|
||||
<Flex align="center" gap="2">
|
||||
<Switch checked={onlineOnly} onCheckedChange={setOnlineOnly} />
|
||||
<Text size="2">Online only</Text>
|
||||
</Flex>
|
||||
<Flex align="center" gap="2">
|
||||
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
|
||||
<Text size="2">Auto-refresh</Text>
|
||||
</Flex>
|
||||
<Button
|
||||
size="1"
|
||||
variant="ghost"
|
||||
onClick={() => void reload(query.trim())}
|
||||
disabled={busy}
|
||||
style={{ minWidth: 64, justifyContent: "center" }}
|
||||
>
|
||||
{busy ? "Loading…" : "Refresh"}
|
||||
</Button>
|
||||
<Text
|
||||
size="1"
|
||||
color="gray"
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
flexShrink: 0,
|
||||
minWidth: 96,
|
||||
textAlign: "right",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{visible.length} of {users.length}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{error ? (
|
||||
<Text size="1" color="red">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Table.Root variant="surface" size="1">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeaderCell>Name</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>FLS ID</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Level</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Partition</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Status</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Last seen</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell></Table.ColumnHeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{visible.map((user) => (
|
||||
<Table.Row key={user.flsId}>
|
||||
<Table.Cell>{user.name || <Text color="gray">—</Text>}</Table.Cell>
|
||||
<Table.Cell className="mono" style={{ fontSize: 11 }}>
|
||||
{user.flsId}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="mono" style={{ fontSize: 11 }}>
|
||||
{user.level ?? <Text color="gray">—</Text>}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="mono" style={{ fontSize: 11 }}>
|
||||
{user.partitionId ?? <Text color="gray">—</Text>}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge color={user.online.toLowerCase() === "online" ? "green" : "gray"}>
|
||||
{user.online || "offline"}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="mono" style={{ fontSize: 11, color: "var(--gray-10)" }}>
|
||||
{user.online.toLowerCase() === "online" ? "—" : formatLastSeen(user.lastSeen)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button size="1" variant="ghost">
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item onSelect={() => void copyTextToClipboard(user.flsId)}>
|
||||
Copy FLS ID
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() =>
|
||||
onSwitchToAdmin({
|
||||
commandId: "AddItemToInventory",
|
||||
values: { PlayerId: user.flsId },
|
||||
})
|
||||
}
|
||||
>
|
||||
Grant item…
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() =>
|
||||
onSwitchToAdmin({
|
||||
commandId: "AwardXP",
|
||||
values: { PlayerId: user.flsId },
|
||||
})
|
||||
}
|
||||
>
|
||||
Award XP…
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() =>
|
||||
onSwitchToAdmin({
|
||||
commandId: "TeleportTo",
|
||||
values: { PlayerId: user.flsId },
|
||||
})
|
||||
}
|
||||
>
|
||||
Teleport…
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
color="red"
|
||||
onSelect={() =>
|
||||
onSwitchToAdmin({
|
||||
commandId: "KickPlayer",
|
||||
values: { PlayerId: user.flsId },
|
||||
})
|
||||
}
|
||||
>
|
||||
Kick player…
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
{visible.length === 0 && !busy ? (
|
||||
<Table.Row>
|
||||
<Table.Cell colSpan={7}>
|
||||
<Text color="gray">
|
||||
No users{onlineOnly ? " online" : ""}.
|
||||
</Text>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
) : null}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
PlusIcon,
|
||||
PaperPlaneIcon,
|
||||
ReloadIcon,
|
||||
TrashIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
Flex,
|
||||
Separator,
|
||||
Table,
|
||||
Text,
|
||||
TextArea,
|
||||
TextField,
|
||||
Tooltip,
|
||||
} from "@radix-ui/themes";
|
||||
|
||||
import { managementApi, managementService } from "../../services/management";
|
||||
import type { PlayerDto, PublishResultDto, ScheduleConfig, WelcomeGrantDto } from "../../types/management";
|
||||
import type { RemoteServerRecord } from "../../types/server";
|
||||
import { formatDateTime } from "../../utils/formatting";
|
||||
import Combobox from "./Combobox";
|
||||
import ItemCombobox from "./ItemCombobox";
|
||||
|
||||
type WelcomeAction = { type: "grantItem"; itemName: string; quantity: number };
|
||||
|
||||
export type WelcomePackageTabProps = {
|
||||
tunnelId: string;
|
||||
server: RemoteServerRecord;
|
||||
onAfterRestart?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export default function WelcomePackageTab({
|
||||
tunnelId,
|
||||
server,
|
||||
onAfterRestart,
|
||||
}: WelcomePackageTabProps) {
|
||||
const [config, setConfig] = useState<ScheduleConfig | null>(null);
|
||||
const [grants, setGrants] = useState<WelcomeGrantDto[]>([]);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [messageEnabled, setMessageEnabled] = useState(false);
|
||||
const [whisperSourcePlayer, setWhisperSourcePlayer] = useState("");
|
||||
const [welcomeMessage, setWelcomeMessage] = useState("");
|
||||
const [testRecipientPlayer, setTestRecipientPlayer] = useState("");
|
||||
const [testMessage, setTestMessage] = useState("");
|
||||
const [testOpen, setTestOpen] = useState(false);
|
||||
const [actions, setActions] = useState<WelcomeAction[]>([]);
|
||||
const [contentsOpen, setContentsOpen] = useState(true);
|
||||
const [jsonMode, setJsonMode] = useState(false);
|
||||
const [jsonText, setJsonText] = useState("[]");
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [sendingWhisper, setSendingWhisper] = useState(false);
|
||||
const [whisperResult, setWhisperResult] = useState<PublishResultDto | null>(null);
|
||||
const [retryingKey, setRetryingKey] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const c = await managementApi.getConfig(tunnelId);
|
||||
const g = await managementApi.welcomeGrants(tunnelId, 50);
|
||||
setConfig(c);
|
||||
setEnabled(c.welcomePackageEnabled);
|
||||
setMessageEnabled(c.welcomeMessageEnabled ?? false);
|
||||
setWhisperSourcePlayer(c.welcomeWhisperSourcePlayer ?? "");
|
||||
setWelcomeMessage(c.welcomeMessage ?? "");
|
||||
const rawJson = c.welcomePackageActionsJson || c.welcomePackageItemsJson || "[]";
|
||||
setActions(parseActions(rawJson));
|
||||
// Pretty-print and keep the JSON-mode textarea in sync with what the
|
||||
// service is actually persisting, so toggling into JSON mode after a
|
||||
// reload shows the current config rather than a stale buffer.
|
||||
try {
|
||||
setJsonText(JSON.stringify(JSON.parse(rawJson), null, 2));
|
||||
} catch {
|
||||
setJsonText(rawJson);
|
||||
}
|
||||
setJsonError(null);
|
||||
setGrants(g);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}, [tunnelId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const retryGrant = useCallback(
|
||||
async (grant: WelcomeGrantDto) => {
|
||||
const key = `${grant.playerId}:${grant.packageVersion}:${grant.accountId}`;
|
||||
setRetryingKey(key);
|
||||
setError(null);
|
||||
try {
|
||||
await managementApi.retryWelcomeGrant(
|
||||
tunnelId,
|
||||
grant.playerId,
|
||||
grant.packageVersion,
|
||||
grant.accountId,
|
||||
);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setRetryingKey(null);
|
||||
}
|
||||
},
|
||||
[refresh, tunnelId],
|
||||
);
|
||||
|
||||
const actionsJson = useMemo(() => JSON.stringify(actions, null, 2), [actions]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (messageEnabled && !welcomeMessage.trim()) {
|
||||
throw new Error("Enabled welcome message needs message text.");
|
||||
}
|
||||
// In JSON mode the textarea is the source of truth — validate by
|
||||
// parsing it through the same shape check the visual editor uses.
|
||||
let outgoingActionsJson = actionsJson;
|
||||
if (jsonMode) {
|
||||
const parsed = parseActions(jsonText);
|
||||
validateActions(parsed);
|
||||
outgoingActionsJson = JSON.stringify(parsed, null, 2);
|
||||
} else {
|
||||
validateActions(actions);
|
||||
}
|
||||
await managementApi.setConfig(tunnelId, {
|
||||
welcomeMessageEnabled: messageEnabled,
|
||||
welcomePackageEnabled: enabled,
|
||||
welcomePackageVersion: "v1",
|
||||
welcomePackageActionsJson: outgoingActionsJson,
|
||||
welcomeWhisperSourcePlayer: whisperSourcePlayer,
|
||||
welcomeMessage,
|
||||
});
|
||||
await managementService.restart({
|
||||
host: server.host,
|
||||
user: server.user,
|
||||
keyPath: server.keyPath,
|
||||
port: server.port,
|
||||
});
|
||||
await waitForConfig(tunnelId);
|
||||
await refresh();
|
||||
await onAfterRestart?.();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [
|
||||
actions,
|
||||
actionsJson,
|
||||
enabled,
|
||||
jsonMode,
|
||||
jsonText,
|
||||
messageEnabled,
|
||||
refresh,
|
||||
server.host,
|
||||
server.keyPath,
|
||||
server.port,
|
||||
server.user,
|
||||
tunnelId,
|
||||
whisperSourcePlayer,
|
||||
welcomeMessage,
|
||||
onAfterRestart,
|
||||
]);
|
||||
|
||||
const sendWhisper = useCallback(async () => {
|
||||
setSendingWhisper(true);
|
||||
setError(null);
|
||||
setWhisperResult(null);
|
||||
try {
|
||||
if (!testRecipientPlayer.trim()) throw new Error("Pick a recipient player.");
|
||||
if (!testMessage.trim()) throw new Error("Welcome message must not be empty.");
|
||||
const result = await managementApi.sendWelcomeWhisper(
|
||||
tunnelId,
|
||||
testRecipientPlayer,
|
||||
whisperSourcePlayer,
|
||||
testMessage,
|
||||
);
|
||||
setWhisperResult(result);
|
||||
if (result.ok) setTestOpen(false);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setSendingWhisper(false);
|
||||
}
|
||||
}, [testMessage, testRecipientPlayer, tunnelId, whisperSourcePlayer]);
|
||||
|
||||
const trigger = useCallback(async () => {
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
await managementApi.triggerRun(tunnelId, "welcome-package");
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}, [refresh, tunnelId]);
|
||||
|
||||
const restartRequired = config?.restartRequired ?? false;
|
||||
|
||||
return (
|
||||
<Box mt="3">
|
||||
<Flex justify="between" align="start" gap="3" wrap="wrap">
|
||||
<Box>
|
||||
<Text size="3" weight="medium">Welcome automation</Text>
|
||||
<Flex gap="2" mt="2" align="center" wrap="wrap">
|
||||
<Badge color={messageEnabled ? "green" : "gray"}>
|
||||
message {messageEnabled ? "enabled" : "off"}
|
||||
</Badge>
|
||||
<Badge color={enabled ? "green" : "gray"}>
|
||||
package {enabled ? "enabled" : "off"}
|
||||
</Badge>
|
||||
</Flex>
|
||||
</Box>
|
||||
<Flex gap="2" align="center" wrap="wrap">
|
||||
<Button size="1" variant="surface" onClick={refresh} disabled={busy || running}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="1" variant="surface" onClick={trigger} disabled={busy || running}>
|
||||
{running ? "Running..." : "Run scan"}
|
||||
</Button>
|
||||
<Button size="1" onClick={save} disabled={busy || running}>
|
||||
{busy ? "Saving..." : "Save & restart service"}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Separator size="4" my="3" />
|
||||
|
||||
<Box className="run-row-body">
|
||||
<Flex direction="column" gap="3">
|
||||
<Flex justify="between" align="center" gap="3" wrap="wrap">
|
||||
<Text size="2" weight="medium">Welcome message</Text>
|
||||
<Flex gap="2" align="center" wrap="wrap">
|
||||
{whisperResult ? (
|
||||
<Badge color={whisperResult.ok ? "green" : "red"}>
|
||||
{whisperResult.ok ? "sent" : "failed"}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Button
|
||||
size="1"
|
||||
variant="surface"
|
||||
onClick={() => {
|
||||
setTestMessage(welcomeMessage);
|
||||
setTestOpen(true);
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
<PaperPlaneIcon />
|
||||
Test
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex align="center" gap="2">
|
||||
<Checkbox
|
||||
checked={messageEnabled}
|
||||
onCheckedChange={(checked) => setMessageEnabled(Boolean(checked))}
|
||||
/>
|
||||
<Text size="2">Enabled</Text>
|
||||
</Flex>
|
||||
<Flex gap="3" align="end" wrap="wrap">
|
||||
<Box style={{ flex: "1 1 280px", minWidth: 240 }}>
|
||||
<Text size="1" color="gray">Sender identity</Text>
|
||||
<PlayerCombobox
|
||||
tunnelId={tunnelId}
|
||||
value={whisperSourcePlayer}
|
||||
onChange={setWhisperSourcePlayer}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box>
|
||||
<Text size="1" color="gray">Message</Text>
|
||||
<TextArea
|
||||
value={welcomeMessage}
|
||||
onChange={(e) => setWelcomeMessage(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<Separator size="4" my="4" />
|
||||
|
||||
<Box className="run-row-body">
|
||||
<Flex direction="column" gap="2" mb="2">
|
||||
<Text size="2" weight="medium">Welcome package</Text>
|
||||
<Flex align="center" gap="2">
|
||||
<Checkbox checked={enabled} onCheckedChange={(checked) => setEnabled(Boolean(checked))} />
|
||||
<Text size="2">Enabled</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Box mt="3">
|
||||
<Flex justify="between" align="center" gap="3" wrap="wrap" mb={contentsOpen ? "3" : "0"}>
|
||||
<Button
|
||||
size="1"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
onClick={() => setContentsOpen((open) => !open)}
|
||||
aria-expanded={contentsOpen}
|
||||
>
|
||||
{contentsOpen ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
||||
<Text size="2" weight="medium">Package contents</Text>
|
||||
<Badge color="gray">{actions.length} item{actions.length === 1 ? "" : "s"}</Badge>
|
||||
</Button>
|
||||
{contentsOpen ? (
|
||||
<Flex gap="2" wrap="wrap" align="center">
|
||||
<Text as="label" size="1" color="gray">
|
||||
<Flex align="center" gap="1">
|
||||
<Checkbox
|
||||
checked={jsonMode}
|
||||
onCheckedChange={(checked) => {
|
||||
const next = checked === true;
|
||||
if (next) {
|
||||
// Visual -> JSON: seed the textarea from the current
|
||||
// actions so the operator can copy / hand-edit.
|
||||
setJsonText(JSON.stringify(actions, null, 2));
|
||||
setJsonError(null);
|
||||
setJsonMode(true);
|
||||
} else {
|
||||
// JSON -> Visual: parse the textarea and only switch
|
||||
// back if it's valid; otherwise stay in JSON mode and
|
||||
// show the error so nothing silently drops.
|
||||
try {
|
||||
const parsed = parseActions(jsonText);
|
||||
validateActions(parsed);
|
||||
setActions(parsed);
|
||||
setJsonError(null);
|
||||
setJsonMode(false);
|
||||
} catch (err) {
|
||||
setJsonError(String(err));
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
JSON mode
|
||||
</Flex>
|
||||
</Text>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Flex>
|
||||
|
||||
{contentsOpen && jsonMode ? (
|
||||
<Flex direction="column" gap="2">
|
||||
<TextArea
|
||||
value={jsonText}
|
||||
onChange={(e) => {
|
||||
setJsonText(e.target.value);
|
||||
if (jsonError) setJsonError(null);
|
||||
}}
|
||||
placeholder='[{"type":"grantItem","itemName":"PlantFiber","quantity":1}]'
|
||||
rows={16}
|
||||
style={{ fontFamily: "var(--code-font-family, monospace)", fontSize: 12 }}
|
||||
/>
|
||||
{jsonError ? (
|
||||
<Text size="1" color="red">{jsonError}</Text>
|
||||
) : (
|
||||
<Text size="1" color="gray">
|
||||
Raw JSON of package contents. Saved after validation. Toggle JSON mode off to switch back to the visual editor.
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
) : null}
|
||||
|
||||
{contentsOpen && !jsonMode ? (
|
||||
<Flex direction="column" gap="3">
|
||||
{actions.length === 0 ? (
|
||||
<Text size="2" color="gray">No items configured.</Text>
|
||||
) : (
|
||||
<Table.Root variant="surface" size="1">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeaderCell>Item</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell width="120px">Qty</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell width="44px"></Table.ColumnHeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{actions.map((action, index) => (
|
||||
<ActionRow
|
||||
key={`${index}:${action.itemName}`}
|
||||
tunnelId={tunnelId}
|
||||
action={action}
|
||||
onChange={(next) =>
|
||||
setActions((prev) => prev.map((row, i) => (i === index ? next : row)))
|
||||
}
|
||||
onRemove={() => setActions((prev) => prev.filter((_, i) => i !== index))}
|
||||
/>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
)}
|
||||
<Box>
|
||||
<Button
|
||||
size="1"
|
||||
variant="surface"
|
||||
onClick={() =>
|
||||
setActions((prev) => [
|
||||
...prev,
|
||||
{ type: "grantItem", itemName: "", quantity: 1 },
|
||||
])
|
||||
}
|
||||
>
|
||||
<PlusIcon />
|
||||
Add item
|
||||
</Button>
|
||||
</Box>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{restartRequired ? (
|
||||
<Text size="1" color="amber" as="div" mt="3">
|
||||
Saved values differ from the running service; save/restart applies the current package.
|
||||
</Text>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Text size="1" color="red" as="div" mt="3">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Dialog.Root open={testOpen} onOpenChange={setTestOpen}>
|
||||
<Dialog.Content maxWidth="520px">
|
||||
<Dialog.Title>Test welcome message</Dialog.Title>
|
||||
<Flex direction="column" gap="3" mt="3">
|
||||
<Box>
|
||||
<Text size="1" color="gray">Recipient</Text>
|
||||
<PlayerCombobox
|
||||
tunnelId={tunnelId}
|
||||
value={testRecipientPlayer}
|
||||
onChange={setTestRecipientPlayer}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="1" color="gray">Message</Text>
|
||||
<TextArea
|
||||
value={testMessage}
|
||||
onChange={(e) => setTestMessage(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={1000}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Flex justify="end" gap="2" mt="4">
|
||||
<Dialog.Close>
|
||||
<Button size="1" variant="ghost" color="gray">
|
||||
Cancel
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
<Button
|
||||
size="1"
|
||||
onClick={sendWhisper}
|
||||
disabled={busy || sendingWhisper}
|
||||
>
|
||||
<PaperPlaneIcon />
|
||||
{sendingWhisper ? "Sending..." : "Send"}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<Box mt="4">
|
||||
<Text size="2" weight="medium">Recent grants</Text>
|
||||
<Table.Root variant="surface" size="1" mt="2">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeaderCell>Status</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Player</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Updated</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell width="64px"></Table.ColumnHeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{grants.length === 0 ? (
|
||||
<Table.Row>
|
||||
<Table.Cell colSpan={4}>
|
||||
<Text size="1" color="gray">No grants recorded yet.</Text>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
) : (
|
||||
grants.map((grant) => {
|
||||
const key = `${grant.playerId}:${grant.packageVersion}:${grant.accountId}`;
|
||||
return (
|
||||
<Table.Row key={key}>
|
||||
<Table.Cell>
|
||||
<Badge color={grant.status === "granted" ? "green" : grant.status === "failed" ? "red" : "amber"}>
|
||||
{grant.status}
|
||||
</Badge>
|
||||
{grant.status === "failed" && grant.lastError ? (
|
||||
<Text size="1" color="red" as="div" style={{ maxWidth: 320 }}>
|
||||
{grant.lastError}
|
||||
</Text>
|
||||
) : null}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Text size="1" className="mono">{grant.playerId}</Text>
|
||||
{grant.characterName ? (
|
||||
<Text size="1" color="gray" as="div">{grant.characterName}</Text>
|
||||
) : null}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="mono">{formatDateTime(grant.updatedAt)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{grant.status === "failed" ? (
|
||||
<Tooltip content="Clear the failed record so the next scan retries">
|
||||
<Button
|
||||
size="1"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
disabled={retryingKey === key}
|
||||
onClick={() => void retryGrant(grant)}
|
||||
aria-label="Retry welcome package"
|
||||
>
|
||||
<ReloadIcon />
|
||||
{retryingKey === key ? "Retrying..." : "Retry"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({
|
||||
tunnelId,
|
||||
action,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
tunnelId: string;
|
||||
action: WelcomeAction;
|
||||
onChange: (action: WelcomeAction) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<ItemCombobox
|
||||
tunnelId={tunnelId}
|
||||
value={action.itemName}
|
||||
onChange={(itemName) => onChange({ ...action, itemName })}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<TextField.Root
|
||||
type="number"
|
||||
min="1"
|
||||
value={String(action.quantity)}
|
||||
onChange={(e) => onChange({ ...action, quantity: Number(e.target.value) || 1 })}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Flex justify="center" align="center">
|
||||
<Tooltip content="Remove item">
|
||||
<Button
|
||||
size="1"
|
||||
variant="ghost"
|
||||
color="red"
|
||||
onClick={onRemove}
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerCombobox({
|
||||
tunnelId,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
tunnelId: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const loadOptions = useCallback(
|
||||
async (query: string) => managementApi.searchPlayers(tunnelId, query, 30),
|
||||
[tunnelId],
|
||||
);
|
||||
const resolveLabel = useCallback(
|
||||
async (id: string): Promise<string | null> => {
|
||||
if (!id) return null;
|
||||
try {
|
||||
const rows = await managementApi.searchPlayers(tunnelId, id, 5);
|
||||
const hit = rows.find((p) => p.flsId === id);
|
||||
return hit ? `${hit.name || "(unnamed)"} (${hit.online}) · ${hit.flsId}` : id;
|
||||
} catch {
|
||||
return id;
|
||||
}
|
||||
},
|
||||
[tunnelId],
|
||||
);
|
||||
return (
|
||||
<Combobox<PlayerDto>
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
loadOptions={loadOptions}
|
||||
getOptionValue={(p) => p.flsId}
|
||||
resolveLabel={resolveLabel}
|
||||
renderOption={(p) => (
|
||||
<Flex justify="between" gap="2" align="center">
|
||||
<Box>
|
||||
<Text size="2">{p.name || "(unnamed)"}</Text>
|
||||
<Text size="1" color="gray" as="div" className="mono">{p.flsId}</Text>
|
||||
</Box>
|
||||
<Badge color={p.online?.toLowerCase() === "online" ? "green" : "gray"}>
|
||||
{p.online || "offline"}
|
||||
</Badge>
|
||||
</Flex>
|
||||
)}
|
||||
placeholder="Pick a player…"
|
||||
searchPlaceholder="Search players…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function parseActions(raw: string): WelcomeAction[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || "[]");
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
if (parsed.some((row) => row && typeof row === "object" && "type" in row)) {
|
||||
return parsed
|
||||
.map((row): WelcomeAction | null => {
|
||||
if (row?.type === "grantItem") {
|
||||
return {
|
||||
type: "grantItem",
|
||||
itemName: String(row.itemName ?? row.item_name ?? ""),
|
||||
quantity: Number(row.quantity ?? 1) || 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((row): row is WelcomeAction => !!row);
|
||||
}
|
||||
return parsed
|
||||
.map((row): WelcomeAction | null => ({
|
||||
type: "grantItem",
|
||||
itemName: String(row?.itemName ?? row?.item_name ?? ""),
|
||||
quantity: Number(row?.quantity ?? 1) || 1,
|
||||
}))
|
||||
.filter((row): row is WelcomeAction => !!row && row.type === "grantItem" && row.itemName.trim().length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function validateActions(actions: WelcomeAction[]) {
|
||||
for (const action of actions) {
|
||||
if (action.type === "grantItem") {
|
||||
if (!action.itemName.trim()) throw new Error("Every item grant needs an item.");
|
||||
if (action.quantity <= 0) throw new Error(`Quantity for ${action.itemName} must be greater than 0.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForConfig(tunnelId: string) {
|
||||
const deadline = Date.now() + 15_000;
|
||||
let lastErr: unknown = null;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
try {
|
||||
await managementApi.getConfig(tunnelId);
|
||||
return;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
throw new Error(`service did not come back up: ${lastErr}`);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { managementService } from "../../services/management";
|
||||
import type { RemoteServerRecord } from "../../types/server";
|
||||
import type { ManagementServiceStatus } from "../../types/management";
|
||||
import type { LogRow } from "../../types/log";
|
||||
import { log } from "../../utils/logging";
|
||||
|
||||
export type ManagementStatusState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "ok"; value: ManagementServiceStatus }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
export type UseManagementStatus = {
|
||||
state: ManagementStatusState;
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function useManagementStatus(
|
||||
server: RemoteServerRecord,
|
||||
appendLogRow?: (row: LogRow) => void,
|
||||
): UseManagementStatus {
|
||||
const [state, setState] = useState<ManagementStatusState>({ kind: "idle" });
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setState({ kind: "loading" });
|
||||
appendLogRow?.(
|
||||
log.info("mgmt.status", `Checking management service on ${server.host}…`, server.id),
|
||||
);
|
||||
try {
|
||||
const result = await managementService.status({
|
||||
host: server.host,
|
||||
user: server.user,
|
||||
keyPath: server.keyPath,
|
||||
port: server.port,
|
||||
});
|
||||
setState({ kind: "ok", value: result });
|
||||
const summary = !result.installed
|
||||
? "not installed"
|
||||
: result.active
|
||||
? `active${result.installedVersion ? ` v${result.installedVersion}` : ""} (${result.initSystem})`
|
||||
: `installed but not running${result.installedVersion ? ` v${result.installedVersion}` : ""}`;
|
||||
appendLogRow?.(
|
||||
log.info("mgmt.status", `Management service on ${server.host}: ${summary}.`, server.id),
|
||||
);
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
setState({ kind: "error", message });
|
||||
appendLogRow?.(
|
||||
log.error(
|
||||
"mgmt.status",
|
||||
`Failed to read management status on ${server.host}: ${message}`,
|
||||
server.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [server.host, server.id, server.keyPath, server.port, server.user, appendLogRow]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { state, refresh };
|
||||
}
|
||||
|
||||
export function isManagementReady(state: ManagementStatusState): boolean {
|
||||
return state.kind === "ok" && state.value.installed && state.value.active;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { RemoteServerRecord } from "../../types/server";
|
||||
import type { ServerTunnelStatus } from "../../types/tunnel";
|
||||
import { serverTunnelKey } from "../../utils/remote-server";
|
||||
|
||||
export type ManagementTunnelState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "connecting"; tunnelId: string }
|
||||
| { kind: "ready"; tunnelId: string; status: ServerTunnelStatus }
|
||||
| { kind: "error"; tunnelId: string; message: string };
|
||||
|
||||
export function useManagementTunnel(
|
||||
server: RemoteServerRecord,
|
||||
enabled: boolean,
|
||||
): ManagementTunnelState {
|
||||
const tunnelId = serverTunnelKey(server.id, "managementApi");
|
||||
const [state, setState] = useState<ManagementTunnelState>({ kind: "idle" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setState({ kind: "idle" });
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setState({ kind: "connecting", tunnelId });
|
||||
invoke<ServerTunnelStatus>("start_server_tunnel", {
|
||||
request: {
|
||||
tunnelId,
|
||||
serverKind: server.type,
|
||||
service: "managementApi",
|
||||
host: server.host,
|
||||
user: server.user,
|
||||
keyPath: server.keyPath,
|
||||
port: server.port,
|
||||
namespace: server.namespace || "",
|
||||
},
|
||||
})
|
||||
.then((status) => {
|
||||
if (!cancelled) setState({ kind: "ready", tunnelId, status });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setState({ kind: "error", tunnelId, message: String(err) });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
invoke("stop_server_tunnel", { request: { tunnelId } }).catch(() => {});
|
||||
};
|
||||
}, [enabled, tunnelId, server.host, server.keyPath, server.namespace, server.port, server.type, server.user]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Badge, Box, Button, Flex, Text } from "@radix-ui/themes";
|
||||
|
||||
import type { RemoteServerComponent } from "../../types/server";
|
||||
import { copyTextToClipboard } from "../../utils/clipboard";
|
||||
import { componentLogStateKey, isCriticalRestartComponent } from "../../utils/remote-server";
|
||||
|
||||
export type ComponentHealthGroupProps = {
|
||||
title: string;
|
||||
serverKey: string;
|
||||
components: RemoteServerComponent[];
|
||||
logs: Record<string, string>;
|
||||
logBusy: Record<string, boolean>;
|
||||
restartBusy: Record<string, boolean>;
|
||||
onRefreshLog: (component: RemoteServerComponent) => void;
|
||||
onRestart: (component: RemoteServerComponent) => void;
|
||||
};
|
||||
|
||||
export default function ComponentHealthGroup({
|
||||
title,
|
||||
serverKey,
|
||||
components,
|
||||
logs,
|
||||
logBusy,
|
||||
restartBusy,
|
||||
onRefreshLog,
|
||||
onRestart,
|
||||
}: ComponentHealthGroupProps) {
|
||||
if (components.length === 0) return null;
|
||||
return (
|
||||
<details className="component-group">
|
||||
<summary className="component-group-summary">
|
||||
<Flex align="center" justify="between" gap="2">
|
||||
<Text size="1" weight="medium" color="gray" className="component-group-title">
|
||||
{title}
|
||||
</Text>
|
||||
<Badge color="gray" variant="soft">
|
||||
{components.length}
|
||||
</Badge>
|
||||
</Flex>
|
||||
</summary>
|
||||
<Flex direction="column" gap="2" mt="2">
|
||||
{components.map((component) => {
|
||||
const logKey = componentLogStateKey(serverKey, component);
|
||||
const logText = logs[logKey];
|
||||
const busy = !!logBusy[logKey];
|
||||
const restarting = !!restartBusy[logKey];
|
||||
return (
|
||||
<details key={`${component.logKey}-${component.name}`} className="component-row">
|
||||
<summary className="component-summary">
|
||||
<Flex align="center" justify="between" gap="3" width="100%">
|
||||
<Box minWidth="0">
|
||||
<Flex align="center" gap="2" wrap="wrap">
|
||||
<Text size="2" weight="medium">
|
||||
{component.name}
|
||||
</Text>
|
||||
<Badge color={component.tone} variant="soft">
|
||||
{component.state}
|
||||
</Badge>
|
||||
</Flex>
|
||||
<Text as="div" size="2" color="gray" className="component-summary-text">
|
||||
{component.summary}
|
||||
</Text>
|
||||
</Box>
|
||||
<Flex gap="2" style={{ flexShrink: 0 }}>
|
||||
<Button
|
||||
type="button"
|
||||
size="1"
|
||||
variant="surface"
|
||||
disabled={busy || restarting}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const row = event.currentTarget.closest("details");
|
||||
if (row) row.open = true;
|
||||
onRefreshLog(component);
|
||||
}}
|
||||
>
|
||||
{busy ? "Loading logs" : logText ? "Refresh logs" : "View logs"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="1"
|
||||
color={isCriticalRestartComponent(component) ? "amber" : "bronze"}
|
||||
variant="soft"
|
||||
disabled={busy || restarting}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const row = event.currentTarget.closest("details");
|
||||
if (row) row.open = true;
|
||||
onRestart(component);
|
||||
}}
|
||||
>
|
||||
{restarting ? "Restarting" : "Restart"}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</summary>
|
||||
<Box className="component-body">
|
||||
{component.details.length > 0 ? (
|
||||
<ul className="component-details">
|
||||
{component.details.map((detail) => (
|
||||
<li key={detail}>{detail}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<Text as="div" size="1" color="gray">
|
||||
No additional details reported.
|
||||
</Text>
|
||||
)}
|
||||
{logText ? (
|
||||
<>
|
||||
<Flex justify="end" mt="2">
|
||||
<Button type="button" size="1" variant="soft" onClick={() => void copyTextToClipboard(logText)}>
|
||||
Copy logs
|
||||
</Button>
|
||||
</Flex>
|
||||
<Box className="component-log" mt="2">
|
||||
{logText.split(/\r?\n/).map((line, index) => (
|
||||
<Text as="div" size="1" className="mono" key={`${component.logKey}-${index}`}>
|
||||
{line || "\u00a0"}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Box, Flex } from "@radix-ui/themes";
|
||||
|
||||
import type { RemoteServerComponent } from "../../types/server";
|
||||
import ComponentHealthGroup from "./ComponentHealthGroup";
|
||||
|
||||
export type ComponentHealthListProps = {
|
||||
serverKey: string;
|
||||
components: RemoteServerComponent[];
|
||||
logs: Record<string, string>;
|
||||
logBusy: Record<string, boolean>;
|
||||
restartBusy: Record<string, boolean>;
|
||||
onRefreshLog: (component: RemoteServerComponent) => void;
|
||||
onRestart: (component: RemoteServerComponent) => void;
|
||||
};
|
||||
|
||||
export default function ComponentHealthList({
|
||||
serverKey,
|
||||
components,
|
||||
logs,
|
||||
logBusy,
|
||||
restartBusy,
|
||||
onRefreshLog,
|
||||
onRestart,
|
||||
}: ComponentHealthListProps) {
|
||||
if (components.length === 0) return null;
|
||||
const systems = components.filter((component) => component.category !== "map");
|
||||
const maps = components.filter((component) => component.category === "map");
|
||||
return (
|
||||
<Box className="component-health" mt="3">
|
||||
<Flex direction="column" gap="3">
|
||||
<ComponentHealthGroup
|
||||
title="Systems"
|
||||
serverKey={serverKey}
|
||||
components={systems}
|
||||
logs={logs}
|
||||
logBusy={logBusy}
|
||||
restartBusy={restartBusy}
|
||||
onRefreshLog={onRefreshLog}
|
||||
onRestart={onRestart}
|
||||
/>
|
||||
<ComponentHealthGroup
|
||||
title="Maps"
|
||||
serverKey={serverKey}
|
||||
components={maps}
|
||||
logs={logs}
|
||||
logBusy={logBusy}
|
||||
restartBusy={restartBusy}
|
||||
onRefreshLog={onRefreshLog}
|
||||
onRestart={onRestart}
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Box, Button, Flex, Grid, Link, Select, Text, TextField } from "@radix-ui/themes";
|
||||
import { TrashIcon } from "@radix-ui/react-icons";
|
||||
|
||||
import type { RemoteServerKind } from "../../types/server";
|
||||
import type {
|
||||
CustomTunnelDef,
|
||||
CustomTunnelProtocol,
|
||||
CustomTunnelStartRequest,
|
||||
ServerTunnelStatus,
|
||||
} from "../../types/tunnel";
|
||||
import { readCustomTunnels, writeCustomTunnels } from "../../services/storage";
|
||||
import BusySpinner from "../ui/BusySpinner";
|
||||
|
||||
type Props = {
|
||||
serverKey: string;
|
||||
host: string;
|
||||
serverKind: RemoteServerKind;
|
||||
user: string;
|
||||
keyPath?: string;
|
||||
port?: number;
|
||||
tunnels: Record<string, ServerTunnelStatus>;
|
||||
tunnelBusy: Record<string, boolean>;
|
||||
onStartCustomTunnel: (request: CustomTunnelStartRequest, name: string) => void;
|
||||
onStopTunnel: (tunnelId: string) => void;
|
||||
onOpenTunnel: (tunnel: ServerTunnelStatus) => void;
|
||||
};
|
||||
|
||||
const BLANK_FORM = {
|
||||
name: "",
|
||||
protocol: "http" as CustomTunnelProtocol,
|
||||
remotePort: "",
|
||||
localPort: "",
|
||||
};
|
||||
|
||||
export default function CustomTunnelControls({
|
||||
serverKey,
|
||||
host,
|
||||
serverKind,
|
||||
user,
|
||||
keyPath,
|
||||
port,
|
||||
tunnels,
|
||||
tunnelBusy,
|
||||
onStartCustomTunnel,
|
||||
onStopTunnel,
|
||||
onOpenTunnel,
|
||||
}: Props) {
|
||||
const [defs, setDefs] = useState<CustomTunnelDef[]>(() => readCustomTunnels(serverKey));
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState(BLANK_FORM);
|
||||
|
||||
useEffect(() => {
|
||||
writeCustomTunnels(serverKey, defs);
|
||||
}, [serverKey, defs]);
|
||||
|
||||
const addDef = () => {
|
||||
const remotePort = parseInt(form.remotePort, 10);
|
||||
const localPort = parseInt(form.localPort, 10) || 0;
|
||||
if (!form.name.trim() || !remotePort) return;
|
||||
const def: CustomTunnelDef = {
|
||||
id: crypto.randomUUID(),
|
||||
name: form.name.trim(),
|
||||
protocol: form.protocol,
|
||||
remotePort,
|
||||
localPort,
|
||||
};
|
||||
setDefs((prev) => [...prev, def]);
|
||||
setForm(BLANK_FORM);
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const removeDef = (id: string) => setDefs((prev) => prev.filter((d) => d.id !== id));
|
||||
|
||||
return (
|
||||
<Box mt="2">
|
||||
<Flex direction="column" gap="2">
|
||||
{defs.map((def) => {
|
||||
const tunnelId = `${serverKey}:tunnel:custom:${def.id}`;
|
||||
const active = tunnels[tunnelId];
|
||||
const busy = !!tunnelBusy[tunnelId];
|
||||
const openLabel = def.protocol === "postgresql" ? "Copy URI" : "Open";
|
||||
const disabled = busy || (!active && !host.trim());
|
||||
return (
|
||||
<Flex key={def.id} align="center" justify="between" gap="3" wrap="wrap" className="tunnel-row">
|
||||
<Flex direction="column" gap="1" minWidth="0">
|
||||
<Text size="2" weight="medium">{def.name}</Text>
|
||||
<Text size="1" color="gray">
|
||||
{active
|
||||
? `Forwarding remote port ${active.remotePort} to local port ${active.localPort}`
|
||||
: !host.trim()
|
||||
? "Requires server host"
|
||||
: "Tunnel stopped"}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex align="center" gap="2" wrap="wrap" justify="end">
|
||||
{active ? (
|
||||
<Button type="button" size="1" variant="surface" onClick={() => onOpenTunnel(active)}>
|
||||
{openLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="1"
|
||||
variant={active ? "soft" : "surface"}
|
||||
color={active ? "red" : undefined}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (active) {
|
||||
onStopTunnel(tunnelId);
|
||||
return;
|
||||
}
|
||||
onStartCustomTunnel(
|
||||
{ tunnelId, serverKind, host, user, keyPath, port, protocol: def.protocol, remotePort: def.remotePort, localPort: def.localPort },
|
||||
def.name,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{busy ? (
|
||||
<Flex align="center" gap="1"><BusySpinner /> Working</Flex>
|
||||
) : active ? (
|
||||
"Stop Tunnel"
|
||||
) : (
|
||||
"Start Tunnel"
|
||||
)}
|
||||
</Button>
|
||||
{!active ? (
|
||||
<Button type="button" size="1" variant="ghost" color="red" disabled={busy} onClick={() => removeDef(def.id)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
) : null}
|
||||
{active ? (
|
||||
<Link
|
||||
size="1"
|
||||
href="#"
|
||||
className="mono tunnel-url"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onOpenTunnel(active);
|
||||
}}
|
||||
>
|
||||
{active.url}
|
||||
</Link>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
|
||||
{showForm ? (
|
||||
<Flex direction="column" gap="2" mt="1">
|
||||
<Grid columns="2fr 1fr" gap="2">
|
||||
<TextField.Root
|
||||
placeholder="Name"
|
||||
size="1"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
<Select.Root
|
||||
value={form.protocol}
|
||||
onValueChange={(v) => setForm((f) => ({ ...f, protocol: v as CustomTunnelProtocol }))}
|
||||
>
|
||||
<Select.Trigger />
|
||||
<Select.Content>
|
||||
<Select.Item value="http">http</Select.Item>
|
||||
<Select.Item value="https">https</Select.Item>
|
||||
<Select.Item value="postgresql">postgresql</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Grid>
|
||||
<Grid columns="1fr 1fr" gap="2">
|
||||
<TextField.Root
|
||||
placeholder="Remote port"
|
||||
size="1"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={form.remotePort}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remotePort: e.target.value }))}
|
||||
/>
|
||||
<TextField.Root
|
||||
placeholder="Local port (0 = auto)"
|
||||
size="1"
|
||||
type="number"
|
||||
min={0}
|
||||
max={65535}
|
||||
value={form.localPort}
|
||||
onChange={(e) => setForm((f) => ({ ...f, localPort: e.target.value }))}
|
||||
/>
|
||||
</Grid>
|
||||
<Flex gap="2">
|
||||
<Button
|
||||
type="button"
|
||||
size="1"
|
||||
variant="surface"
|
||||
disabled={!form.name.trim() || !form.remotePort}
|
||||
onClick={addDef}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="1"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
onClick={() => { setShowForm(false); setForm(BLANK_FORM); }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="1"
|
||||
variant="ghost"
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
onClick={() => setShowForm(true)}
|
||||
>
|
||||
+ Add Custom Tunnel
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { Flex } from "@radix-ui/themes";
|
||||
|
||||
import type { RemoteServerRecord, RemoteServerStatus } from "../../types/server";
|
||||
import type { CustomTunnelStartRequest, ServerTunnelStartRequest, ServerTunnelStatus } from "../../types/tunnel";
|
||||
import {
|
||||
isBattlegroupStarted,
|
||||
isDirectorReadyPhase,
|
||||
phaseTone,
|
||||
remoteServerDefaultUser,
|
||||
} from "../../utils/remote-server";
|
||||
import ActionButton from "../ui/ActionButton";
|
||||
import Metric from "../ui/Metric";
|
||||
import ServerStatsTable from "./ServerStatsTable";
|
||||
import ServerTunnelControls from "./ServerTunnelControls";
|
||||
import CustomTunnelControls from "./CustomTunnelControls";
|
||||
import ManagementServiceCard from "../management/ManagementServiceCard";
|
||||
import type { ManagementStatusState } from "../management/useManagementStatus";
|
||||
import type { LogRow } from "../../types/log";
|
||||
|
||||
export type ServerDashboardProps = {
|
||||
server: RemoteServerRecord;
|
||||
status?: RemoteServerStatus;
|
||||
statusError?: string;
|
||||
busyLabel?: string;
|
||||
tunnels: Record<string, ServerTunnelStatus>;
|
||||
tunnelBusy: Record<string, boolean>;
|
||||
managementStatus: ManagementStatusState;
|
||||
onRefreshManagement: () => Promise<void>;
|
||||
appendLogRow: (row: LogRow) => void;
|
||||
onStartBattlegroup: () => void;
|
||||
onStopBattlegroup: () => void;
|
||||
onRestartBattlegroup: () => void;
|
||||
onStartTunnel: (request: ServerTunnelStartRequest) => void;
|
||||
onStartCustomTunnel: (request: CustomTunnelStartRequest, name: string) => void;
|
||||
onStopTunnel: (tunnelId: string) => void;
|
||||
onOpenTunnel: (tunnel: ServerTunnelStatus) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-server Dashboard sub-tab: status hero metrics, per-map server-stats
|
||||
* table, lifecycle action row (start/stop/restart), and tunnel controls.
|
||||
*/
|
||||
export default function ServerDashboard({
|
||||
server,
|
||||
status,
|
||||
statusError,
|
||||
busyLabel,
|
||||
tunnels,
|
||||
tunnelBusy,
|
||||
managementStatus,
|
||||
onRefreshManagement,
|
||||
appendLogRow,
|
||||
onStartBattlegroup,
|
||||
onStopBattlegroup,
|
||||
onRestartBattlegroup,
|
||||
onStartTunnel,
|
||||
onStartCustomTunnel,
|
||||
onStopTunnel,
|
||||
onOpenTunnel,
|
||||
}: ServerDashboardProps) {
|
||||
const liveStatus = statusError ? undefined : status;
|
||||
const battlegroup = liveStatus?.battlegroup;
|
||||
const battlegroupStarted = liveStatus ? isBattlegroupStarted(liveStatus.battlegroup) : false;
|
||||
const battlegroupStartRequested = liveStatus ? !liveStatus.battlegroup.stop : false;
|
||||
const battlegroupStopped = liveStatus ? liveStatus.battlegroup.stop : false;
|
||||
const directorReady = !!liveStatus && isDirectorReadyPhase(liveStatus.battlegroup.directorPhase);
|
||||
const busy = !!busyLabel;
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="4">
|
||||
<div className="metric-grid">
|
||||
<Metric label="Namespace" value={server.namespace || ""} />
|
||||
<Metric label="BattleGroup" value={server.battlegroupName || ""} />
|
||||
<Metric
|
||||
label="Database"
|
||||
value={battlegroup?.databasePhase ?? ""}
|
||||
tone={battlegroup ? phaseTone(battlegroup.databasePhase ?? "") : "muted"}
|
||||
/>
|
||||
<Metric
|
||||
label="Gateway"
|
||||
value={battlegroup?.serverGroupPhase ?? ""}
|
||||
tone={battlegroup ? phaseTone(battlegroup.serverGroupPhase) : "muted"}
|
||||
/>
|
||||
<Metric
|
||||
label="Director"
|
||||
value={battlegroup?.directorPhase ?? ""}
|
||||
tone={battlegroup ? phaseTone(battlegroup.directorPhase) : "muted"}
|
||||
/>
|
||||
<Metric label="Uptime" value={battlegroup?.uptime ?? ""} />
|
||||
</div>
|
||||
|
||||
{battlegroup?.serverStats && battlegroup.serverStats.length > 0 ? (
|
||||
<ServerStatsTable rows={battlegroup.serverStats} />
|
||||
) : null}
|
||||
|
||||
{statusError ? <div className="server-error">{statusError}</div> : null}
|
||||
|
||||
<div className="action-row">
|
||||
{battlegroupStopped || !liveStatus ? (
|
||||
<ActionButton
|
||||
onClick={onStartBattlegroup}
|
||||
busy={busy && !battlegroupStarted}
|
||||
disabled={busy || !liveStatus || !battlegroupStopped}
|
||||
tone="accent"
|
||||
pendingLabel="Starting"
|
||||
>
|
||||
Start BattleGroup
|
||||
</ActionButton>
|
||||
) : null}
|
||||
{battlegroupStartRequested ? (
|
||||
<>
|
||||
<ActionButton
|
||||
onClick={onRestartBattlegroup}
|
||||
busy={busy}
|
||||
disabled={busy || !liveStatus}
|
||||
tone="default"
|
||||
pendingLabel="Restarting"
|
||||
>
|
||||
Restart
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
onClick={onStopBattlegroup}
|
||||
busy={busy && battlegroupStartRequested && !battlegroupStopped}
|
||||
disabled={busy || !liveStatus}
|
||||
tone="danger"
|
||||
pendingLabel="Stopping"
|
||||
>
|
||||
Stop BattleGroup
|
||||
</ActionButton>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ManagementServiceCard
|
||||
server={server}
|
||||
status={managementStatus}
|
||||
onRefresh={onRefreshManagement}
|
||||
appendLogRow={appendLogRow}
|
||||
/>
|
||||
|
||||
<ServerTunnelControls
|
||||
serverKey={server.id}
|
||||
namespace={server.namespace}
|
||||
host={server.host}
|
||||
serverKind={server.type}
|
||||
user={server.user || remoteServerDefaultUser(server.type)}
|
||||
keyPath={server.keyPath}
|
||||
port={server.port}
|
||||
canStartDirectorTunnel={!!liveStatus && !liveStatus.battlegroup.stop && directorReady}
|
||||
canStartFileBrowserTunnel={!!liveStatus && !liveStatus.battlegroup.stop}
|
||||
canStartDatabaseTunnel={!!liveStatus && !liveStatus.battlegroup.stop}
|
||||
canStartPgHeroTunnel={!!liveStatus && !liveStatus.battlegroup.stop}
|
||||
tunnels={tunnels}
|
||||
tunnelBusy={tunnelBusy}
|
||||
onStartTunnel={onStartTunnel}
|
||||
onStopTunnel={onStopTunnel}
|
||||
onOpenTunnel={onOpenTunnel}
|
||||
/>
|
||||
<CustomTunnelControls
|
||||
key={server.id}
|
||||
serverKey={server.id}
|
||||
host={server.host}
|
||||
serverKind={server.type}
|
||||
user={server.user || remoteServerDefaultUser(server.type)}
|
||||
keyPath={server.keyPath}
|
||||
port={server.port}
|
||||
tunnels={tunnels}
|
||||
tunnelBusy={tunnelBusy}
|
||||
onStartCustomTunnel={onStartCustomTunnel}
|
||||
onStopTunnel={onStopTunnel}
|
||||
onOpenTunnel={onOpenTunnel}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { Box, Flex, Tabs, Text } from "@radix-ui/themes";
|
||||
|
||||
import type {
|
||||
RemoteServerComponent,
|
||||
RemoteServerRecord,
|
||||
RemoteServerStatus,
|
||||
} from "../../types/server";
|
||||
import type { LogRow } from "../../types/log";
|
||||
import type { CustomTunnelStartRequest, ServerTunnelStartRequest, ServerTunnelStatus } from "../../types/tunnel";
|
||||
import type { ServerSubPage } from "../../types/ui";
|
||||
import { isManagementSubPage } from "../../types/ui";
|
||||
import { remoteServerDefaultUser, resolveServerStatus } from "../../utils/remote-server";
|
||||
import ActionButton from "../ui/ActionButton";
|
||||
import StatusPill from "../ui/StatusPill";
|
||||
import ServerDashboard from "./ServerDashboard";
|
||||
import ServerPods from "./ServerPods";
|
||||
import ServerUpdatePanel from "./ServerUpdatePanel";
|
||||
import AdminTab, { type AdminTabPrefill } from "../management/AdminTab";
|
||||
import AutomatedTasksTab from "../management/AutomatedTasksTab";
|
||||
import UsersTab from "../management/UsersTab";
|
||||
import WelcomePackageTab from "../management/WelcomePackageTab";
|
||||
import { isManagementReady, useManagementStatus } from "../management/useManagementStatus";
|
||||
import { useManagementTunnel } from "../management/useManagementTunnel";
|
||||
|
||||
export type ServerDetailPageProps = {
|
||||
server: RemoteServerRecord;
|
||||
sub: ServerSubPage;
|
||||
onSubChange: (sub: ServerSubPage) => void;
|
||||
status?: RemoteServerStatus;
|
||||
statusError?: string;
|
||||
busyLabel?: string;
|
||||
components: RemoteServerComponent[];
|
||||
componentLogs: Record<string, string>;
|
||||
componentLogBusy: Record<string, boolean>;
|
||||
componentRestartBusy: Record<string, boolean>;
|
||||
tunnels: Record<string, ServerTunnelStatus>;
|
||||
tunnelBusy: Record<string, boolean>;
|
||||
onRefresh: () => void;
|
||||
onRemove: () => void;
|
||||
onStartBattlegroup: () => void;
|
||||
onStopBattlegroup: () => void;
|
||||
onRestartBattlegroup: () => void;
|
||||
onUpdateBattlegroup: () => void;
|
||||
onStartTunnel: (request: ServerTunnelStartRequest) => void;
|
||||
onStartCustomTunnel: (request: CustomTunnelStartRequest, name: string) => void;
|
||||
onStopTunnel: (tunnelId: string) => void;
|
||||
onOpenTunnel: (tunnel: ServerTunnelStatus) => void;
|
||||
onRefreshComponentLog: (component: RemoteServerComponent) => void;
|
||||
onRestartComponent: (component: RemoteServerComponent) => void;
|
||||
appendLogRow: (row: LogRow) => void;
|
||||
};
|
||||
|
||||
export default function ServerDetailPage(props: ServerDetailPageProps) {
|
||||
const {
|
||||
server,
|
||||
sub,
|
||||
onSubChange,
|
||||
status,
|
||||
statusError,
|
||||
busyLabel,
|
||||
components,
|
||||
componentLogs,
|
||||
componentLogBusy,
|
||||
componentRestartBusy,
|
||||
tunnels,
|
||||
tunnelBusy,
|
||||
onRefresh,
|
||||
onRemove,
|
||||
onStartBattlegroup,
|
||||
onStopBattlegroup,
|
||||
onRestartBattlegroup,
|
||||
onUpdateBattlegroup,
|
||||
onStartTunnel,
|
||||
onStartCustomTunnel,
|
||||
onStopTunnel,
|
||||
onOpenTunnel,
|
||||
onRefreshComponentLog,
|
||||
onRestartComponent,
|
||||
appendLogRow,
|
||||
} = props;
|
||||
const busy = !!busyLabel;
|
||||
const liveStatus = statusError ? undefined : status;
|
||||
const resolved = resolveServerStatus(statusError, liveStatus, busy, server);
|
||||
|
||||
const management = useManagementStatus(server, appendLogRow);
|
||||
const managementReady = isManagementReady(management.state);
|
||||
const tunnelState = useManagementTunnel(server, managementReady);
|
||||
const tunnelId = tunnelState.kind === "ready" ? tunnelState.tunnelId : null;
|
||||
const [adminPrefill, setAdminPrefill] = useState<AdminTabPrefill>(null);
|
||||
|
||||
const goToAdmin = useCallback(
|
||||
(prefill: AdminTabPrefill) => {
|
||||
setAdminPrefill(prefill);
|
||||
onSubChange("admin");
|
||||
},
|
||||
[onSubChange],
|
||||
);
|
||||
|
||||
// If management goes away (uninstalled / unreachable) while a management
|
||||
// sub-page is active, bounce the user back to the dashboard.
|
||||
useEffect(() => {
|
||||
if (!managementReady && isManagementSubPage(sub)) {
|
||||
onSubChange("dashboard");
|
||||
}
|
||||
}, [managementReady, sub, onSubChange]);
|
||||
|
||||
return (
|
||||
<Box className="pane page-pane">
|
||||
<Flex direction="column" gap="4" height="100%" minHeight="0" p="4">
|
||||
<div className="server-detail-hero" data-tone={resolved.tone}>
|
||||
<div className="server-detail-hero-rail" />
|
||||
<Flex direction="column" gap="1" minWidth="0">
|
||||
<Flex align="center" gap="3" wrap="wrap">
|
||||
<span className="server-name">{server.name}</span>
|
||||
<StatusPill label={resolved.label} tone={resolved.tone} pulse={resolved.pulse} />
|
||||
{busyLabel ? <span className="app-title-sub">{busyLabel}</span> : null}
|
||||
</Flex>
|
||||
<span className="server-host">
|
||||
{server.user || remoteServerDefaultUser(server.type)}@{server.host}
|
||||
{server.battlegroupName ? ` · ${server.battlegroupName}` : ""}
|
||||
{liveStatus?.battlegroup.uptime ? ` · up ${liveStatus.battlegroup.uptime}` : ""}
|
||||
</span>
|
||||
</Flex>
|
||||
<Flex align="center" gap="2">
|
||||
<ActionButton onClick={onRefresh} busy={busy} pendingLabel="Refreshing">
|
||||
Refresh
|
||||
</ActionButton>
|
||||
<ActionButton onClick={onRemove} tone="danger" disabled={busy}>
|
||||
Forget
|
||||
</ActionButton>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<Tabs.Root
|
||||
className="server-detail-tabs"
|
||||
value={sub}
|
||||
onValueChange={(value) => onSubChange(value as ServerSubPage)}
|
||||
>
|
||||
<Tabs.List size="2" color="bronze">
|
||||
<Tabs.Trigger value="dashboard">Dashboard</Tabs.Trigger>
|
||||
<Tabs.Trigger value="update">Update</Tabs.Trigger>
|
||||
<Tabs.Trigger value="pods">Pods</Tabs.Trigger>
|
||||
{managementReady ? (
|
||||
<>
|
||||
<Tabs.Trigger value="users">Users</Tabs.Trigger>
|
||||
<Tabs.Trigger value="admin">Admin</Tabs.Trigger>
|
||||
<Tabs.Trigger value="welcome">Welcome Package</Tabs.Trigger>
|
||||
<Tabs.Trigger value="tasks">Automated tasks</Tabs.Trigger>
|
||||
</>
|
||||
) : null}
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="dashboard" className="server-detail-tab-content">
|
||||
<ServerDashboard
|
||||
server={server}
|
||||
status={liveStatus}
|
||||
statusError={statusError}
|
||||
busyLabel={busyLabel}
|
||||
tunnels={tunnels}
|
||||
tunnelBusy={tunnelBusy}
|
||||
managementStatus={management.state}
|
||||
onRefreshManagement={management.refresh}
|
||||
appendLogRow={appendLogRow}
|
||||
onStartBattlegroup={onStartBattlegroup}
|
||||
onStopBattlegroup={onStopBattlegroup}
|
||||
onRestartBattlegroup={onRestartBattlegroup}
|
||||
onStartTunnel={onStartTunnel}
|
||||
onStartCustomTunnel={onStartCustomTunnel}
|
||||
onStopTunnel={onStopTunnel}
|
||||
onOpenTunnel={onOpenTunnel}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="update" className="server-detail-tab-content">
|
||||
<ServerUpdatePanel
|
||||
server={server}
|
||||
status={liveStatus}
|
||||
busyLabel={busyLabel}
|
||||
onUpdateBattlegroup={onUpdateBattlegroup}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="pods" className="server-detail-tab-content">
|
||||
<ServerPods
|
||||
serverKey={server.id}
|
||||
components={liveStatus ? components : []}
|
||||
logs={componentLogs}
|
||||
logBusy={componentLogBusy}
|
||||
restartBusy={componentRestartBusy}
|
||||
onRefreshLog={onRefreshComponentLog}
|
||||
onRestart={onRestartComponent}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
{managementReady ? (
|
||||
<>
|
||||
<Tabs.Content value="users" className="server-detail-tab-content">
|
||||
<ManagementContent tunnelState={tunnelState} tunnelId={tunnelId}>
|
||||
{(id) => <UsersTab tunnelId={id} onSwitchToAdmin={goToAdmin} />}
|
||||
</ManagementContent>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="admin" className="server-detail-tab-content">
|
||||
<ManagementContent tunnelState={tunnelState} tunnelId={tunnelId}>
|
||||
{(id) => (
|
||||
<AdminTab
|
||||
tunnelId={id}
|
||||
prefill={adminPrefill}
|
||||
onPrefillConsumed={() => setAdminPrefill(null)}
|
||||
/>
|
||||
)}
|
||||
</ManagementContent>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="tasks" className="server-detail-tab-content">
|
||||
<ManagementContent tunnelState={tunnelState} tunnelId={tunnelId}>
|
||||
{(id) => (
|
||||
<AutomatedTasksTab
|
||||
tunnelId={id}
|
||||
server={server}
|
||||
onAfterRestart={management.refresh}
|
||||
/>
|
||||
)}
|
||||
</ManagementContent>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="welcome" className="server-detail-tab-content">
|
||||
<ManagementContent tunnelState={tunnelState} tunnelId={tunnelId}>
|
||||
{(id) => (
|
||||
<WelcomePackageTab
|
||||
tunnelId={id}
|
||||
server={server}
|
||||
onAfterRestart={management.refresh}
|
||||
/>
|
||||
)}
|
||||
</ManagementContent>
|
||||
</Tabs.Content>
|
||||
</>
|
||||
) : null}
|
||||
</Tabs.Root>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ManagementContent({
|
||||
tunnelState,
|
||||
tunnelId,
|
||||
children,
|
||||
}: {
|
||||
tunnelState: ReturnType<typeof useManagementTunnel>;
|
||||
tunnelId: string | null;
|
||||
children: (tunnelId: string) => ReactNode;
|
||||
}) {
|
||||
if (tunnelState.kind === "error") {
|
||||
return (
|
||||
<Box p="4">
|
||||
<Text color="red">Could not open tunnel: {tunnelState.message}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (!tunnelId || tunnelState.kind !== "ready") {
|
||||
return (
|
||||
<Box p="4">
|
||||
<Text color="gray">Opening tunnel to management service…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return <>{children(tunnelId)}</>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { RemoteServerPackageStatus } from "../../types/server";
|
||||
import Metric, { type MetricTone } from "../ui/Metric";
|
||||
|
||||
export type ServerPackageCardStatusProps = {
|
||||
guestPackage?: RemoteServerPackageStatus;
|
||||
};
|
||||
|
||||
export default function ServerPackageCardStatus({ guestPackage }: ServerPackageCardStatusProps) {
|
||||
if (!guestPackage) return null;
|
||||
const downloaded = guestPackage.battlegroupVersion ?? "";
|
||||
const live = guestPackage.liveBattlegroupVersion ?? "";
|
||||
const liveTone: MetricTone = downloaded && live && downloaded !== live ? "warn" : "default";
|
||||
return (
|
||||
<div className="metric-grid">
|
||||
<Metric label="Installed build" value={guestPackage.installedBuildId ?? ""} />
|
||||
<Metric label="Downloaded" value={downloaded} />
|
||||
<Metric label="Running" value={live} tone={liveTone} />
|
||||
<Metric label="Operator" value={guestPackage.operatorVersion ?? ""} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons";
|
||||
import { Flex, Text } from "@radix-ui/themes";
|
||||
|
||||
import type { RemoteServerComponent } from "../../types/server";
|
||||
import { copyTextToClipboard } from "../../utils/clipboard";
|
||||
import { phaseTone } from "../../utils/remote-server";
|
||||
import ActionButton from "../ui/ActionButton";
|
||||
import StatusPill from "../ui/StatusPill";
|
||||
|
||||
export type ServerPodRowProps = {
|
||||
component: RemoteServerComponent;
|
||||
logKey: string;
|
||||
logText?: string;
|
||||
logBusy: boolean;
|
||||
restartBusy: boolean;
|
||||
onRefreshLog: () => void;
|
||||
onRestart: () => void;
|
||||
};
|
||||
|
||||
export default function ServerPodRow({
|
||||
component,
|
||||
logKey,
|
||||
logText,
|
||||
logBusy,
|
||||
restartBusy,
|
||||
onRefreshLog,
|
||||
onRestart,
|
||||
}: ServerPodRowProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const tone = component.tone === "green"
|
||||
? "ok"
|
||||
: component.tone === "amber"
|
||||
? "warn"
|
||||
: component.tone === "red"
|
||||
? "err"
|
||||
: phaseTone(component.state);
|
||||
return (
|
||||
<div className="pod-row" data-tone={tone} data-open={open}>
|
||||
<button
|
||||
type="button"
|
||||
className="pod-row-summary"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="pod-row-chevron" aria-hidden>
|
||||
{open ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
||||
</span>
|
||||
<span className="pod-row-title">
|
||||
<span className="pod-row-name">{component.name}</span>
|
||||
<span className="pod-row-key">{component.logKey}</span>
|
||||
</span>
|
||||
<StatusPill label={component.state} tone={tone} />
|
||||
<span className="pod-row-summary-text">{component.summary}</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="pod-row-body">
|
||||
{component.details.length > 0 ? (
|
||||
<ul className="component-details">
|
||||
{component.details.map((detail) => (
|
||||
<li key={detail}>{detail}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<Text size="1" style={{ color: "var(--color-text-muted)" }}>
|
||||
No additional details reported.
|
||||
</Text>
|
||||
)}
|
||||
<Flex gap="2" mt="2" wrap="wrap">
|
||||
<ActionButton
|
||||
onClick={onRefreshLog}
|
||||
busy={logBusy}
|
||||
pendingLabel="Loading logs"
|
||||
>
|
||||
{logText ? "Refresh logs" : "View logs"}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
onClick={onRestart}
|
||||
busy={restartBusy}
|
||||
tone="danger"
|
||||
pendingLabel="Restarting"
|
||||
>
|
||||
Restart
|
||||
</ActionButton>
|
||||
{logText ? (
|
||||
<ActionButton onClick={() => void copyTextToClipboard(logText)}>
|
||||
Copy logs
|
||||
</ActionButton>
|
||||
) : null}
|
||||
</Flex>
|
||||
{logText ? (
|
||||
<pre className="component-log" data-log-key={logKey}>
|
||||
{logText}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Text } from "@radix-ui/themes";
|
||||
|
||||
import type { RemoteServerComponent } from "../../types/server";
|
||||
import { componentLogStateKey } from "../../utils/remote-server";
|
||||
import ServerPodRow from "./ServerPodRow";
|
||||
|
||||
export type ServerPodsProps = {
|
||||
serverKey: string;
|
||||
components: RemoteServerComponent[];
|
||||
logs: Record<string, string>;
|
||||
logBusy: Record<string, boolean>;
|
||||
restartBusy: Record<string, boolean>;
|
||||
onRefreshLog: (component: RemoteServerComponent) => void;
|
||||
onRestart: (component: RemoteServerComponent) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-server Pods sub-tab: flat table of pod components grouped by
|
||||
* category (systems first, then maps). Each row expands to show details,
|
||||
* a live log tail, and per-pod restart/refresh actions.
|
||||
*/
|
||||
export default function ServerPods({
|
||||
serverKey,
|
||||
components,
|
||||
logs,
|
||||
logBusy,
|
||||
restartBusy,
|
||||
onRefreshLog,
|
||||
onRestart,
|
||||
}: ServerPodsProps) {
|
||||
if (components.length === 0) {
|
||||
return (
|
||||
<Text size="2" style={{ color: "var(--color-text-muted)" }}>
|
||||
No pod information yet. Refresh the server to inventory pods.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const systems = components.filter((component) => component.category !== "map");
|
||||
const maps = components.filter((component) => component.category === "map");
|
||||
return (
|
||||
<div className="pods-table">
|
||||
{systems.length > 0 ? (
|
||||
<section>
|
||||
<div className="section-title">System pods</div>
|
||||
<div className="pod-list">
|
||||
{systems.map((component) => {
|
||||
const key = componentLogStateKey(serverKey, component);
|
||||
return (
|
||||
<ServerPodRow
|
||||
key={component.logKey}
|
||||
component={component}
|
||||
logKey={key}
|
||||
logText={logs[key]}
|
||||
logBusy={!!logBusy[key]}
|
||||
restartBusy={!!restartBusy[key]}
|
||||
onRefreshLog={() => onRefreshLog(component)}
|
||||
onRestart={() => onRestart(component)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{maps.length > 0 ? (
|
||||
<section>
|
||||
<div className="section-title">Map server pods</div>
|
||||
<div className="pod-list">
|
||||
{maps.map((component) => {
|
||||
const key = componentLogStateKey(serverKey, component);
|
||||
return (
|
||||
<ServerPodRow
|
||||
key={component.logKey}
|
||||
component={component}
|
||||
logKey={key}
|
||||
logText={logs[key]}
|
||||
logBusy={!!logBusy[key]}
|
||||
restartBusy={!!restartBusy[key]}
|
||||
onRefreshLog={() => onRefreshLog(component)}
|
||||
onRestart={() => onRestart(component)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { RemoteBattlegroupServerStat } from "../../types/server";
|
||||
import type { StatusTone } from "../ui/StatusPill";
|
||||
|
||||
export type ServerStatsTableProps = {
|
||||
rows: RemoteBattlegroupServerStat[];
|
||||
};
|
||||
|
||||
function phaseTone(phase: string): StatusTone {
|
||||
const v = phase.trim().toLowerCase();
|
||||
if (["running", "ready", "healthy", "available", "reconciling"].includes(v)) return "ok";
|
||||
if (["pending", "starting", "deploying", "scheduling", "creating"].includes(v)) return "warn";
|
||||
if (["failed", "error", "crashloop", "crashloopbackoff", "unhealthy"].includes(v)) return "err";
|
||||
return "gray";
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact per-map game-server table parsed from the vendor `battlegroup
|
||||
* status` output. Mirrors the wrapper's "Game Servers" section.
|
||||
*/
|
||||
export default function ServerStatsTable({ rows }: ServerStatsTableProps) {
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<div className="server-stats">
|
||||
<div className="server-stats-header">
|
||||
<span>Map</span>
|
||||
<span>Phase</span>
|
||||
<span>Ready</span>
|
||||
<span>Players</span>
|
||||
<span className="server-stats-cell-age">Age</span>
|
||||
</div>
|
||||
{rows.map((row, index) => (
|
||||
<div
|
||||
key={`${row.map}-${row.age}-${index}`}
|
||||
className="server-stats-row"
|
||||
data-tone={phaseTone(row.phase)}
|
||||
>
|
||||
<span>{row.map}</span>
|
||||
<span className="server-stats-cell-phase">{row.phase}</span>
|
||||
<span>{row.ready}</span>
|
||||
<span>{row.players}</span>
|
||||
<span className="server-stats-cell-age">{row.age}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Box, Button, Flex, Link, Text } from "@radix-ui/themes";
|
||||
|
||||
import type { RemoteServerKind } from "../../types/server";
|
||||
import type {
|
||||
ServerTunnelStartRequest,
|
||||
ServerTunnelStatus,
|
||||
TunnelService,
|
||||
} from "../../types/tunnel";
|
||||
import { serverTunnelKey } from "../../utils/remote-server";
|
||||
import BusySpinner from "../ui/BusySpinner";
|
||||
|
||||
export type ServerTunnelControlsProps = {
|
||||
serverKey: string;
|
||||
namespace: string;
|
||||
host: string;
|
||||
serverKind: RemoteServerKind;
|
||||
user: string;
|
||||
keyPath?: string;
|
||||
port?: number;
|
||||
canStartDirectorTunnel: boolean;
|
||||
canStartFileBrowserTunnel: boolean;
|
||||
canStartDatabaseTunnel: boolean;
|
||||
canStartPgHeroTunnel: boolean;
|
||||
tunnels: Record<string, ServerTunnelStatus>;
|
||||
tunnelBusy: Record<string, boolean>;
|
||||
onStartTunnel: (request: ServerTunnelStartRequest) => void;
|
||||
onStopTunnel: (tunnelId: string) => void;
|
||||
onOpenTunnel: (tunnel: ServerTunnelStatus) => void;
|
||||
};
|
||||
|
||||
export default function ServerTunnelControls({
|
||||
serverKey,
|
||||
namespace,
|
||||
host,
|
||||
serverKind,
|
||||
user,
|
||||
keyPath,
|
||||
port,
|
||||
canStartDirectorTunnel,
|
||||
canStartFileBrowserTunnel,
|
||||
canStartDatabaseTunnel,
|
||||
canStartPgHeroTunnel,
|
||||
tunnels,
|
||||
tunnelBusy,
|
||||
onStartTunnel,
|
||||
onStopTunnel,
|
||||
onOpenTunnel,
|
||||
}: ServerTunnelControlsProps) {
|
||||
const services: Array<{ service: TunnelService; label: string }> = [
|
||||
{ service: "director", label: "Director UI" },
|
||||
{ service: "fileBrowser", label: "File Browser" },
|
||||
{ service: "database", label: "Postgres" },
|
||||
{ service: "pgHero", label: "PgHero" },
|
||||
];
|
||||
return (
|
||||
<Box className="tunnel-controls" mt="3">
|
||||
<Flex direction="column" gap="2">
|
||||
{services.map(({ service, label }) => {
|
||||
const tunnelId = serverTunnelKey(serverKey, service);
|
||||
const active = tunnels[tunnelId];
|
||||
const busy = !!tunnelBusy[tunnelId];
|
||||
const serviceAvailable =
|
||||
service === "director"
|
||||
? canStartDirectorTunnel
|
||||
: service === "pgHero"
|
||||
? canStartPgHeroTunnel
|
||||
: service === "database"
|
||||
? canStartDatabaseTunnel
|
||||
: canStartFileBrowserTunnel;
|
||||
const openLabel = service === "database" ? "Copy URI" : `Open ${label}`;
|
||||
const disabled = busy || (!active && (!serviceAvailable || !host.trim() || !namespace.trim()));
|
||||
return (
|
||||
<Flex key={service} align="center" justify="between" gap="3" wrap="wrap" className="tunnel-row">
|
||||
<Flex direction="column" gap="1" minWidth="0">
|
||||
<Text size="2" weight="medium">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="1" color="gray">
|
||||
{active
|
||||
? `Forwarding remote port ${active.remotePort} to local port ${active.localPort}`
|
||||
: !serviceAvailable
|
||||
? service === "director"
|
||||
? "Requires started BattleGroup and healthy Director"
|
||||
: "Requires started BattleGroup"
|
||||
: !host.trim() || !namespace.trim()
|
||||
? "Requires detected server namespace and host"
|
||||
: "Tunnel stopped"}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex align="center" gap="2" wrap="wrap" justify="end">
|
||||
{active ? (
|
||||
<Button type="button" size="1" variant="surface" onClick={() => onOpenTunnel(active)}>
|
||||
{openLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="1"
|
||||
variant={active ? "soft" : "surface"}
|
||||
color={active ? "red" : undefined}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (active) {
|
||||
onStopTunnel(tunnelId);
|
||||
return;
|
||||
}
|
||||
onStartTunnel({ tunnelId, serverKind, service, host, user, keyPath, port, namespace });
|
||||
}}
|
||||
>
|
||||
{busy ? (
|
||||
<Flex align="center" gap="1">
|
||||
<BusySpinner /> Working
|
||||
</Flex>
|
||||
) : active ? (
|
||||
"Stop Tunnel"
|
||||
) : (
|
||||
"Start Tunnel"
|
||||
)}
|
||||
</Button>
|
||||
{active ? (
|
||||
<Link
|
||||
size="1"
|
||||
href="#"
|
||||
className="mono tunnel-url"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onOpenTunnel(active);
|
||||
}}
|
||||
>
|
||||
{active.url}
|
||||
</Link>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Flex, Text } from "@radix-ui/themes";
|
||||
|
||||
import type { RemoteServerRecord, RemoteServerStatus } from "../../types/server";
|
||||
import { hasBattlegroupUpdateAvailable } from "../../utils/remote-server";
|
||||
import ActionButton from "../ui/ActionButton";
|
||||
import ServerPackageCardStatus from "./ServerPackageCardStatus";
|
||||
|
||||
export type ServerUpdatePanelProps = {
|
||||
server: RemoteServerRecord;
|
||||
status?: RemoteServerStatus;
|
||||
busyLabel?: string;
|
||||
onUpdateBattlegroup: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-server Update sub-tab: package versions strip + Update Server action
|
||||
* (only enabled when the wrapper has staged a newer version than what is
|
||||
* currently running in Kubernetes).
|
||||
*/
|
||||
export default function ServerUpdatePanel({
|
||||
server: _server,
|
||||
status,
|
||||
busyLabel,
|
||||
onUpdateBattlegroup,
|
||||
}: ServerUpdatePanelProps) {
|
||||
const updateAvailable = hasBattlegroupUpdateAvailable(status?.package);
|
||||
const busy = !!busyLabel;
|
||||
return (
|
||||
<Flex direction="column" gap="4">
|
||||
<div>
|
||||
<div className="section-title">Package versions</div>
|
||||
{status?.package ? (
|
||||
<ServerPackageCardStatus guestPackage={status.package} />
|
||||
) : (
|
||||
<Text size="2" style={{ color: "var(--color-text-muted)" }}>
|
||||
No package information yet. Refresh the server to fetch versions.
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-title">Apply update</div>
|
||||
{updateAvailable ? (
|
||||
<Flex direction="column" gap="2">
|
||||
<Text size="2" style={{ color: "var(--color-text-secondary)" }}>
|
||||
A newer battlegroup version is downloaded on the host. Apply it to roll the
|
||||
running images.
|
||||
</Text>
|
||||
<div>
|
||||
<ActionButton
|
||||
onClick={onUpdateBattlegroup}
|
||||
busy={busy}
|
||||
disabled={busy || !status}
|
||||
tone="accent"
|
||||
pendingLabel="Updating"
|
||||
title="Run vendor `battlegroup update` (steamcmd + operators + maps + images)"
|
||||
>
|
||||
Update Server
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Flex>
|
||||
) : (
|
||||
<Text size="2" style={{ color: "var(--color-text-muted)" }}>
|
||||
The downloaded battlegroup version matches what is currently running. No
|
||||
update pending.
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Box, Flex, Heading, Text } from "@radix-ui/themes";
|
||||
|
||||
import type { RemoteServerRecord, RemoteServerStatus } from "../../types/server";
|
||||
import { remoteServerDefaultUser, resolveServerStatus } from "../../utils/remote-server";
|
||||
import ActionButton from "../ui/ActionButton";
|
||||
import EmptyState from "../ui/EmptyState";
|
||||
import StatusPill from "../ui/StatusPill";
|
||||
|
||||
export type ServersListPageProps = {
|
||||
servers: RemoteServerRecord[];
|
||||
statuses: Record<string, RemoteServerStatus>;
|
||||
statusErrors: Record<string, string>;
|
||||
busyMap: Record<string, string>;
|
||||
onOpenServer: (serverId: string) => void;
|
||||
onAddServer: () => void;
|
||||
};
|
||||
|
||||
export default function ServersListPage({
|
||||
servers,
|
||||
statuses,
|
||||
statusErrors,
|
||||
busyMap,
|
||||
onOpenServer,
|
||||
onAddServer,
|
||||
}: ServersListPageProps) {
|
||||
return (
|
||||
<Box className="pane page-pane">
|
||||
<Flex direction="column" gap="4" height="100%" minHeight="0" p="4">
|
||||
<Flex align="center" justify="between" gap="3">
|
||||
<Box>
|
||||
<Heading size="6" className="h-display">
|
||||
Servers
|
||||
</Heading>
|
||||
<Text as="p" size="2" mt="1" style={{ color: "var(--color-text-muted)" }}>
|
||||
Attached remote Dune battlegroups. Click a row to open its console.
|
||||
</Text>
|
||||
</Box>
|
||||
<ActionButton onClick={onAddServer} tone="accent">
|
||||
+ Add server
|
||||
</ActionButton>
|
||||
</Flex>
|
||||
<Box className="page-scroll">
|
||||
{servers.length > 0 ? (
|
||||
<div className="server-list">
|
||||
{servers.map((server, index) => {
|
||||
const status = statuses[server.id];
|
||||
const resolved = resolveServerStatus(
|
||||
statusErrors[server.id],
|
||||
status,
|
||||
!!busyMap[server.id],
|
||||
server,
|
||||
);
|
||||
const userName = server.user || remoteServerDefaultUser(server.type);
|
||||
return (
|
||||
<button
|
||||
key={server.id}
|
||||
type="button"
|
||||
className="server-row"
|
||||
data-tone={resolved.tone}
|
||||
style={{ animationDelay: `${index * 30}ms` }}
|
||||
onClick={() => onOpenServer(server.id)}
|
||||
>
|
||||
<span className="server-row-rail" />
|
||||
<span className="server-row-content">
|
||||
<span className="server-row-name">{server.name}</span>
|
||||
<span className="server-row-host">
|
||||
{userName}@{server.host}
|
||||
{server.battlegroupName ? ` · ${server.battlegroupName}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<StatusPill
|
||||
label={resolved.label}
|
||||
tone={resolved.tone}
|
||||
pulse={resolved.pulse}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No remote servers attached"
|
||||
body="Add a remote Ubuntu host that already has a Dune battlegroup running."
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import BusySpinner from "./BusySpinner";
|
||||
|
||||
export type ActionButtonTone = "default" | "accent" | "ok" | "danger";
|
||||
|
||||
export type ActionButtonProps = {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void | Promise<void>;
|
||||
busy?: boolean;
|
||||
disabled?: boolean;
|
||||
tone?: ActionButtonTone;
|
||||
pendingLabel?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type Reaction = "idle" | "success" | "error";
|
||||
|
||||
/**
|
||||
* Button that visually reacts to every action.
|
||||
*
|
||||
* - While `busy` is true, the label is replaced by an inline spinner +
|
||||
* `pendingLabel`, and the button is locked.
|
||||
* - When `busy` flips from true to false, the button briefly flashes
|
||||
* "success". If the onClick handler rejected, the parent should set
|
||||
* the `reactionKey` (via remount) and the button shake-flashes "error".
|
||||
*
|
||||
* The simplest contract — caller passes `busy`. We watch transitions to
|
||||
* decide success/error using the most recent `onClick` outcome.
|
||||
*/
|
||||
export default function ActionButton({
|
||||
children,
|
||||
onClick,
|
||||
busy = false,
|
||||
disabled = false,
|
||||
tone = "default",
|
||||
pendingLabel,
|
||||
title,
|
||||
}: ActionButtonProps) {
|
||||
const [reaction, setReaction] = useState<Reaction>("idle");
|
||||
const prevBusyRef = useRef(busy);
|
||||
const lastResultRef = useRef<"ok" | "error" | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevBusyRef.current && !busy) {
|
||||
// Busy → idle transition. Play whichever reaction the last click
|
||||
// reported. Default to success.
|
||||
const next: Reaction = lastResultRef.current === "error" ? "error" : "success";
|
||||
setReaction(next);
|
||||
const timer = window.setTimeout(() => setReaction("idle"), next === "error" ? 520 : 720);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
prevBusyRef.current = busy;
|
||||
return undefined;
|
||||
}, [busy]);
|
||||
|
||||
async function handleClick() {
|
||||
if (busy || disabled) return;
|
||||
lastResultRef.current = null;
|
||||
try {
|
||||
const result = onClick();
|
||||
if (result && typeof (result as Promise<void>).then === "function") {
|
||||
await result;
|
||||
}
|
||||
lastResultRef.current = "ok";
|
||||
} catch (err) {
|
||||
lastResultRef.current = "error";
|
||||
// Re-throw so callers can still observe failures upstream.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const state: "idle" | "pending" | "success" | "error" = busy
|
||||
? "pending"
|
||||
: reaction === "success"
|
||||
? "success"
|
||||
: reaction === "error"
|
||||
? "error"
|
||||
: "idle";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="action-btn"
|
||||
data-tone={tone}
|
||||
data-state={state}
|
||||
disabled={disabled || busy}
|
||||
onClick={handleClick}
|
||||
title={title}
|
||||
>
|
||||
{busy ? (
|
||||
<>
|
||||
<BusySpinner />
|
||||
<span>{pendingLabel ?? "Working"}</span>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type BusySpinnerProps = Record<string, never>;
|
||||
|
||||
export default function BusySpinner(_props: BusySpinnerProps) {
|
||||
return <span className="inline-spinner" aria-hidden />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Box, Heading, Text } from "@radix-ui/themes";
|
||||
|
||||
export type EmptyStateProps = {
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export default function EmptyState({ title, body }: EmptyStateProps) {
|
||||
return (
|
||||
<Box className="empty-state">
|
||||
<Heading size="4">{title}</Heading>
|
||||
<Text as="p" size="2" color="gray">
|
||||
{body}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Text } from "@radix-ui/themes";
|
||||
|
||||
export type FieldProps = {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function Field({ label, children }: FieldProps) {
|
||||
return (
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="medium" mb="1" className="field-label">
|
||||
{label}
|
||||
</Text>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { StatusTone } from "./StatusPill";
|
||||
|
||||
export type MetricTone = StatusTone | "muted" | "default";
|
||||
|
||||
export type MetricProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: MetricTone;
|
||||
};
|
||||
|
||||
export default function Metric({ label, value, tone = "default" }: MetricProps) {
|
||||
return (
|
||||
<div className="metric">
|
||||
<div className="metric-label">{label}</div>
|
||||
<div className="metric-value" data-tone={tone === "default" ? undefined : tone}>
|
||||
{value || "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export type StatusTone = "ok" | "warn" | "err" | "gray";
|
||||
|
||||
export type StatusPillProps = {
|
||||
label: string;
|
||||
tone: StatusTone;
|
||||
pulse?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Status pill with optional pulsing dot. Used as the hero indicator on
|
||||
* server cards. Tone follows the same vocabulary as the rest of the app
|
||||
* (`ok` / `warn` / `err` / `gray`).
|
||||
*/
|
||||
export default function StatusPill({ label, tone, pulse = false }: StatusPillProps) {
|
||||
return (
|
||||
<span className="status-pill" data-tone={tone}>
|
||||
<span className="status-dot" data-pulse={pulse ? "true" : "false"} aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user