docs(reference): import Dune: Awakening server-manager references
Phase 2 references for the host-agent Dune adapter, moved out of volatile /tmp
into docs/reference-repos/ (per Commander). Three upstream projects, .git +
node_modules + compiled binaries stripped (16MB source). Nested AI-instruction
files (.claude/, CLAUDE.md) removed so they don't pollute Corrosion sessions.
- icehunter/ dune-admin (Go+React) — 4 control planes; SETUP_DOCKER.md is the
closest analog to our agent's Dune docker control plane (compose
lifecycle, docker logs, RabbitMQ-via-exec, dune Postgres schema)
- adainrivers/ Rust/Tauri desktop — SSH+k8s BattleGroup control, maintenance
daemon, in-game admin console (Rust idiom reference)
- the4rchangel/ Node web UI replacing battlegroup.bat — matches the Commander's
Hyper-V self-host path + game-config schema
See docs/reference-repos/README.md for the full index + how we use each.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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 };
|
||||
}
|
||||
117
docs/reference-repos/adainrivers/app/src/hooks/useAppUpdates.ts
Normal file
117
docs/reference-repos/adainrivers/app/src/hooks/useAppUpdates.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(", ") + ".";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user