Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions app/(app)/[id]/actions/hibernate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"use server";

import { revalidatePath } from "next/cache";
import { start } from "workflow/api";
import { z } from "zod";

import { prisma } from "@/lib/db";
import { requireUser } from "@/lib/session";
import { hibernateServer } from "@/lib/workflows/hibernate-server";

const inputSchema = z.object({ serverId: z.string().min(1) });

export type HibernateServerInput = z.infer<typeof inputSchema>;

export type HibernateServerResult = { ok: true } | { error: string; ok: false };

export const hibernate = async (
input: HibernateServerInput
): Promise<HibernateServerResult> => {
await requireUser();

const parsed = inputSchema.safeParse(input);
if (!parsed.success) {
return { error: "Invalid input", ok: false };
}
const { serverId } = parsed.data;

const server = await prisma.server.findFirst({
where: { deletedAt: null, id: serverId },
});
if (!server) {
return { error: "Not found", ok: false };
}
if (!server.providerServerId) {
return { error: "Server is not provisioned yet", ok: false };
}

// Atomically claim the transition so a double-click can't start two
// workflows. "failed" is only claimable when the failure came from a
// hibernation attempt (desiredState is still "hibernated"), never from a
// failed provision.
const { count } = await prisma.server.updateMany({
data: {
desiredState: "hibernated",
errorReason: null,
observedState: "hibernating",
phase: "hibernating",
},
where: {
OR: [
{ observedState: { in: ["running", "stopped"] } },
{ desiredState: "hibernated", observedState: "failed" },
],
deletedAt: null,
id: serverId,
providerServerId: { not: null },
},
});
if (count === 0) {
return {
error: "Server must be running or stopped to hibernate",
ok: false,
};
}

try {
await start(hibernateServer, [{ serverId }]);
} catch {
// The workflow never started; put the row back so the server isn't
// stranded in "hibernating" with nothing driving it.
await prisma.server.updateMany({
data: {
desiredState: server.desiredState,
errorReason: server.errorReason,
observedState: server.observedState,
phase: server.phase,
},
where: { id: serverId, observedState: "hibernating" },
});
return { error: "Failed to start hibernation", ok: false };
}

revalidatePath("/", "layout");
return { ok: true };
};
79 changes: 79 additions & 0 deletions app/(app)/[id]/actions/wake.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"use server";

import { revalidatePath } from "next/cache";
import { start } from "workflow/api";
import { z } from "zod";

import { prisma } from "@/lib/db";
import { requireUser } from "@/lib/session";
import { wakeServer } from "@/lib/workflows/wake-server";

const inputSchema = z.object({ serverId: z.string().min(1) });

export type WakeServerInput = z.infer<typeof inputSchema>;

export type WakeServerResult = { ok: true } | { error: string; ok: false };

export const wake = async (
input: WakeServerInput
): Promise<WakeServerResult> => {
await requireUser();

const parsed = inputSchema.safeParse(input);
if (!parsed.success) {
return { error: "Invalid input", ok: false };
}
const { serverId } = parsed.data;

const server = await prisma.server.findFirst({
where: { deletedAt: null, id: serverId },
});
if (!server) {
return { error: "Not found", ok: false };
}
if (!server.hibernationImageId) {
return { error: "No hibernation snapshot found", ok: false };
}

// Atomically claim the transition so a double-click can't start two
// workflows (two concurrent wakes could each create a VM). "failed" is
// claimable so a wake that timed out can be retried; the workflow reuses
// the existing VM when one survived the failed attempt.
const { count } = await prisma.server.updateMany({
data: {
desiredState: "running",
errorReason: null,
observedState: "waking",
phase: "waking",
},
where: {
deletedAt: null,
hibernationImageId: { not: null },
id: serverId,
observedState: { in: ["hibernated", "failed"] },
},
});
if (count === 0) {
return { error: "Server is not hibernated", ok: false };
}

try {
await start(wakeServer, [{ serverId }]);
} catch {
// The workflow never started; put the row back so the server isn't
// stranded in "waking" with nothing driving it.
await prisma.server.updateMany({
data: {
desiredState: server.desiredState,
errorReason: server.errorReason,
observedState: server.observedState,
phase: server.phase,
},
where: { id: serverId, observedState: "waking" },
});
return { error: "Failed to start wake", ok: false };
}

revalidatePath("/", "layout");
return { ok: true };
};
1 change: 1 addition & 0 deletions app/(app)/[id]/components/server-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface ServerView {
lastHeartbeatAt: string | null;
serverType: string;
backupsEnabled: boolean;
hibernationImageId: string | null;
specs: Specs | null;
location: ServerLocation | null;
settings: Record<string, unknown>;
Expand Down
82 changes: 82 additions & 0 deletions app/(app)/[id]/components/shell.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"use client";
import {
MoonIcon,
MoreHorizontalIcon,
PlayIcon,
RotateCcwIcon,
SquareIcon,
SunIcon,
Trash2Icon,
} from "lucide-react";
import Image from "next/image";
Expand Down Expand Up @@ -40,6 +42,8 @@ import { cn } from "@/lib/utils";
import { PageBody, PageHeader } from "../../components/page-header";
import { runServerCommand } from "../actions/commands";
import { deleteServer } from "../actions/delete";
import { hibernate } from "../actions/hibernate";
import { wake } from "../actions/wake";
import { ProvisioningStatus } from "./provisioning-status";
import { ServerProvider } from "./server-context";
import type { ServerView } from "./server-context";
Expand Down Expand Up @@ -109,6 +113,7 @@ export const ServerShell = ({
const [server, setServer] = useState(initial);
const [pending, setPending] = useState<null | string>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [hibernateOpen, setHibernateOpen] = useState(false);

useEffect(() => {
const t = setInterval(async () => {
Expand All @@ -124,6 +129,7 @@ export const ServerShell = ({
desiredState: fresh.desiredState,
errorReason: fresh.errorReason ?? null,
game: fresh.game,
hibernationImageId: fresh.hibernationImageId ?? null,
id: fresh.id,
ipv4: fresh.ipv4,
lastHeartbeatAt: fresh.agent?.lastHeartbeatAt ?? null,
Expand Down Expand Up @@ -159,6 +165,35 @@ export const ServerShell = ({
}
};

const runHibernate = async () => {
setPending("HIBERNATE");
try {
const result = await hibernate({ serverId: server.id });
if (result.ok) {
toast.success("Hibernating server");
setHibernateOpen(false);
} else {
toast.error(result.error);
}
} finally {
setPending(null);
}
};

const runWake = async () => {
setPending("WAKE");
try {
const result = await wake({ serverId: server.id });
if (result.ok) {
toast.success("Waking server");
} else {
toast.error(result.error);
}
} finally {
setPending(null);
}
};

const updateServer = (patch: Partial<ServerView>) =>
setServer((prev) => ({ ...prev, ...patch }));

Expand All @@ -170,6 +205,16 @@ export const ServerShell = ({
}

const deleting = server.desiredState === "deleted";
// "failed" is wake-able (or hibernate-able) again so a timed-out attempt
// isn't a dead end; which retry applies depends on which transition failed.
const canWake =
server.hibernationImageId !== null &&
(server.observedState === "hibernated" ||
(server.observedState === "failed" && server.desiredState === "running"));
const canHibernate =
server.observedState === "running" ||
server.observedState === "stopped" ||
(server.observedState === "failed" && server.desiredState === "hibernated");
const statusLabel = deleting ? "deleting" : server.observedState;

const meta = (
Expand Down Expand Up @@ -214,6 +259,21 @@ export const ServerShell = ({
Restart
</DropdownMenuItem>
<DropdownMenuSeparator />
{canWake ? (
<DropdownMenuItem disabled={pending !== null} onSelect={runWake}>
<SunIcon />
Wake
</DropdownMenuItem>
) : (
<DropdownMenuItem
disabled={pending !== null || !canHibernate}
onSelect={() => setHibernateOpen(true)}
>
<MoonIcon />
Hibernate
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={pending !== null || deleting}
onSelect={() => setDeleteOpen(true)}
Expand Down Expand Up @@ -244,6 +304,27 @@ export const ServerShell = ({
</AlertDialog>
);

const hibernateDialog = (
<AlertDialog onOpenChange={setHibernateOpen} open={hibernateOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hibernate this server?</AlertDialogTitle>
<AlertDialogDescription>
We&apos;ll snapshot the disk and release the VM to stop Hetzner
charges. Restoring takes a few minutes, and the public IP will
change on wake.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={runHibernate}>
Hibernate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);

const gameIcon = (
<Image
alt={game.name}
Expand Down Expand Up @@ -273,6 +354,7 @@ export const ServerShell = ({
/>
</PageBody>
{deleteDialog}
{hibernateDialog}
</>
);
}
Expand Down
1 change: 1 addition & 0 deletions app/(app)/[id]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const ServerLayout = async ({
dockerImage: game?.dockerImage ?? null,
errorReason: server.errorReason,
game: server.game,
hibernationImageId: server.hibernationImageId,
id: server.id,
ipv4: server.ipv4,
joinPassword: server.joinPassword,
Expand Down
20 changes: 20 additions & 0 deletions lib/providers/hetzner/servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ export const createServerOps = (client: HetznerClient) => ({
};
},

poweroffServer: async (id: ServerResourceId): Promise<void> => {
const { error, response } = await client.POST(
"/servers/{id}/actions/poweroff",
{ params: { path: { id: Number(id) } } }
);
if (!response.ok && response.status !== 422) {
throwIfHetznerError(error, response);
}
},

rescaleServer: async (
id: ServerResourceId,
serverType: string
Expand Down Expand Up @@ -140,4 +150,14 @@ export const createServerOps = (client: HetznerClient) => ({
throwIfHetznerError(error, response);
}
},

shutdownServer: async (id: ServerResourceId): Promise<void> => {
const { error, response } = await client.POST(
"/servers/{id}/actions/shutdown",
{ params: { path: { id: Number(id) } } }
);
if (!response.ok && response.status !== 422) {
throwIfHetznerError(error, response);
}
},
});
2 changes: 2 additions & 0 deletions lib/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export interface Provider {
createServer: (input: CreateServerInput) => Promise<ProviderServer>;
getServer: (id: ServerResourceId) => Promise<ProviderServer | null>;
deleteServer: (id: ServerResourceId) => Promise<{ deleted: boolean }>;
shutdownServer: (id: ServerResourceId) => Promise<void>;
poweroffServer: (id: ServerResourceId) => Promise<void>;
rescaleServer: (id: ServerResourceId, serverType: string) => Promise<void>;
getMetrics: (
id: ServerResourceId,
Expand Down
Loading