docs(reference): import Dune: Awakening server-manager references
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 39s
CI / integration (push) Successful in 22s

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:
Vantz Stockwell
2026-06-11 21:08:05 -04:00
parent 0715492ddf
commit 651a35d4be
1334 changed files with 238971 additions and 0 deletions

View File

@@ -0,0 +1,193 @@
import { Box, Flex, Theme } from "@radix-ui/themes";
import AppErrorBoundary from "./components/AppErrorBoundary";
import Header from "./components/layout/Header";
import LogWindow from "./components/logs/LogWindow";
import RemoteAttachDialog from "./components/dialogs/RemoteAttachDialog";
import RemoveRemoteServerDialog from "./components/dialogs/RemoveRemoteServerDialog";
import UpdateDialog from "./components/dialogs/UpdateDialog";
import ServerDetailPage from "./components/servers/ServerDetailPage";
import ServersListPage from "./components/servers/ServersListPage";
import { useAppUpdates } from "./hooks/useAppUpdates";
import { useComponentActions } from "./hooks/useComponentActions";
import { useOperationLogs } from "./hooks/useOperationLogs";
import { useRemoteServerStatus } from "./hooks/useRemoteServerStatus";
import { useRemoteServers } from "./hooks/useRemoteServers";
import { useServerTunnels } from "./hooks/useServerTunnels";
import { useActivePage } from "./hooks/useActivePage";
import { log } from "./utils/logging";
export function App() {
const {
logLevelFilter,
setLogLevelFilter,
logPanelCollapsed,
setLogPanelCollapsed,
scopeToActiveServer,
setScopeToActiveServer,
appendLogRow,
clearLogRows,
renderedLogRows,
} = useOperationLogs();
const remoteServersHook = useRemoteServers({ appendLogRow });
const tunnels = useServerTunnels({ appendLogRow });
const status = useRemoteServerStatus({
appendLogRow,
setRemoteServers: remoteServersHook.setRemoteServers,
});
const componentActions = useComponentActions({
appendLogRow,
detectRemoteServerDetails: status.detectRemoteServerDetails,
setRemoteServerComponents: status.setRemoteServerComponents,
setRemoteComponentLogs: status.setRemoteComponentLogs,
setRemoteComponentLogBusy: status.setRemoteComponentLogBusy,
setRemoteComponentRestartBusy: status.setRemoteComponentRestartBusy,
});
const updates = useAppUpdates({ appendLogRow });
remoteServersHook.bindRefreshRemoteServerStatus(status.refreshRemoteServerStatus);
remoteServersHook.bindRemoteServerBusy(status.remoteServerBusy);
remoteServersHook.bindClearStatusForServer(status.clearStatusForServer);
remoteServersHook.bindStopTunnelsForServer(tunnels.stopTunnelsForServer);
const { activePage, openServer, openServersList, setSub } = useActivePage({
remoteServers: remoteServersHook.remoteServers,
});
const scopeServerId = activePage.kind === "server" ? activePage.serverId : undefined;
const visibleLogRows =
scopeServerId && scopeToActiveServer
? renderedLogRows.filter((row) => !row.serverId || row.serverId === scopeServerId)
: renderedLogRows;
const activeServer =
activePage.kind === "server"
? remoteServersHook.remoteServers.find((server) => server.id === activePage.serverId)
: undefined;
return (
<Theme
appearance="dark"
accentColor="bronze"
grayColor="sand"
radius="medium"
scaling="100%"
panelBackground="solid"
>
<Flex direction="column" height="100vh" className="app-shell">
<Header
activePage={activePage}
servers={remoteServersHook.remoteServers}
statuses={status.remoteServerStatuses}
statusErrors={status.remoteServerStatusErrors}
busyMap={status.remoteServerBusy}
onOpenServersList={openServersList}
onOpenServer={openServer}
onAddServer={() => remoteServersHook.setRemoteAttachOpen(true)}
updateStatus={updates.updateStatus}
update={updates.availableUpdate}
updateProgress={updates.updateProgress}
onCheckUpdate={updates.checkForAppUpdate}
onOpenUpdate={() => updates.setUpdateDialogOpen(true)}
/>
<Flex className="content-shell" gap="3" p="4" pt="0" minHeight="0">
<Box className="main-pane">
<AppErrorBoundary onError={(message) => appendLogRow(log.error("ui", message))}>
{activePage.kind === "servers" || !activeServer ? (
<ServersListPage
servers={remoteServersHook.remoteServers}
statuses={status.remoteServerStatuses}
statusErrors={status.remoteServerStatusErrors}
busyMap={status.remoteServerBusy}
onOpenServer={openServer}
onAddServer={() => remoteServersHook.setRemoteAttachOpen(true)}
/>
) : (
<ServerDetailPage
server={activeServer}
sub={activePage.sub}
onSubChange={setSub}
status={status.remoteServerStatuses[activeServer.id]}
statusError={status.remoteServerStatusErrors[activeServer.id]}
busyLabel={status.remoteServerBusy[activeServer.id]}
components={status.remoteServerComponents[activeServer.id] ?? []}
componentLogs={status.remoteComponentLogs}
componentLogBusy={status.remoteComponentLogBusy}
componentRestartBusy={status.remoteComponentRestartBusy}
tunnels={tunnels.serverTunnels}
tunnelBusy={tunnels.serverTunnelBusy}
onRefresh={() => status.refreshRemoteServerStatus(activeServer)}
onRemove={() => remoteServersHook.setRemoteServerToRemove(activeServer)}
onStartBattlegroup={() => status.runRemoteBattlegroupAction(activeServer, "start")}
onStopBattlegroup={() => status.runRemoteBattlegroupAction(activeServer, "stop")}
onRestartBattlegroup={() =>
status.runRemoteBattlegroupAction(activeServer, "restart")
}
onUpdateBattlegroup={() => status.runRemoteBattlegroupAction(activeServer, "update")}
onStartTunnel={tunnels.startServerTunnel}
onStartCustomTunnel={tunnels.startCustomTunnel}
onStopTunnel={tunnels.stopServerTunnel}
onOpenTunnel={tunnels.openServerTunnel}
onRefreshComponentLog={(component) =>
componentActions.refreshRemoteComponentLog(activeServer, component)
}
onRestartComponent={(component) =>
componentActions.restartRemoteComponent(activeServer, component)
}
appendLogRow={appendLogRow}
/>
)}
</AppErrorBoundary>
</Box>
<LogWindow
rows={visibleLogRows}
level={logLevelFilter}
collapsed={logPanelCollapsed}
scopedToServer={scopeToActiveServer}
canScopeToServer={!!scopeServerId}
onLevelChange={setLogLevelFilter}
onClear={clearLogRows}
onToggleCollapsed={() => setLogPanelCollapsed((collapsed) => !collapsed)}
onToggleScope={setScopeToActiveServer}
/>
</Flex>
<RemoteAttachDialog
open={remoteServersHook.remoteAttachOpen}
form={remoteServersHook.remoteAttachForm}
running={remoteServersHook.remoteAttachRunning}
errorMessage={remoteServersHook.remoteAttachError}
preflight={remoteServersHook.remoteAttachPreflight}
onOpenChange={(open) => {
remoteServersHook.setRemoteAttachOpen(open);
if (!open) remoteServersHook.setRemoteAttachError(null);
}}
onChange={remoteServersHook.setRemoteAttachForm}
onAttach={remoteServersHook.addRemoteServer}
/>
<RemoveRemoteServerDialog
server={remoteServersHook.remoteServerToRemove}
onOpenChange={(open) => {
if (!open) remoteServersHook.setRemoteServerToRemove(null);
}}
onRemove={(server) => {
remoteServersHook.removeRemoteServer(server);
remoteServersHook.setRemoteServerToRemove(null);
}}
/>
<UpdateDialog
open={updates.updateDialogOpen}
update={updates.availableUpdate}
status={updates.updateStatus}
progress={updates.updateProgress}
onOpenChange={updates.setUpdateDialogOpen}
onInstall={updates.installAppUpdate}
/>
</Flex>
</Theme>
);
}

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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&apos;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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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…"
/>
);
}

View File

@@ -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&apos;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);
}

View File

@@ -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>
);
}

View File

@@ -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&apos;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>
);
}

View File

@@ -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..."
/>
);
}

View File

@@ -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 " ";
}
}

View File

@@ -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>
);
}

View File

@@ -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}`);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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)}</>;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,5 @@
export type BusySpinnerProps = Record<string, never>;
export default function BusySpinner(_props: BusySpinnerProps) {
return <span className="inline-spinner" aria-hidden />;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,69 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { RemoteServerRecord } from "../types/server";
import type { ActivePage, ServerSubPage } from "../types/ui";
import { readActivePage, writeActivePage } from "../services/storage";
export type UseActivePageOptions = {
remoteServers: RemoteServerRecord[];
};
/**
* Tracks the active top-level page and persists the per-server sub-tab
* across launches. Falls back to the Servers list when the persisted
* server is no longer attached.
*/
export function useActivePage({ remoteServers }: UseActivePageOptions) {
const attachedIds = useMemo(() => remoteServers.map((s) => s.id), [remoteServers]);
const idsKey = attachedIds.join("|");
const initializedRef = useRef(false);
const [activePage, setActivePageState] = useState<ActivePage>(() => readActivePage(attachedIds));
// Re-validate whenever the attached server list changes.
useEffect(() => {
if (!initializedRef.current) {
initializedRef.current = true;
return;
}
setActivePageState((current) => {
if (current.kind === "server" && !attachedIds.includes(current.serverId)) {
const fallback: ActivePage = { kind: "servers" };
writeActivePage(fallback);
return fallback;
}
return current;
});
// idsKey covers attached id set changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [idsKey]);
const setActivePage = useCallback((next: ActivePage) => {
writeActivePage(next);
setActivePageState(next);
}, []);
const openServer = useCallback(
(serverId: string, sub: ServerSubPage = "dashboard") => {
setActivePage({ kind: "server", serverId, sub });
},
[setActivePage],
);
const openServersList = useCallback(() => {
setActivePage({ kind: "servers" });
}, [setActivePage]);
const setSub = useCallback(
(sub: ServerSubPage) => {
setActivePageState((current) => {
if (current.kind !== "server") return current;
const next: ActivePage = { ...current, sub };
writeActivePage(next);
return next;
});
},
[],
);
return { activePage, setActivePage, openServer, openServersList, setSub };
}

View File

@@ -0,0 +1,117 @@
import { useEffect, useRef, useState } from "react";
import {
checkForUpdate,
downloadAndInstallUpdate,
type DownloadEvent,
type Update,
} from "../services/updater";
import { relaunch } from "../services/tauri";
import type { LogRow } from "../types/log";
import type { UpdateStatus } from "../types/update";
import { errorMessage } from "../utils/errors";
import { formatBytes } from "../utils/formatting";
import { log } from "../utils/logging";
const startupUpdateChecksEnabled = import.meta.env.VITE_ENABLE_STARTUP_UPDATE_CHECK === "true";
type UseAppUpdatesArgs = {
appendLogRow: (row: LogRow) => void;
};
export function useAppUpdates({ appendLogRow }: UseAppUpdatesArgs) {
const [availableUpdate, setAvailableUpdate] = useState<Update | null>(null);
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>("idle");
const [updateDialogOpen, setUpdateDialogOpen] = useState(false);
const [updateProgress, setUpdateProgress] = useState<string | null>(null);
const updateCheckInFlight = useRef(false);
const checkForAppUpdate = async () => {
if (updateCheckInFlight.current) return;
updateCheckInFlight.current = true;
setUpdateStatus("checking");
setUpdateProgress(null);
appendLogRow(log.info("updates", "Checking for app updates."));
try {
const nextUpdate = await checkForUpdate(15_000);
setAvailableUpdate(nextUpdate);
if (nextUpdate) {
setUpdateStatus("available");
appendLogRow(
log.info(
"updates",
`Update ${nextUpdate.version} is available; current version is ${nextUpdate.currentVersion}.`,
),
);
setUpdateDialogOpen(true);
} else {
setUpdateStatus("current");
appendLogRow(log.info("updates", "The app is up to date."));
}
} catch (err) {
setUpdateStatus("failed");
appendLogRow(log.warn("updates", `Update check failed: ${errorMessage(err)}`));
} finally {
updateCheckInFlight.current = false;
}
};
const installAppUpdate = async () => {
if (!availableUpdate) return;
let downloaded = 0;
let total: number | null = null;
setUpdateStatus("installing");
setUpdateProgress("Preparing download...");
appendLogRow(log.info("updates", `Installing update ${availableUpdate.version}.`));
try {
await downloadAndInstallUpdate(
availableUpdate,
(event: DownloadEvent) => {
if (event.event === "Started") {
total = event.data.contentLength ?? null;
downloaded = 0;
setUpdateProgress(total ? `Downloading 0 of ${formatBytes(total)}` : "Downloading update...");
}
if (event.event === "Progress") {
downloaded += event.data.chunkLength;
setUpdateProgress(
total
? `Downloading ${formatBytes(downloaded)} of ${formatBytes(total)}`
: `Downloading ${formatBytes(downloaded)}`,
);
}
if (event.event === "Finished") {
setUpdateProgress("Installing update...");
}
},
120_000,
);
setUpdateStatus("relaunching");
setUpdateProgress("Relaunching...");
appendLogRow(log.info("updates", "Update installed; relaunching the app."));
await relaunch();
} catch (err) {
setUpdateStatus("failed");
setUpdateProgress(null);
appendLogRow(log.error("updates", errorMessage(err)));
}
};
useEffect(() => {
if (!startupUpdateChecksEnabled) {
appendLogRow(log.debug("updates", "Automatic update checks are disabled for this local build."));
return;
}
void checkForAppUpdate();
}, []);
return {
availableUpdate,
updateStatus,
updateDialogOpen,
setUpdateDialogOpen,
updateProgress,
checkForAppUpdate,
installAppUpdate,
};
}

View File

@@ -0,0 +1,107 @@
import {
getRemoteServerComponents,
remoteComponentLogTail,
restartRemoteComponent as restartRemoteComponentCmd,
} from "../services/tauri";
import type { LogRow } from "../types/log";
import type { RemoteServerComponent, RemoteServerRecord } from "../types/server";
import { errorMessage } from "../utils/errors";
import { log, sanitizeLogMessage } from "../utils/logging";
import {
componentLogStateKey,
isCriticalRestartComponent,
omitKey,
remoteServerActionRequest,
remoteServerDefaultUser,
} from "../utils/remote-server";
type UseComponentActionsArgs = {
appendLogRow: (row: LogRow) => void;
detectRemoteServerDetails: (server: RemoteServerRecord) => Promise<RemoteServerRecord>;
setRemoteServerComponents: React.Dispatch<
React.SetStateAction<Record<string, RemoteServerComponent[]>>
>;
setRemoteComponentLogs: React.Dispatch<React.SetStateAction<Record<string, string>>>;
setRemoteComponentLogBusy: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
setRemoteComponentRestartBusy: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
};
export function useComponentActions({
appendLogRow,
detectRemoteServerDetails,
setRemoteServerComponents,
setRemoteComponentLogs,
setRemoteComponentLogBusy,
setRemoteComponentRestartBusy,
}: UseComponentActionsArgs) {
const refreshRemoteComponentLog = async (
server: RemoteServerRecord,
component: RemoteServerComponent,
) => {
const key = componentLogStateKey(server.id, component);
setRemoteComponentLogBusy((busy) => ({ ...busy, [key]: true }));
appendLogRow(log.info("remote.logs", `Refreshing ${component.name} logs.`, server.id));
try {
const liveServer = server.namespace ? server : await detectRemoteServerDetails(server);
const result = await remoteComponentLogTail({
serverType: liveServer.type,
host: liveServer.host,
user: liveServer.user || remoteServerDefaultUser(liveServer.type),
keyPath: liveServer.keyPath || undefined,
namespace: liveServer.namespace,
component: component.logKey,
tail: 160,
});
setRemoteComponentLogs((logs) => ({
...logs,
[key]: sanitizeLogMessage(result.output || "No log output."),
}));
} catch (err) {
const message = errorMessage(err);
setRemoteComponentLogs((logs) => ({ ...logs, [key]: sanitizeLogMessage(message) }));
appendLogRow(log.warn("remote.logs", message, server.id));
} finally {
setRemoteComponentLogBusy((busy) => omitKey(busy, key));
}
};
const restartRemoteComponent = async (
server: RemoteServerRecord,
component: RemoteServerComponent,
) => {
if (isCriticalRestartComponent(component)) {
const confirmed = window.confirm(
`Restart ${component.name}? This can temporarily interrupt persistence, messaging, or active players.`,
);
if (!confirmed) return;
}
const key = componentLogStateKey(server.id, component);
setRemoteComponentRestartBusy((busy) => ({ ...busy, [key]: true }));
appendLogRow(log.warn("remote.restart", `Restarting ${component.name}.`, server.id));
try {
const liveServer = server.namespace ? server : await detectRemoteServerDetails(server);
const result = await restartRemoteComponentCmd({
serverType: liveServer.type,
host: liveServer.host,
user: liveServer.user || remoteServerDefaultUser(liveServer.type),
keyPath: liveServer.keyPath || undefined,
namespace: liveServer.namespace,
component: component.logKey,
});
setRemoteComponentLogs((logs) => ({
...logs,
[key]: sanitizeLogMessage(result.output || `${component.name} restart requested.`),
}));
const components = await getRemoteServerComponents(remoteServerActionRequest(liveServer));
setRemoteServerComponents((current) => ({ ...current, [liveServer.id]: components }));
} catch (err) {
const message = errorMessage(err);
setRemoteComponentLogs((logs) => ({ ...logs, [key]: sanitizeLogMessage(message) }));
appendLogRow(log.error("remote.restart", message, server.id));
} finally {
setRemoteComponentRestartBusy((busy) => omitKey(busy, key));
}
};
return { refreshRemoteComponentLog, restartRemoteComponent };
}

View File

@@ -0,0 +1,75 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { listenToEvent, recordOperationLog } from "../services/tauri";
import { readLogSidebar, writeLogSidebar } from "../services/storage";
import type { LogLevelFilter, LogRow, OperationLogPayload } from "../types/log";
import {
filterLogRows,
limitLogRows,
logEntry,
maxRenderedLogRows,
} from "../utils/logging";
export function useOperationLogs() {
const persisted = useMemo(readLogSidebar, []);
const [logRows, setLogRows] = useState<LogRow[]>([]);
const [logLevelFilter, setLogLevelFilter] = useState<LogLevelFilter>("info");
const [logPanelCollapsed, setLogPanelCollapsedState] = useState<boolean>(persisted.collapsed ?? false);
const [scopeToActiveServer, setScopeToActiveServerState] = useState<boolean>(
persisted.scopeToActiveServer ?? true,
);
const setLogPanelCollapsed = (next: boolean | ((current: boolean) => boolean)) => {
setLogPanelCollapsedState((current) => {
const resolved = typeof next === "function" ? next(current) : next;
writeLogSidebar({ collapsed: resolved, scopeToActiveServer });
return resolved;
});
};
const setScopeToActiveServer = (next: boolean) => {
setScopeToActiveServerState(next);
writeLogSidebar({ collapsed: logPanelCollapsed, scopeToActiveServer: next });
};
// Memoized so downstream hooks that take appendLogRow as a dep (e.g.
// useManagementStatus) don't see a new identity on every render — that was
// causing a refresh-log-rerender feedback loop that spammed the pane.
const appendLogRow = useCallback((row: LogRow) => {
setLogRows((rows) => limitLogRows([...rows, row]));
void recordOperationLog(row.level, row.scope, row.message).catch(() => undefined);
}, []);
const clearLogRows = useCallback(() => {
setLogRows([]);
}, []);
useEffect(() => {
const unlisten = listenToEvent<OperationLogPayload>("operation-log", (payload) => {
setLogRows((rows) =>
limitLogRows([
...rows,
logEntry(payload.level, payload.scope, payload.message, payload.serverId),
]),
);
});
return () => {
void unlisten.then((dispose) => dispose());
};
}, []);
const renderedLogRows = filterLogRows(logRows, logLevelFilter).slice(-maxRenderedLogRows);
return {
logRows,
logLevelFilter,
setLogLevelFilter,
logPanelCollapsed,
setLogPanelCollapsed,
scopeToActiveServer,
setScopeToActiveServer,
appendLogRow,
clearLogRows,
renderedLogRows,
};
}

View File

@@ -0,0 +1,188 @@
import { useState } from "react";
import {
detectRemoteUbuntuServers,
getRemoteServerComponents,
getRemoteServerStatus,
restartRemoteBattlegroup,
startRemoteBattlegroup,
stopRemoteBattlegroup,
updateRemoteBattlegroup,
} from "../services/tauri";
import { persistRemoteServers, upsertRemoteServer } from "../services/storage";
import type { LogRow } from "../types/log";
import type {
RemoteBattlegroupStatus,
RemoteServerComponent,
RemoteServerRecord,
RemoteServerStatus,
} from "../types/server";
import { errorMessage } from "../utils/errors";
import { log } from "../utils/logging";
import {
omitKey,
omitPrefix,
remoteServerActionRequest,
remoteServerDefaultUser,
} from "../utils/remote-server";
type UseRemoteServerStatusArgs = {
appendLogRow: (row: LogRow) => void;
setRemoteServers: React.Dispatch<React.SetStateAction<RemoteServerRecord[]>>;
};
export function useRemoteServerStatus({ appendLogRow, setRemoteServers }: UseRemoteServerStatusArgs) {
const [remoteServerStatuses, setRemoteServerStatuses] = useState<Record<string, RemoteServerStatus>>({});
const [remoteServerComponents, setRemoteServerComponents] = useState<Record<string, RemoteServerComponent[]>>({});
const [remoteServerStatusErrors, setRemoteServerStatusErrors] = useState<Record<string, string>>({});
const [remoteServerBusy, setRemoteServerBusy] = useState<Record<string, string>>({});
const [remoteComponentLogs, setRemoteComponentLogs] = useState<Record<string, string>>({});
const [remoteComponentLogBusy, setRemoteComponentLogBusy] = useState<Record<string, boolean>>({});
const [remoteComponentRestartBusy, setRemoteComponentRestartBusy] = useState<Record<string, boolean>>({});
const detectRemoteServerDetails = async (server: RemoteServerRecord): Promise<RemoteServerRecord> => {
const detected = await detectRemoteUbuntuServers({
host: server.host,
keyPath: server.keyPath,
serverType: "ubuntu",
user: server.user || remoteServerDefaultUser(server.type),
port: server.port,
});
if (detected.length === 0) {
throw new Error("No Dune battlegroups were detected on the remote server.");
}
return detected.find((candidate) => candidate.battlegroupName === server.battlegroupName) ?? detected[0];
};
const refreshRemoteServerStatus = async (server: RemoteServerRecord) => {
if (!server.host || !server.keyPath) return;
setRemoteServerBusy((busy) => ({ ...busy, [server.id]: "Retrieving server information" }));
setRemoteServerStatuses((statuses) => omitKey(statuses, server.id));
setRemoteServerComponents((components) => omitKey(components, server.id));
setRemoteComponentLogs((logs) => omitPrefix(logs, `${server.id}:`));
setRemoteComponentLogBusy((busy) => omitPrefix(busy, `${server.id}:`));
setRemoteComponentRestartBusy((busy) => omitPrefix(busy, `${server.id}:`));
setRemoteServerStatusErrors((errors) => omitKey(errors, server.id));
try {
const liveServer = await detectRemoteServerDetails(server);
setRemoteServers((servers) => persistRemoteServers(upsertRemoteServer(servers, liveServer)));
const status = await getRemoteServerStatus(remoteServerActionRequest(liveServer));
const components = await getRemoteServerComponents(remoteServerActionRequest(liveServer));
setRemoteServerStatuses((statuses) => ({ ...statuses, [liveServer.id]: status }));
setRemoteServerComponents((current) => ({ ...current, [liveServer.id]: components }));
setRemoteServerStatusErrors((errors) => omitKey(errors, liveServer.id));
setRemoteServers((servers) =>
persistRemoteServers(
servers.map((candidate) =>
candidate.id === liveServer.id
? { ...liveServer, phase: status.battlegroup.phase || liveServer.phase }
: candidate,
),
),
);
appendLogRow(
log.info(
"remote.status",
buildStatusLogLine(liveServer.name, status.battlegroup),
liveServer.id,
),
);
} catch (err) {
const message = errorMessage(err);
setRemoteServerStatuses((statuses) => omitKey(statuses, server.id));
setRemoteServerComponents((components) => omitKey(components, server.id));
setRemoteComponentLogs((logs) => omitPrefix(logs, `${server.id}:`));
setRemoteServerStatusErrors((errors) => ({ ...errors, [server.id]: message }));
appendLogRow(log.warn("remote.status", message, server.id));
} finally {
setRemoteServerBusy((busy) => omitKey(busy, server.id));
}
};
const runRemoteBattlegroupAction = async (
server: RemoteServerRecord,
action: "start" | "stop" | "restart" | "update",
) => {
const verbs: Record<typeof action, [busy: string, log: string]> = {
start: ["Starting battlegroup", "Starting"],
stop: ["Stopping battlegroup", "Stopping"],
restart: ["Restarting battlegroup", "Restarting"],
update: ["Updating battlegroup", "Updating"],
};
const [busyText, verb] = verbs[action];
setRemoteServerBusy((busy) => ({ ...busy, [server.id]: busyText }));
appendLogRow(log.info("bg", `${verb} remote battlegroup.`, server.id));
try {
const liveServer =
server.namespace && server.battlegroupName ? server : await detectRemoteServerDetails(server);
setRemoteServers((servers) => persistRemoteServers(upsertRemoteServer(servers, liveServer)));
const request = remoteServerActionRequest(liveServer);
const status =
action === "start"
? await startRemoteBattlegroup(request)
: action === "stop"
? await stopRemoteBattlegroup(request)
: action === "restart"
? await restartRemoteBattlegroup(request)
: await updateRemoteBattlegroup(request);
const components = await getRemoteServerComponents(request);
setRemoteServerStatuses((statuses) => ({ ...statuses, [liveServer.id]: status }));
setRemoteServerComponents((current) => ({ ...current, [liveServer.id]: components }));
setRemoteServerStatusErrors((errors) => omitKey(errors, liveServer.id));
setRemoteServers((servers) =>
persistRemoteServers(
servers.map((candidate) =>
candidate.id === liveServer.id
? { ...liveServer, phase: status.battlegroup.phase || liveServer.phase }
: candidate,
),
),
);
} catch (err) {
const message = errorMessage(err);
setRemoteServerStatusErrors((errors) => ({ ...errors, [server.id]: message }));
appendLogRow(log.error("bg", message, server.id));
} finally {
setRemoteServerBusy((busy) => omitKey(busy, server.id));
}
};
const clearStatusForServer = (serverId: string) => {
setRemoteServerStatuses((statuses) => omitKey(statuses, serverId));
setRemoteServerComponents((components) => omitKey(components, serverId));
setRemoteServerStatusErrors((errors) => omitKey(errors, serverId));
setRemoteComponentLogs((logs) => omitPrefix(logs, `${serverId}:`));
setRemoteComponentLogBusy((busy) => omitPrefix(busy, `${serverId}:`));
setRemoteComponentRestartBusy((busy) => omitPrefix(busy, `${serverId}:`));
};
return {
remoteServerStatuses,
remoteServerComponents,
setRemoteServerComponents,
remoteServerStatusErrors,
remoteServerBusy,
remoteComponentLogs,
setRemoteComponentLogs,
remoteComponentLogBusy,
setRemoteComponentLogBusy,
remoteComponentRestartBusy,
setRemoteComponentRestartBusy,
detectRemoteServerDetails,
refreshRemoteServerStatus,
runRemoteBattlegroupAction,
clearStatusForServer,
};
}
function buildStatusLogLine(name: string, bg: RemoteBattlegroupStatus): string {
const parts: string[] = [
`${name}: ${bg.phase || "unknown"}`,
`server group ${bg.serverGroupPhase || "unknown"}`,
];
if (bg.databasePhase) parts.push(`DB ${bg.databasePhase}`);
parts.push(`Director ${bg.directorPhase || "unknown"}`);
if (bg.uptime) parts.push(`up ${bg.uptime}`);
if (bg.stop) parts.push("STOP");
return parts.join(", ") + ".";
}

View File

@@ -0,0 +1,148 @@
import { useEffect, useRef, useState } from "react";
import { checkRemoteSudo, detectRemoteUbuntuServers, type PreflightCheck } from "../services/tauri";
import {
mergeRemoteServers,
persistRemoteServers,
readRemoteServers,
} from "../services/storage";
import type { LogRow } from "../types/log";
import type { RemoteServerRecord } from "../types/server";
import type { RemoteAttachForm } from "../types/ui";
import { errorMessage } from "../utils/errors";
import { log } from "../utils/logging";
type UseRemoteServersArgs = {
appendLogRow: (row: LogRow) => void;
};
export function useRemoteServers({ appendLogRow }: UseRemoteServersArgs) {
const [remoteServers, setRemoteServers] = useState<RemoteServerRecord[]>([]);
const [remoteAttachOpen, setRemoteAttachOpen] = useState(false);
const [remoteAttachRunning, setRemoteAttachRunning] = useState(false);
const [remoteAttachError, setRemoteAttachError] = useState<string | null>(null);
const [remoteAttachPreflight, setRemoteAttachPreflight] = useState<PreflightCheck | null>(null);
const [remoteAttachForm, setRemoteAttachForm] = useState<RemoteAttachForm>({
host: "",
user: "dune",
keyPath: "",
port: 22,
});
const [remoteServerToRemove, setRemoteServerToRemove] = useState<RemoteServerRecord | null>(null);
const refreshRef = useRef<(server: RemoteServerRecord) => Promise<void> | void>(() => undefined);
const remoteServerBusyRef = useRef<Record<string, string>>({});
const clearStatusRef = useRef<(serverId: string) => void>(() => undefined);
const stopTunnelsRef = useRef<(serverId: string) => void>(() => undefined);
const bindRefreshRemoteServerStatus = (fn: (server: RemoteServerRecord) => Promise<void> | void) => {
refreshRef.current = fn;
};
const bindRemoteServerBusy = (busy: Record<string, string>) => {
remoteServerBusyRef.current = busy;
};
const bindClearStatusForServer = (fn: (serverId: string) => void) => {
clearStatusRef.current = fn;
};
const bindStopTunnelsForServer = (fn: (serverId: string) => void) => {
stopTunnelsRef.current = fn;
};
const addRemoteServer = async () => {
const host = remoteAttachForm.host.trim();
const keyPath = remoteAttachForm.keyPath.trim();
const user = remoteAttachForm.user.trim() || "dune";
const port = remoteAttachForm.port > 0 ? remoteAttachForm.port : 22;
if (!host || !keyPath) return;
setRemoteAttachRunning(true);
setRemoteAttachError(null);
setRemoteAttachPreflight(null);
appendLogRow(log.info("remote.attach", `Preflight check for ${user}@${host}:${port}.`));
try {
const preflight = await checkRemoteSudo({ host, user, keyPath, port });
setRemoteAttachPreflight(preflight);
if (!preflight.sshOk) {
throw new Error("SSH connection or key authentication failed.");
}
if (!preflight.sudoToDuneOk) {
throw new Error(
`${user} cannot sudo to dune without a password. ` +
`Run on the host as root: echo \"${user} ALL=(dune) NOPASSWD: ALL\" | sudo tee /etc/sudoers.d/${user}`,
);
}
if (!preflight.duneNopasswdOk) {
throw new Error(
"dune needs passwordless sudo. Run on the host as root: " +
`echo "dune ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/dune`,
);
}
appendLogRow(log.info("remote.attach", "Preflight passed. Detecting remote battlegroups."));
const detected = await detectRemoteUbuntuServers({
host,
keyPath,
serverType: "ubuntu",
user,
port,
});
if (detected.length === 0) {
throw new Error("No Dune battlegroups were detected on the remote server.");
}
const nextServers = mergeRemoteServers(remoteServers, detected);
setRemoteServers(persistRemoteServers(nextServers));
setRemoteAttachOpen(false);
setRemoteAttachForm({ host: "", user: "dune", keyPath: "", port: 22 });
setRemoteAttachPreflight(null);
appendLogRow(log.info("remote.attach", `Added ${detected.length} remote battlegroup profile(s).`));
for (const server of detected) {
void refreshRef.current(server);
}
} catch (err) {
const message = errorMessage(err);
setRemoteAttachError(message);
appendLogRow(log.error("remote.attach", message));
} finally {
setRemoteAttachRunning(false);
}
};
const removeRemoteServer = (server: RemoteServerRecord) => {
stopTunnelsRef.current(server.id);
setRemoteServers((servers) =>
persistRemoteServers(servers.filter((candidate) => candidate.id !== server.id)),
);
clearStatusRef.current(server.id);
appendLogRow(log.info("remote.attach", `Forgot remote server ${server.name}.`));
};
useEffect(() => {
setRemoteServers(readRemoteServers());
}, []);
useEffect(() => {
for (const server of remoteServers) {
if (!server.host || !server.keyPath || remoteServerBusyRef.current[server.id]) continue;
void refreshRef.current(server);
}
}, [remoteServers.map((server) => server.id).join("|")]);
return {
remoteServers,
setRemoteServers,
remoteAttachOpen,
setRemoteAttachOpen,
remoteAttachRunning,
remoteAttachForm,
setRemoteAttachForm,
remoteAttachError,
setRemoteAttachError,
remoteAttachPreflight,
remoteServerToRemove,
setRemoteServerToRemove,
addRemoteServer,
removeRemoteServer,
bindRefreshRemoteServerStatus,
bindRemoteServerBusy,
bindClearStatusForServer,
bindStopTunnelsForServer,
};
}

View File

@@ -0,0 +1,109 @@
import { useEffect, useState } from "react";
import {
openExternal,
serverTunnelStatus,
startCustomTunnel as startCustomTunnelCmd,
startServerTunnel as startServerTunnelCmd,
stopAllTunnels,
stopServerTunnel as stopServerTunnelCmd,
} from "../services/tauri";
import type { LogRow } from "../types/log";
import type { CustomTunnelStartRequest, ServerTunnelStartRequest, ServerTunnelStatus } from "../types/tunnel";
import { copyTextToClipboard } from "../utils/clipboard";
import { errorMessage } from "../utils/errors";
import { tunnelServiceLabel } from "../utils/formatting";
import { log } from "../utils/logging";
import { omitKey } from "../utils/remote-server";
type UseServerTunnelsArgs = {
appendLogRow: (row: LogRow) => void;
};
export function useServerTunnels({ appendLogRow }: UseServerTunnelsArgs) {
const [serverTunnels, setServerTunnels] = useState<Record<string, ServerTunnelStatus>>({});
const [serverTunnelBusy, setServerTunnelBusy] = useState<Record<string, boolean>>({});
const startServerTunnel = async (request: ServerTunnelStartRequest) => {
setServerTunnelBusy((busy) => ({ ...busy, [request.tunnelId]: true }));
appendLogRow(log.info("tunnel", `Starting ${tunnelServiceLabel(request.service)} tunnel.`));
try {
const status = await startServerTunnelCmd(request);
setServerTunnels((tunnels) => ({ ...tunnels, [status.tunnelId]: status }));
appendLogRow(log.info("tunnel", `${tunnelServiceLabel(request.service)} tunnel is ready at ${status.url}`));
} catch (err) {
appendLogRow(log.error("tunnel", errorMessage(err)));
} finally {
setServerTunnelBusy((busy) => omitKey(busy, request.tunnelId));
}
};
const openServerTunnel = async (tunnel: ServerTunnelStatus) => {
try {
const status = await serverTunnelStatus(tunnel.tunnelId);
if (!status) {
setServerTunnels((tunnels) => omitKey(tunnels, tunnel.tunnelId));
appendLogRow(log.warn("tunnel", "The SSH tunnel is no longer running."));
return;
}
setServerTunnels((tunnels) => ({ ...tunnels, [status.tunnelId]: status }));
if (status.service === "database" || status.url.startsWith("postgresql://")) {
await copyTextToClipboard(status.url);
appendLogRow(log.info("tunnel", `Copied connection URI ${status.url}`));
return;
}
await openExternal(status.url);
} catch (err) {
appendLogRow(log.error("tunnel", errorMessage(err)));
}
};
const startCustomTunnel = async (request: CustomTunnelStartRequest, name: string) => {
setServerTunnelBusy((busy) => ({ ...busy, [request.tunnelId]: true }));
appendLogRow(log.info("tunnel", `Starting ${name} tunnel.`));
try {
const status = await startCustomTunnelCmd(request);
setServerTunnels((tunnels) => ({ ...tunnels, [status.tunnelId]: status }));
appendLogRow(log.info("tunnel", `${name} tunnel is ready at ${status.url}`));
} catch (err) {
appendLogRow(log.error("tunnel", errorMessage(err)));
} finally {
setServerTunnelBusy((busy) => omitKey(busy, request.tunnelId));
}
};
const stopServerTunnel = async (tunnelId: string) => {
setServerTunnelBusy((busy) => ({ ...busy, [tunnelId]: true }));
try {
await stopServerTunnelCmd(tunnelId);
setServerTunnels((tunnels) => omitKey(tunnels, tunnelId));
appendLogRow(log.info("tunnel", "SSH tunnel stopped."));
} catch (err) {
appendLogRow(log.error("tunnel", errorMessage(err)));
} finally {
setServerTunnelBusy((busy) => omitKey(busy, tunnelId));
}
};
const stopTunnelsForServer = (serverKey: string) => {
for (const tunnelId of Object.keys(serverTunnels).filter((id) => id.startsWith(`${serverKey}:tunnel:`))) {
void stopServerTunnel(tunnelId);
}
};
useEffect(() => {
return () => {
void stopAllTunnels();
};
}, []);
return {
serverTunnels,
serverTunnelBusy,
startServerTunnel,
startCustomTunnel,
openServerTunnel,
stopServerTunnel,
stopTunnelsForServer,
};
}

View File

@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "@radix-ui/themes/styles.css";
import "./styles.css";
import { App } from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,108 @@
import { invoke } from "@tauri-apps/api/core";
import type {
ClusterDto,
CommandSpec,
CronPreviewResult,
DumpPruneItem,
DumpPruneResult,
DumpPruneTarget,
HealthDto,
HistoryDto,
ItemDto,
LogDto,
ManagementConnRequest,
ManagementInstallRequest,
ManagementInstallResult,
ManagementServiceStatus,
PlayerDto,
PlayerLocationDto,
PublishResultDto,
RunDto,
JourneyNodeDto,
ScheduleConfig,
ScheduleConfigUpdate,
SkillModuleDto,
VehicleDto,
WelcomeGrantDto,
XpEventTagDto,
} from "../types/management";
export const managementService = {
install: (req: ManagementInstallRequest) =>
invoke<ManagementInstallResult>("install_management_service", { request: req }),
uninstall: (req: ManagementConnRequest) =>
invoke<void>("uninstall_management_service", { request: req }),
status: (req: ManagementConnRequest) =>
invoke<ManagementServiceStatus>("management_service_status", { request: req }),
bundledVersion: () => invoke<string>("management_service_bundled_version"),
restart: (req: ManagementConnRequest) =>
invoke<void>("restart_management_service", { request: req }),
};
export const managementApi = {
health: (tunnelId: string) => invoke<HealthDto>("ms_health", { tunnelId }),
listRuns: (tunnelId: string, limit?: number, task?: string) =>
invoke<RunDto[]>("ms_list_runs", { tunnelId, limit, task }),
listLogs: (tunnelId: string, limit?: number, runId?: number) =>
invoke<LogDto[]>("ms_list_logs", { tunnelId, limit, runId }),
triggerRun: (tunnelId: string, task: string, options?: Record<string, unknown>) =>
invoke<{ ok: boolean; task: string }>("ms_trigger_run", { tunnelId, task, options }),
listCommands: (tunnelId: string) =>
invoke<CommandSpec[]>("ms_list_commands", { tunnelId }),
searchItems: (tunnelId: string, q: string, limit?: number) =>
invoke<ItemDto[]>("ms_search_items", { tunnelId, q, limit }),
searchVehicles: (tunnelId: string, q: string, limit?: number) =>
invoke<VehicleDto[]>("ms_search_vehicles", { tunnelId, q, limit }),
searchSkillModules: (tunnelId: string, q: string, limit?: number) =>
invoke<SkillModuleDto[]>("ms_search_skill_modules", { tunnelId, q, limit }),
searchJourneyNodes: (tunnelId: string, q: string, limit?: number) =>
invoke<JourneyNodeDto[]>("ms_search_journey_nodes", { tunnelId, q, limit }),
searchXpEventTags: (tunnelId: string, q: string, limit?: number) =>
invoke<XpEventTagDto[]>("ms_search_xp_event_tags", { tunnelId, q, limit }),
getConfig: (tunnelId: string) => invoke<ScheduleConfig>("ms_get_config", { tunnelId }),
setConfig: (tunnelId: string, config: ScheduleConfigUpdate) =>
invoke<{ ok: boolean }>("ms_set_config", { tunnelId, config }),
listTimezones: (tunnelId: string) => invoke<string[]>("ms_list_timezones", { tunnelId }),
cronPreview: (tunnelId: string, expr: string, count?: number) =>
invoke<CronPreviewResult>("ms_cron_preview", { tunnelId, expr, count }),
dumpPrunePreview: (tunnelId: string) =>
invoke<DumpPruneItem[]>("ms_dump_prune_preview", { tunnelId }),
dumpPruneExecute: (tunnelId: string, items: DumpPruneTarget[]) =>
invoke<DumpPruneResult>("ms_dump_prune_execute", { tunnelId, items }),
searchPlayers: (tunnelId: string, q: string, limit?: number) =>
invoke<PlayerDto[]>("ms_search_players", { tunnelId, q, limit }),
playerLocation: (tunnelId: string, flsId: string) =>
invoke<PlayerLocationDto>("ms_player_location", { tunnelId, flsId }),
cluster: (tunnelId: string) => invoke<ClusterDto>("ms_cluster", { tunnelId }),
history: (tunnelId: string, limit?: number) =>
invoke<HistoryDto[]>("ms_history", { tunnelId, limit }),
welcomeGrants: (tunnelId: string, limit?: number) =>
invoke<WelcomeGrantDto[]>("ms_welcome_grants", { tunnelId, limit }),
retryWelcomeGrant: (
tunnelId: string,
playerId: string,
packageVersion: string,
accountId: number,
) =>
invoke<{ ok: boolean; removed: number }>("ms_welcome_grant_retry", {
tunnelId,
playerId,
packageVersion,
accountId,
}),
sendWelcomeWhisper: (
tunnelId: string,
recipientPlayerId: string,
sourcePlayerId: string,
message: string,
) =>
invoke<PublishResultDto>("ms_welcome_whisper", {
tunnelId,
recipientPlayerId,
sourcePlayerId,
message,
}),
publish: (tunnelId: string, command: string, fields: Record<string, unknown>) =>
invoke<PublishResultDto>("ms_publish", { tunnelId, command, fields }),
};

View File

@@ -0,0 +1,142 @@
import type { RemoteServerRecord } from "../types/server";
import type { CustomTunnelDef } from "../types/tunnel";
import type { ActivePage, ServerSubPage } from "../types/ui";
import { SERVER_SUB_PAGES } from "../types/ui";
const remoteServersStorageKey = "dune-manager.remote-servers";
const activePageStorageKey = "dune-manager.active-page";
const logSidebarStorageKey = "dune-manager.log-sidebar";
export function isRemoteServerRecord(value: unknown): value is RemoteServerRecord {
if (!value || typeof value !== "object") return false;
const record = value as Partial<RemoteServerRecord>;
return (
record.type === "ubuntu" &&
typeof record.id === "string" &&
typeof record.name === "string" &&
typeof record.host === "string" &&
typeof record.keyPath === "string"
);
}
export function readRemoteServers(): RemoteServerRecord[] {
const text = window.localStorage.getItem(remoteServersStorageKey);
if (!text) return [];
try {
const parsed = JSON.parse(text);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isRemoteServerRecord);
} catch {
window.localStorage.removeItem(remoteServersStorageKey);
return [];
}
}
export function mergeRemoteServers(
current: RemoteServerRecord[],
incoming: RemoteServerRecord[],
): RemoteServerRecord[] {
const byId = new Map(current.map((server) => [server.id, server]));
for (const server of incoming) {
byId.set(server.id, { ...byId.get(server.id), ...server });
}
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name));
}
export function persistRemoteServers(servers: RemoteServerRecord[]): RemoteServerRecord[] {
const unique = mergeRemoteServers([], servers);
window.localStorage.setItem(remoteServersStorageKey, JSON.stringify(unique));
return unique;
}
export function upsertRemoteServer(
servers: RemoteServerRecord[],
server: RemoteServerRecord,
): RemoteServerRecord[] {
return mergeRemoteServers(servers, [server]);
}
type PersistedActivePage = { activeServerId?: string; activeSub?: ServerSubPage };
function isServerSubPage(value: unknown): value is ServerSubPage {
return typeof value === "string" && (SERVER_SUB_PAGES as readonly string[]).includes(value);
}
export function readActivePage(attachedServerIds: string[]): ActivePage {
const text = window.localStorage.getItem(activePageStorageKey);
if (!text) return { kind: "servers" };
try {
const parsed = JSON.parse(text) as PersistedActivePage;
const id = parsed?.activeServerId;
if (!id || !attachedServerIds.includes(id)) return { kind: "servers" };
const sub = isServerSubPage(parsed?.activeSub) ? parsed.activeSub : "dashboard";
return { kind: "server", serverId: id, sub };
} catch {
window.localStorage.removeItem(activePageStorageKey);
return { kind: "servers" };
}
}
export function writeActivePage(page: ActivePage): void {
if (page.kind === "servers") {
window.localStorage.removeItem(activePageStorageKey);
return;
}
const payload: PersistedActivePage = { activeServerId: page.serverId, activeSub: page.sub };
window.localStorage.setItem(activePageStorageKey, JSON.stringify(payload));
}
type PersistedLogSidebar = { collapsed?: boolean; scopeToActiveServer?: boolean };
export function readLogSidebar(): PersistedLogSidebar {
const text = window.localStorage.getItem(logSidebarStorageKey);
if (!text) return {};
try {
const parsed = JSON.parse(text) as PersistedLogSidebar;
return {
collapsed: typeof parsed.collapsed === "boolean" ? parsed.collapsed : undefined,
scopeToActiveServer:
typeof parsed.scopeToActiveServer === "boolean" ? parsed.scopeToActiveServer : undefined,
};
} catch {
window.localStorage.removeItem(logSidebarStorageKey);
return {};
}
}
export function writeLogSidebar(state: PersistedLogSidebar): void {
window.localStorage.setItem(logSidebarStorageKey, JSON.stringify(state));
}
function customTunnelsKey(serverId: string): string {
return `dune-manager.custom-tunnels.${serverId}`;
}
function isCustomTunnelDef(value: unknown): value is CustomTunnelDef {
if (!value || typeof value !== "object") return false;
const d = value as Partial<CustomTunnelDef>;
return (
typeof d.id === "string" &&
typeof d.name === "string" &&
(d.protocol === "http" || d.protocol === "https" || d.protocol === "postgresql") &&
typeof d.remotePort === "number" &&
typeof d.localPort === "number"
);
}
export function readCustomTunnels(serverId: string): CustomTunnelDef[] {
const text = window.localStorage.getItem(customTunnelsKey(serverId));
if (!text) return [];
try {
const parsed = JSON.parse(text);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isCustomTunnelDef);
} catch {
window.localStorage.removeItem(customTunnelsKey(serverId));
return [];
}
}
export function writeCustomTunnels(serverId: string, defs: CustomTunnelDef[]): void {
window.localStorage.setItem(customTunnelsKey(serverId), JSON.stringify(defs));
}

View File

@@ -0,0 +1,170 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { open as openShell } from "@tauri-apps/plugin-shell";
import { relaunch as relaunchProcess } from "@tauri-apps/plugin-process";
import type {
RemoteComponentLogResult,
RemoteComponentRestartResult,
} from "../types/component";
import type {
RemoteServerComponent,
RemoteServerKind,
RemoteServerRecord,
RemoteServerStatus,
} from "../types/server";
import type { CustomTunnelStartRequest, ServerTunnelStartRequest, ServerTunnelStatus } from "../types/tunnel";
type RemoteActionRequest = {
serverType: RemoteServerKind;
host: string;
user: string;
keyPath?: string;
port?: number;
namespace: string;
battlegroupName: string;
};
type DetectRemoteServersRequest = {
host: string;
keyPath: string;
serverType: RemoteServerKind;
user: string;
port?: number;
};
type RemoteComponentLogRequest = {
serverType: RemoteServerKind;
host: string;
user: string;
keyPath?: string;
port?: number;
namespace: string;
component: string;
tail: number;
};
type RemoteComponentRestartRequest = {
serverType: RemoteServerKind;
host: string;
user: string;
keyPath?: string;
port?: number;
namespace: string;
component: string;
};
export async function detectRemoteUbuntuServers(
request: DetectRemoteServersRequest,
): Promise<RemoteServerRecord[]> {
return invoke<RemoteServerRecord[]>("detect_remote_ubuntu_servers", { request });
}
export async function getRemoteServerStatus(request: RemoteActionRequest): Promise<RemoteServerStatus> {
return invoke<RemoteServerStatus>("remote_server_status", { request });
}
export async function getRemoteServerComponents(
request: RemoteActionRequest,
): Promise<RemoteServerComponent[]> {
return invoke<RemoteServerComponent[]>("remote_server_components", { request });
}
export async function startRemoteBattlegroup(request: RemoteActionRequest): Promise<RemoteServerStatus> {
return invoke<RemoteServerStatus>("start_remote_battlegroup", { request });
}
export async function stopRemoteBattlegroup(request: RemoteActionRequest): Promise<RemoteServerStatus> {
return invoke<RemoteServerStatus>("stop_remote_battlegroup", { request });
}
export async function updateRemoteBattlegroup(request: RemoteActionRequest): Promise<RemoteServerStatus> {
return invoke<RemoteServerStatus>("update_remote_battlegroup", { request });
}
export async function restartRemoteBattlegroup(request: RemoteActionRequest): Promise<RemoteServerStatus> {
return invoke<RemoteServerStatus>("restart_remote_battlegroup", { request });
}
export async function startServerTunnel(request: ServerTunnelStartRequest): Promise<ServerTunnelStatus> {
return invoke<ServerTunnelStatus>("start_server_tunnel", { request });
}
export async function startCustomTunnel(request: CustomTunnelStartRequest): Promise<ServerTunnelStatus> {
return invoke<ServerTunnelStatus>("start_custom_tunnel", { request });
}
export async function stopServerTunnel(tunnelId: string): Promise<void> {
await invoke("stop_server_tunnel", { request: { tunnelId } });
}
export async function serverTunnelStatus(tunnelId: string): Promise<ServerTunnelStatus | null> {
return invoke<ServerTunnelStatus | null>("server_tunnel_status", { request: { tunnelId } });
}
export async function stopAllTunnels(): Promise<void> {
await invoke("stop_all_tunnels");
}
export async function remoteComponentLogTail(
request: RemoteComponentLogRequest,
): Promise<RemoteComponentLogResult> {
return invoke<RemoteComponentLogResult>("remote_component_log_tail", { request });
}
export async function restartRemoteComponent(
request: RemoteComponentRestartRequest,
): Promise<RemoteComponentRestartResult> {
return invoke<RemoteComponentRestartResult>("restart_remote_component", { request });
}
export function listenToEvent<T>(
channel: string,
handler: (payload: T) => void,
): Promise<UnlistenFn> {
return listen<T>(channel, (event) => handler(event.payload));
}
export async function openFileDialog(title: string): Promise<string | null> {
const selected = await openDialog({ directory: false, multiple: false, title });
return typeof selected === "string" ? selected : null;
}
export async function openExternal(url: string): Promise<void> {
await openShell(url);
}
export async function relaunch(): Promise<void> {
await relaunchProcess();
}
export type PreflightCheck = {
sshOk: boolean;
sudoToDuneOk: boolean;
duneNopasswdOk: boolean;
isDuneLogin: boolean;
rawOutput: string;
};
export async function checkRemoteSudo(request: {
host: string;
user: string;
keyPath: string;
port?: number;
}): Promise<PreflightCheck> {
return invoke<PreflightCheck>("check_remote_sudo", { request });
}
export async function recordOperationLog(level: string, scope: string, message: string): Promise<void> {
await invoke("record_operation_log", { level, scope, message });
}
export async function getLogsFolder(): Promise<string> {
return invoke<string>("get_logs_folder");
}
export async function openLogsFolder(): Promise<void> {
const path = await getLogsFolder();
if (path) await openShell(path);
}

View File

@@ -0,0 +1,15 @@
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
export type { DownloadEvent, Update } from "@tauri-apps/plugin-updater";
export async function checkForUpdate(timeoutMs = 15_000): Promise<Update | null> {
return check({ timeout: timeoutMs });
}
export async function downloadAndInstallUpdate(
update: Update,
onEvent: (event: DownloadEvent) => void,
timeoutMs = 120_000,
): Promise<void> {
await update.downloadAndInstall(onEvent, { timeout: timeoutMs });
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
export type RemoteComponentLogResult = {
component: string;
output: string;
};
export type RemoteComponentRestartResult = {
component: string;
output: string;
};

View File

@@ -0,0 +1,19 @@
export type LogLevel = "debug" | "info" | "warn" | "error";
export type LogLevelFilter = LogLevel;
export type LogRow = {
id: number;
timestamp: string;
level: LogLevel;
scope: string;
message: string;
/** When set, the row is associated with a specific attached server. */
serverId?: string;
};
export type OperationLogPayload = {
level: LogLevel;
scope: string;
message: string;
serverId?: string;
};

View File

@@ -0,0 +1,284 @@
export type ManagementInstallRequest = {
host: string;
user: string;
keyPath?: string;
port?: number;
commandAuthToken?: string;
};
export type ManagementConnRequest = {
host: string;
user: string;
keyPath?: string;
port?: number;
};
export type ManagementInstallResult = {
installed: boolean;
started: boolean;
initSystem: string;
installedVersion: string | null;
message: string;
};
export type ManagementServiceStatus = {
installed: boolean;
active: boolean;
initSystem: string;
installedVersion: string | null;
bundledVersion: string;
journalTail: string;
};
export type InstallProgressEvent = {
step: string;
status: "pending" | "running" | "ok" | "error";
message: string | null;
};
export const INSTALL_STEPS: ReadonlyArray<{ id: string; label: string }> = [
{ id: "stop-old", label: "Stop existing service" },
{ id: "prepare-host", label: "Prepare host directories" },
{ id: "upload-binary", label: "Upload binary" },
{ id: "write-token", label: "Write command-auth token" },
{ id: "install-init", label: "Install init unit" },
{ id: "start-service", label: "Start service" },
{ id: "verify", label: "Verify" },
];
export type HealthDto = {
ok: boolean;
version: string;
now: string;
};
export type RunDto = {
id: number;
taskId: string;
trigger: "scheduled" | "manual" | "startup";
dryRun: boolean;
status: "running" | "success" | "failed" | "skipped";
startedAt: string;
finishedAt: string | null;
durationMs: number | null;
error: string | null;
};
export type LogDto = {
id: number;
createdAt: string;
level: "info" | "warn" | "error";
message: string;
taskId: string | null;
runId: number | null;
};
export type FieldKind = "string" | "int" | "float" | "bool" | "select" | "text";
export type SelectOption = {
value: string;
label: string;
};
export type FieldSpec = {
key: string;
label: string;
kind: FieldKind;
required?: boolean;
default?: unknown;
helper?: string;
options?: SelectOption[];
};
export type Category =
| "items"
| "movement"
| "broadcast"
| "progression"
| "player"
| "journey"
| "exec";
export type CommandSpec = {
id: string;
label: string;
category: Category;
destructive?: boolean;
needsPlayer: boolean;
allowAllPlayers: boolean;
describe: string;
fields: FieldSpec[];
};
export type ItemDto = {
id: string;
name: string;
category: string;
source: string;
};
export type VehicleDto = {
id: string;
actor_class: string;
templates: string[];
};
export type SkillModuleDto = {
id: string;
name: string;
category: string;
maxLevel: number;
};
export type JourneyNodeDto = {
id: string;
label: string;
card: string;
category: string;
};
export type XpEventTagDto = {
id: string;
family: string;
constant: string;
};
export type ScheduleConfig = {
restartHour: number;
restartMinute: number;
restartWarningFrequencySecs: number;
restartWarningDurationSecs: number;
updateLeadSecs: number;
restartTz: string;
/**
* Master switches for the daily restart, update loop, and scheduled backups.
* Optional so older service builds (which omit them) read as undefined;
* callers should treat undefined as enabled (the default).
*/
restartEnabled?: boolean;
updateEnabled?: boolean;
backupEnabled?: boolean;
/** null = scheduled backups disabled; otherwise the 5-field cron string. */
backupCron: string | null;
welcomeMessageEnabled: boolean;
welcomePackageEnabled: boolean;
welcomePackageVersion: string;
welcomePackageActionsJson: string;
/** Backward-compatible alias returned by older service builds. */
welcomePackageItemsJson: string;
welcomeWhisperSourcePlayer: string;
welcomeMessage: string;
restartRequired: boolean;
};
export type ScheduleConfigUpdate = Partial<{
restartHour: number;
restartMinute: number;
restartWarningFrequencySecs: number;
restartWarningDurationSecs: number;
updateLeadSecs: number;
restartTz: string;
restartEnabled: boolean;
updateEnabled: boolean;
backupEnabled: boolean;
/** Empty string clears the cron (disables); non-empty validated server-side. */
backupCron: string;
welcomeMessageEnabled: boolean;
welcomePackageEnabled: boolean;
welcomePackageVersion: string;
welcomePackageActionsJson: string;
/** Older service builds accept this; newer builds map it to actions. */
welcomePackageItemsJson: string;
welcomeWhisperSourcePlayer: string;
welcomeMessage: string;
}>;
export type CronPreviewResult =
| { ok: true; tz: string; next: string[] }
| { ok: false; error: string };
export type DumpPruneItem = {
namespace: string;
name: string;
action: string;
backup: string | null;
phase: string;
createdAt: string;
ageDays: number;
};
export type DumpPruneTarget = {
namespace: string;
name: string;
};
export type DumpPruneResult = {
deleted: string[];
skipped: { namespace: string; name: string; reason: string }[];
};
export type PlayerLocationDto = {
x: number;
y: number;
z: number;
dimensionIndex: number | null;
partitionId: number | null;
/// Pawn actor class — e.g. "…BP_DunePlayerCharacter_C". Useful sanity.
source: string;
};
export type RestartNoticeOptions = {
leadSecs?: number;
frequencySecs?: number;
durationSecs?: number;
title?: string;
body?: string;
};
export type PlayerDto = {
flsId: string;
name: string;
online: string;
lastSeen: string;
level: number | null;
partitionId: number | null;
};
export type ClusterDto = {
namespace: string;
mqPod: string;
dbPod: string | null;
serviceVersion: string;
};
export type HistoryDto = {
id: number;
createdAt: string;
command: string;
payload: Record<string, unknown>;
ok: boolean;
message: string | null;
};
export type WelcomeGrantDto = {
playerId: string;
packageVersion: string;
accountId: number;
characterName: string | null;
status: "pending" | "granted" | "failed";
detectedAt: string;
updatedAt: string;
grantedAt: string | null;
attempts: number;
lastOnlineStatus: string | null;
firstOnlineAt: string | null;
lastError: string | null;
};
export type PublishResultDto = {
ok: boolean;
command: string;
output: string;
error: string | null;
inner: Record<string, unknown>;
};

View File

@@ -0,0 +1,58 @@
import type { BadgeTone } from "./ui";
export type RemoteServerKind = "ubuntu";
export type RemoteServerPackageStatus = {
installedBuildId?: string | null;
battlegroupVersion?: string | null;
liveBattlegroupVersion?: string | null;
operatorVersion?: string | null;
};
export type RemoteBattlegroupStatus = {
stop: boolean;
phase: string;
databasePhase?: string;
/** Gateway phase column from the vendor wrapper. */
serverGroupPhase: string;
directorPhase: string;
uptime?: string;
serverStats?: RemoteBattlegroupServerStat[];
};
export type RemoteBattlegroupServerStat = {
map: string;
phase: string;
ready: string;
players: string;
age: string;
};
export type RemoteServerStatus = {
battlegroup: RemoteBattlegroupStatus;
package: RemoteServerPackageStatus;
};
export type RemoteServerComponent = {
name: string;
logKey: string;
category: "system" | "map";
state: string;
tone: BadgeTone;
summary: string;
details: string[];
};
export type RemoteServerRecord = {
type: RemoteServerKind;
id: string;
name: string;
host: string;
user: string;
keyPath: string;
port?: number;
namespace: string;
battlegroupName: string;
worldUniqueName: string;
phase: string;
};

View File

@@ -0,0 +1,44 @@
import type { RemoteServerKind } from "./server";
export type TunnelService = "director" | "fileBrowser" | "database" | "pgHero" | "managementApi";
export type CustomTunnelProtocol = "http" | "https" | "postgresql";
export type CustomTunnelDef = {
id: string;
name: string;
protocol: CustomTunnelProtocol;
remotePort: number;
localPort: number;
};
export type CustomTunnelStartRequest = {
tunnelId: string;
serverKind: RemoteServerKind;
host: string;
user: string;
keyPath?: string;
port?: number;
protocol: CustomTunnelProtocol;
remotePort: number;
localPort: number;
};
export type ServerTunnelStatus = {
tunnelId: string;
service: TunnelService;
localPort: number;
remotePort: number;
url: string;
};
export type ServerTunnelStartRequest = {
tunnelId: string;
serverKind: RemoteServerKind;
service: TunnelService;
host: string;
user: string;
keyPath?: string;
port?: number;
namespace: string;
};

View File

@@ -0,0 +1,44 @@
export type ServerSubPage =
| "dashboard"
| "update"
| "pods"
| "users"
| "admin"
| "welcome"
| "tasks";
export type ActivePage =
| { kind: "servers" }
| { kind: "server"; serverId: string; sub: ServerSubPage };
export const SERVER_SUB_PAGES: readonly ServerSubPage[] = [
"dashboard",
"update",
"pods",
"users",
"admin",
"welcome",
"tasks",
] as const;
export const MANAGEMENT_SUB_PAGES: readonly ServerSubPage[] = [
"users",
"admin",
"welcome",
"tasks",
] as const;
export function isManagementSubPage(sub: ServerSubPage): boolean {
return MANAGEMENT_SUB_PAGES.includes(sub);
}
export type DetectionState = "idle" | "detecting" | "ready" | "failed";
export type BadgeTone = "green" | "amber" | "red" | "gray" | "bronze";
export type RemoteAttachForm = {
host: string;
user: string;
keyPath: string;
port: number;
};

View File

@@ -0,0 +1,8 @@
export type UpdateStatus =
| "idle"
| "checking"
| "available"
| "current"
| "installing"
| "relaunching"
| "failed";

View File

@@ -0,0 +1,3 @@
export async function copyTextToClipboard(text: string) {
await navigator.clipboard.writeText(text);
}

View File

@@ -0,0 +1,5 @@
export function errorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === "string") return err;
return "Operation failed.";
}

View File

@@ -0,0 +1,89 @@
import type { Update } from "../services/updater";
import type { RemoteServerRecord, RemoteServerStatus } from "../types/server";
import type { TunnelService } from "../types/tunnel";
import type { UpdateStatus } from "../types/update";
// Backend timestamps are RFC3339 with a UTC offset (chrono `Utc::now().to_rfc3339()`),
// so `new Date(iso)` parses them as UTC. `toLocale*` then renders in the operator's
// local timezone. Never slice `.toISOString()` for display — that leaks raw UTC.
export function formatTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
}
export function formatDateTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return `${d.toLocaleDateString([], {
year: "numeric",
month: "2-digit",
day: "2-digit",
})} ${formatTime(iso)}`;
}
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "unknown";
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${Math.round(bytes / 1024 / 1024)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
export function updateLabel(status: UpdateStatus, availableUpdate: Update | null, progress: string | null): string {
if (status === "checking") return "Checking";
if (status === "installing") return progress ?? "Installing";
if (status === "relaunching") return progress ?? "Relaunching";
if (status === "failed") return "Check failed";
if (availableUpdate) return `${availableUpdate.version} available`;
if (status === "current") return "Up to date";
return "Not checked";
}
export function updateTone(status: UpdateStatus): "green" | "amber" | "red" {
if (status === "failed") return "red";
if (status === "current") return "green";
return "amber";
}
export function remoteStatusTone(
statusError: string | undefined,
liveStatus: RemoteServerStatus | undefined,
battlegroupStarted: boolean,
battlegroupStartRequested: boolean,
battlegroupStopped: boolean,
server: RemoteServerRecord,
): "green" | "amber" | "red" | "gray" {
if (statusError) return "red";
if (battlegroupStarted) return "green";
if (battlegroupStartRequested) return "amber";
if (battlegroupStopped) return "gray";
if (server.phase === "Setup running") return "amber";
return liveStatus ? "green" : "gray";
}
export function remoteStatusLabel(
statusError: string | undefined,
liveStatus: RemoteServerStatus | undefined,
busyLabel: string | undefined,
battlegroupStarted: boolean,
battlegroupStartRequested: boolean,
server: RemoteServerRecord,
): string {
if (statusError) return "Check failed";
if (busyLabel) return "Retrieving";
if (!liveStatus) return server.phase || "Unknown";
if (battlegroupStarted) return "Started";
return battlegroupStartRequested ? "Starting" : "Stopped";
}
export function tunnelServiceLabel(service: TunnelService): string {
if (service === "fileBrowser") return "File Browser";
if (service === "database") return "Postgres";
if (service === "pgHero") return "PgHero";
return "Director";
}

View File

@@ -0,0 +1,54 @@
import type { LogLevel, LogLevelFilter, LogRow } from "../types/log";
export const maxStoredLogRows = 2500;
export const maxRenderedLogRows = 1200;
let nextLogRowId = 1;
export function sanitizeLogMessage(message: string): string {
return message.replace(
/\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?::\d{1,5})?\b/g,
"IP address",
);
}
export function logEntry(
level: LogLevel,
scope: string,
message: string,
serverId?: string,
): LogRow {
return {
id: nextLogRowId++,
timestamp: new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}),
level,
scope,
message: sanitizeLogMessage(message),
serverId,
};
}
export function filterLogRows(rows: LogRow[], minimum: LogLevelFilter): LogRow[] {
const rank: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
return rows.filter((row) => rank[row.level] >= rank[minimum]);
}
export function limitLogRows(rows: LogRow[]): LogRow[] {
if (rows.length <= maxStoredLogRows) return rows;
return rows.slice(-maxStoredLogRows);
}
export const log = {
debug: (scope: string, message: string, serverId?: string): LogRow =>
logEntry("debug", scope, message, serverId),
info: (scope: string, message: string, serverId?: string): LogRow =>
logEntry("info", scope, message, serverId),
warn: (scope: string, message: string, serverId?: string): LogRow =>
logEntry("warn", scope, message, serverId),
error: (scope: string, message: string, serverId?: string): LogRow =>
logEntry("error", scope, message, serverId),
};

View File

@@ -0,0 +1,136 @@
import type {
RemoteBattlegroupStatus,
RemoteServerComponent,
RemoteServerKind,
RemoteServerPackageStatus,
RemoteServerRecord,
RemoteServerStatus,
} from "../types/server";
import type { TunnelService } from "../types/tunnel";
import type { StatusTone } from "../components/ui/StatusPill";
export function remoteServerDefaultUser(_kind: RemoteServerKind): string {
return "dune";
}
export function remoteServerActionRequest(server: RemoteServerRecord) {
return {
serverType: server.type,
host: server.host,
user: server.user || remoteServerDefaultUser(server.type),
keyPath: server.keyPath || undefined,
port: server.port,
namespace: server.namespace,
battlegroupName: server.battlegroupName,
};
}
export function isCriticalRestartComponent(component: RemoteServerComponent): boolean {
const key = component.logKey.toLowerCase();
const name = component.name.toLowerCase();
return (
key.includes("database") ||
key.includes("messagequeue") ||
name.includes("database") ||
name.includes("message queue")
);
}
const STARTED_PHASES = new Set([
"running",
"ready",
"healthy",
"available",
"reconciling",
]);
function isStartedPhase(phase: string): boolean {
return STARTED_PHASES.has(phase.trim().toLowerCase());
}
export function isBattlegroupStarted(status: RemoteBattlegroupStatus): boolean {
if (status.stop) return false;
if (!isStartedPhase(status.phase)) return false;
if (status.serverGroupPhase && !isStartedPhase(status.serverGroupPhase)) return false;
if (status.directorPhase && !isDirectorReadyPhase(status.directorPhase)) return false;
return true;
}
/**
* Returns true when the downloaded battlegroup version differs from the
* version currently running in Kubernetes. Both versions must be known; if
* either is missing we treat the state as "no actionable update" so the
* Update Server button stays hidden.
*/
export function hasBattlegroupUpdateAvailable(
pkg: RemoteServerPackageStatus | undefined,
): boolean {
if (!pkg) return false;
const downloaded = pkg.battlegroupVersion?.trim();
const live = pkg.liveBattlegroupVersion?.trim();
if (!downloaded || !live) return false;
return downloaded !== live;
}
export function isDirectorReadyPhase(phase: string): boolean {
const normalized = phase.trim().toLowerCase();
if (normalized === "" || normalized === "true") return true;
return isStartedPhase(normalized);
}
export function serverTunnelKey(serverKey: string, service: TunnelService): string {
return `${serverKey}:tunnel:${service}`;
}
export function componentLogStateKey(serverKey: string, component: RemoteServerComponent): string {
return `${serverKey}:${component.logKey}`;
}
export function omitKey<T>(record: Record<string, T>, key: string): Record<string, T> {
const { [key]: _removed, ...rest } = record;
return rest;
}
export function omitPrefix<T>(record: Record<string, T>, prefix: string): Record<string, T> {
return Object.fromEntries(Object.entries(record).filter(([key]) => !key.startsWith(prefix)));
}
export type ResolvedServerStatus = {
tone: StatusTone;
label: string;
pulse: boolean;
};
/**
* Reduces an attached server's various status signals (live status, error,
* busy label, persisted record phase) into a single tone + label + pulse
* triple. Shared by RemoteServer detail pages, the top tab strip, and the
* compact list view.
*/
export function resolveServerStatus(
statusError: string | undefined,
liveStatus: RemoteServerStatus | undefined,
busy: boolean,
server: RemoteServerRecord,
): ResolvedServerStatus {
if (statusError) return { tone: "err", label: "Check failed", pulse: false };
if (!liveStatus) return { tone: "gray", label: busy ? "Checking" : "Unknown", pulse: busy };
const battlegroup = liveStatus.battlegroup;
if (isBattlegroupStarted(battlegroup)) return { tone: "ok", label: "Started", pulse: false };
if (!battlegroup.stop) return { tone: "warn", label: battlegroup.phase || "Starting", pulse: true };
if (battlegroup.stop) return { tone: "gray", label: "Stopped", pulse: false };
if (server.phase === "Setup running") return { tone: "warn", label: "Setup running", pulse: true };
return { tone: "gray", label: server.phase || "Unknown", pulse: false };
}
/**
* Maps a Kubernetes/operator phase string onto the shared status tone
* vocabulary used by metric tiles and per-map server-stats rows.
*/
export 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";
}

View File

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