diff --git a/panel/src/components/PowerButton.tsx b/panel/src/components/PowerButton.tsx new file mode 100644 index 0000000..b02fd74 --- /dev/null +++ b/panel/src/components/PowerButton.tsx @@ -0,0 +1,107 @@ +import { useState } from "react"; +import { Loader2, Play, Square } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { api, humanizeError } from "@/lib/api"; +import { cn } from "@/lib/utils"; + +interface Props { + name: string; + /** A pod is up or on its way (Starting/Running/Stopping): offer Stop. */ + live: boolean; + playersOnline?: number; + /** The operator cannot read the player count, so players may be online. */ + playerCountUnknown?: boolean; + /** Called after the wake or stop was accepted, so the parent refetches. */ + onChanged: () => void; + size?: "sm" | "default"; + className?: string; +} + +// PowerButton starts or stops one server. It is busy while the call runs (no +// double send), shows why a call was refused (quota, cooldown, a phase that +// moved on), and asks before a stop that would disconnect players: the count +// is in the question, and an unreadable count asks too. +export function PowerButton({ + name, + live, + playersOnline, + playerCountUnknown, + onChanged, + size = "sm", + className, +}: Props) { + const { t } = useTranslation("servers"); + const [busy, setBusy] = useState(false); + const [confirming, setConfirming] = useState(false); + const [error, setError] = useState(null); + + async function run(kind: "wake" | "stop") { + if (busy) return; + setBusy(true); + setError(null); + try { + await (kind === "wake" ? api.wake(name) : api.stop(name)); + setConfirming(false); + onChanged(); + } catch (e) { + setError(humanizeError(e)); + } finally { + setBusy(false); + } + } + + const players = playersOnline ?? 0; + const askFirst = players > 0 || playerCountUnknown === true; + const spinner = ; + + let control; + if (!live) { + control = ( + + ); + } else if (confirming) { + control = ( +
+ + {players > 0 + ? t("stop_confirm_players", { count: players }) + : t("stop_confirm_unknown")} + + + +
+ ); + } else { + control = ( + + ); + } + + return ( +
+ {control} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/panel/src/components/ServerCard.tsx b/panel/src/components/ServerCard.tsx index a5dfbd8..f6fbb0a 100644 --- a/panel/src/components/ServerCard.tsx +++ b/panel/src/components/ServerCard.tsx @@ -1,10 +1,11 @@ import { useState } from "react"; import { Link } from "react-router-dom"; -import { Play, Square, Hand, Users, ExternalLink, Terminal } from "lucide-react"; +import { Hand, Users, ExternalLink, Terminal } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { PhaseBadge } from "@/components/PhaseBadge"; +import { PowerButton } from "@/components/PowerButton"; import { api, humanizeError } from "@/lib/api"; import { hostFor, type RuntimeConfig } from "@/lib/config"; import type { ServerInfo } from "@/lib/types"; @@ -18,11 +19,11 @@ interface Props { export function ServerCard({ server, cfg, onChanged }: Props) { const { t } = useTranslation("servers"); - const [busy, setBusy] = useState(null); + const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const host = hostFor(server.subdomain, cfg); - async function act(kind: "wake" | "stop" | "claim", fn: () => Promise) { + async function act(kind: "claim", fn: () => Promise) { setBusy(kind); setError(null); try { @@ -93,26 +94,15 @@ export function ServerCard({ server, cfg, onChanged }: Props) { {busy === "claim" ? t("claiming") : t("claim")} )} - {server.owned && - (running || transitioning ? ( - - ) : ( - - ))} + {server.owned && ( + + )} ); diff --git a/panel/src/components/States.tsx b/panel/src/components/States.tsx index 52f45ef..30b96cc 100644 --- a/panel/src/components/States.tsx +++ b/panel/src/components/States.tsx @@ -1,7 +1,7 @@ import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon, SearchX } from "lucide-react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { Button } from "@/components/ui/button"; +import { PowerButton } from "@/components/PowerButton"; import { humanizeError } from "@/lib/api"; export function Loading({ label }: { label?: string }) { @@ -136,11 +136,13 @@ export function NotYours({ title, body, linkTo = "/servers", linkLabel }: NotYou export interface NotRunningProps { title: string; body: string; - wakeLabel: string; - onWake: () => void; + /** The server to wake from here. */ + serverName: string; + /** Called once the wake was accepted, so the page refetches its status. */ + onWoken: () => void; } -export function NotRunning({ title, body, wakeLabel, onWake }: NotRunningProps) { +export function NotRunning({ title, body, serverName, onWoken }: NotRunningProps) { return (
@@ -148,9 +150,7 @@ export function NotRunning({ title, body, wakeLabel, onWake }: NotRunningProps)

{title}

{body}

- + ); } diff --git a/panel/src/i18n/resources/en-US/servers.json b/panel/src/i18n/resources/en-US/servers.json index 65720c3..102d46f 100644 --- a/panel/src/i18n/resources/en-US/servers.json +++ b/panel/src/i18n/resources/en-US/servers.json @@ -178,5 +178,8 @@ "idle_stop_never": "Never", "idle_stop_seconds": "{{count}} s", "idle_stop_minutes": "{{count}} min", - "idle_stop_hours": "{{count}} h" + "idle_stop_hours": "{{count}} h", + "stop_confirm_players_one": "1 player is online and will be disconnected. Stop anyway?", + "stop_confirm_players_other": "{{count}} players are online and will be disconnected. Stop anyway?", + "stop_confirm_unknown": "The player count can't be read, so players may be online. Stop anyway?" } diff --git a/panel/src/i18n/resources/zh-CN/servers.json b/panel/src/i18n/resources/zh-CN/servers.json index 83f75d6..9d48c9c 100644 --- a/panel/src/i18n/resources/zh-CN/servers.json +++ b/panel/src/i18n/resources/zh-CN/servers.json @@ -178,5 +178,7 @@ "idle_stop_never": "从不", "idle_stop_seconds": "{{count}} 秒", "idle_stop_minutes": "{{count}} 分钟", - "idle_stop_hours": "{{count}} 小时" + "idle_stop_hours": "{{count}} 小时", + "stop_confirm_players": "{{count}} 名玩家在线,停服会断开他们。确定停止?", + "stop_confirm_unknown": "读不到在线人数,可能有玩家在线。确定停止?" } diff --git a/panel/src/pages/ServerConsole.tsx b/panel/src/pages/ServerConsole.tsx index 9fb7bd0..349f90d 100644 --- a/panel/src/pages/ServerConsole.tsx +++ b/panel/src/pages/ServerConsole.tsx @@ -3,7 +3,6 @@ import { Link, useParams } from "react-router-dom"; import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, FolderOpen, ChevronRight, type LucideIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Card, CardContent } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; import { BackLink } from "@/components/BackLink"; import { PhaseBadge } from "@/components/PhaseBadge"; import { PageHeader } from "@/components/PageHeader"; @@ -16,6 +15,7 @@ import { canManage } from "@/lib/ownership"; import { hostFor } from "@/lib/config"; import type { Phase, AutostartPolicy } from "@/lib/types"; import { EditServerDialog } from "@/components/EditServerDialog"; +import { PowerButton } from "@/components/PowerButton"; // notStreamingCopy explains why there is no live feed for a phase that has no // streamable pod. The read path only has something to relay once a pod is up, so @@ -226,19 +226,13 @@ export function ServerConsole() { actions={
- {streamable ? ( - - ) : ( - - )} +
} className="mb-6" diff --git a/panel/src/pages/ServerLuckPerms.tsx b/panel/src/pages/ServerLuckPerms.tsx index 58eb828..76a3db1 100644 --- a/panel/src/pages/ServerLuckPerms.tsx +++ b/panel/src/pages/ServerLuckPerms.tsx @@ -325,7 +325,7 @@ export function ServerLuckPerms() { ) : !owned ? ( ) : phase !== "Running" ? ( - api.wake(name).then(reload)} /> + ) : (
{/* Left Directory Sidebar: Single card for Search + Online Players */} diff --git a/panel/src/pages/ServerPlayers.tsx b/panel/src/pages/ServerPlayers.tsx index dff09d7..c3093aa 100644 --- a/panel/src/pages/ServerPlayers.tsx +++ b/panel/src/pages/ServerPlayers.tsx @@ -84,7 +84,7 @@ export function ServerPlayers() { ) : !owned ? ( ) : phase !== "Running" ? ( - api.wake(name).then(reload)} /> + ) : (
{/* Ordered as a who-may-be-here gradient: who is on right now → who may diff --git a/panel/src/pages/servers/ServersPage.tsx b/panel/src/pages/servers/ServersPage.tsx index 8b3b59f..1db740d 100644 --- a/panel/src/pages/servers/ServersPage.tsx +++ b/panel/src/pages/servers/ServersPage.tsx @@ -3,7 +3,6 @@ import { Link } from "react-router-dom"; import { Network, Play, - Square, Terminal, ExternalLink, RefreshCw, @@ -33,6 +32,7 @@ import { SelectItem, } from "@/components/ui/select"; import { PhaseBadge, PHASE_KEY, PHASE_COLOR } from "@/components/PhaseBadge"; +import { PowerButton } from "@/components/PowerButton"; import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Pagination } from "@/components/Pagination"; import { CreateServerDialog } from "@/components/CreateServerDialog"; @@ -78,6 +78,7 @@ interface UnifiedServer { autostartPolicy?: AutostartPolicy; playersOnline: number; playersMax: number; + playerCountUnknown?: boolean; owner?: string; endpointAddress?: string | null; claimable?: boolean; @@ -139,6 +140,7 @@ export function ServersPage() { autostartPolicy: s.autostartPolicy, playersOnline: s.playersOnline ?? 0, playersMax: s.playersMax ?? 0, + playerCountUnknown: s.playerCountUnknown, owner: s.owned ? t("servers:owned_filter_mine") || "me" : undefined, claimable: s.claimable, owned: s.owned, @@ -402,11 +404,11 @@ function ServerRow({ }) { const { t } = useTranslation("ops"); const { t: ts } = useTranslation("servers"); - const [busy, setBusy] = useState(null); + const [busy, setBusy] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); const [error, setError] = useState(null); - async function act(kind: "wake" | "stop" | "claim", fn: () => Promise) { + async function act(kind: "claim", fn: () => Promise) { setBusy(kind); setError(null); try { @@ -538,25 +540,13 @@ function ServerRow({ ) : ( <> - {live ? ( - - ) : ( - - )} + {(server.owned || isAdmin) && (