fix(panel): 启停按钮统一为带忙碌态和失败原因的组件,有玩家在线或人数未知时停服先确认
This commit is contained in:
9 files changed
+156
-70
No files matched your search
@@ -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<string | null>(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 = <Loader2 className="animate-spin" />;
|
||||||
|
|
||||||
|
let control;
|
||||||
|
if (!live) {
|
||||||
|
control = (
|
||||||
|
<Button size={size} onClick={() => void run("wake")} disabled={busy}>
|
||||||
|
{busy ? spinner : <Play />}
|
||||||
|
{busy ? t("waking") : t("wake")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
} else if (confirming) {
|
||||||
|
control = (
|
||||||
|
<div role="alert" className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<span className="text-xs font-medium text-foreground">
|
||||||
|
{players > 0
|
||||||
|
? t("stop_confirm_players", { count: players })
|
||||||
|
: t("stop_confirm_unknown")}
|
||||||
|
</span>
|
||||||
|
<Button size={size} variant="ghost" onClick={() => setConfirming(false)} disabled={busy}>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button size={size} variant="destructive" onClick={() => void run("stop")} disabled={busy}>
|
||||||
|
{busy ? spinner : <Square />}
|
||||||
|
{busy ? t("stopping") : t("stop")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
control = (
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => (askFirst ? setConfirming(true) : void run("stop"))}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? spinner : <Square />}
|
||||||
|
{busy ? t("stopping") : t("stop")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col items-end gap-1", className)}>
|
||||||
|
{control}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="max-w-xs text-right text-xs text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PhaseBadge } from "@/components/PhaseBadge";
|
import { PhaseBadge } from "@/components/PhaseBadge";
|
||||||
|
import { PowerButton } from "@/components/PowerButton";
|
||||||
import { api, humanizeError } from "@/lib/api";
|
import { api, humanizeError } from "@/lib/api";
|
||||||
import { hostFor, type RuntimeConfig } from "@/lib/config";
|
import { hostFor, type RuntimeConfig } from "@/lib/config";
|
||||||
import type { ServerInfo } from "@/lib/types";
|
import type { ServerInfo } from "@/lib/types";
|
||||||
@@ -18,11 +19,11 @@ interface Props {
|
|||||||
|
|
||||||
export function ServerCard({ server, cfg, onChanged }: Props) {
|
export function ServerCard({ server, cfg, onChanged }: Props) {
|
||||||
const { t } = useTranslation("servers");
|
const { t } = useTranslation("servers");
|
||||||
const [busy, setBusy] = useState<null | "wake" | "stop" | "claim">(null);
|
const [busy, setBusy] = useState<null | "claim">(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const host = hostFor(server.subdomain, cfg);
|
const host = hostFor(server.subdomain, cfg);
|
||||||
|
|
||||||
async function act(kind: "wake" | "stop" | "claim", fn: () => Promise<unknown>) {
|
async function act(kind: "claim", fn: () => Promise<unknown>) {
|
||||||
setBusy(kind);
|
setBusy(kind);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -93,26 +94,15 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
|
|||||||
<Hand /> {busy === "claim" ? t("claiming") : t("claim")}
|
<Hand /> {busy === "claim" ? t("claiming") : t("claim")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{server.owned &&
|
{server.owned && (
|
||||||
(running || transitioning ? (
|
<PowerButton
|
||||||
<Button
|
name={server.name}
|
||||||
size="sm"
|
live={running || transitioning}
|
||||||
variant="destructive"
|
playersOnline={server.playersOnline}
|
||||||
disabled={busy !== null}
|
playerCountUnknown={server.playerCountUnknown}
|
||||||
onClick={() => act("stop", () => api.stop(server.name))}
|
onChanged={onChanged}
|
||||||
>
|
/>
|
||||||
<Square /> {busy === "stop" ? t("stopping") : t("stop")}
|
)}
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="default"
|
|
||||||
disabled={busy !== null}
|
|
||||||
onClick={() => act("wake", () => api.wake(server.name))}
|
|
||||||
>
|
|
||||||
<Play /> {busy === "wake" ? t("waking") : t("wake")}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon, SearchX } from "lucide-react";
|
import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon, SearchX } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { PowerButton } from "@/components/PowerButton";
|
||||||
import { humanizeError } from "@/lib/api";
|
import { humanizeError } from "@/lib/api";
|
||||||
|
|
||||||
export function Loading({ label }: { label?: string }) {
|
export function Loading({ label }: { label?: string }) {
|
||||||
@@ -136,11 +136,13 @@ export function NotYours({ title, body, linkTo = "/servers", linkLabel }: NotYou
|
|||||||
export interface NotRunningProps {
|
export interface NotRunningProps {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
wakeLabel: string;
|
/** The server to wake from here. */
|
||||||
onWake: () => void;
|
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 (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-border bg-muted/20 py-16 text-center">
|
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-border bg-muted/20 py-16 text-center">
|
||||||
<Moon className="h-8 w-8 text-muted-foreground/70" />
|
<Moon className="h-8 w-8 text-muted-foreground/70" />
|
||||||
@@ -148,9 +150,7 @@ export function NotRunning({ title, body, wakeLabel, onWake }: NotRunningProps)
|
|||||||
<p className="font-medium">{title}</p>
|
<p className="font-medium">{title}</p>
|
||||||
<p className="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">{body}</p>
|
<p className="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">{body}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={onWake}>
|
<PowerButton name={serverName} live={false} onChanged={onWoken} className="items-center" />
|
||||||
{wakeLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -178,5 +178,8 @@
|
|||||||
"idle_stop_never": "Never",
|
"idle_stop_never": "Never",
|
||||||
"idle_stop_seconds": "{{count}} s",
|
"idle_stop_seconds": "{{count}} s",
|
||||||
"idle_stop_minutes": "{{count}} min",
|
"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?"
|
||||||
}
|
}
|
||||||
@@ -178,5 +178,7 @@
|
|||||||
"idle_stop_never": "从不",
|
"idle_stop_never": "从不",
|
||||||
"idle_stop_seconds": "{{count}} 秒",
|
"idle_stop_seconds": "{{count}} 秒",
|
||||||
"idle_stop_minutes": "{{count}} 分钟",
|
"idle_stop_minutes": "{{count}} 分钟",
|
||||||
"idle_stop_hours": "{{count}} 小时"
|
"idle_stop_hours": "{{count}} 小时",
|
||||||
|
"stop_confirm_players": "{{count}} 名玩家在线,停服会断开他们。确定停止?",
|
||||||
|
"stop_confirm_unknown": "读不到在线人数,可能有玩家在线。确定停止?"
|
||||||
}
|
}
|
||||||
@@ -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 { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, FolderOpen, ChevronRight, type LucideIcon } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { BackLink } from "@/components/BackLink";
|
import { BackLink } from "@/components/BackLink";
|
||||||
import { PhaseBadge } from "@/components/PhaseBadge";
|
import { PhaseBadge } from "@/components/PhaseBadge";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
@@ -16,6 +15,7 @@ import { canManage } from "@/lib/ownership";
|
|||||||
import { hostFor } from "@/lib/config";
|
import { hostFor } from "@/lib/config";
|
||||||
import type { Phase, AutostartPolicy } from "@/lib/types";
|
import type { Phase, AutostartPolicy } from "@/lib/types";
|
||||||
import { EditServerDialog } from "@/components/EditServerDialog";
|
import { EditServerDialog } from "@/components/EditServerDialog";
|
||||||
|
import { PowerButton } from "@/components/PowerButton";
|
||||||
|
|
||||||
// notStreamingCopy explains why there is no live feed for a phase that has no
|
// 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
|
// streamable pod. The read path only has something to relay once a pod is up, so
|
||||||
@@ -226,19 +226,13 @@ export function ServerConsole() {
|
|||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<PhaseBadge phase={data.phase} />
|
<PhaseBadge phase={data.phase} />
|
||||||
{streamable ? (
|
<PowerButton
|
||||||
<Button
|
name={name}
|
||||||
variant="destructive"
|
live={streamable || data.phase === "Stopping"}
|
||||||
size="sm"
|
playersOnline={data.playersOnline}
|
||||||
onClick={() => api.stop(name).then(reload)}
|
playerCountUnknown={data.playerCountUnknown}
|
||||||
>
|
onChanged={reload}
|
||||||
{t("stop")}
|
/>
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button variant="default" size="sm" onClick={() => api.wake(name).then(reload)}>
|
|
||||||
{t("wake")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
className="mb-6"
|
className="mb-6"
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ export function ServerLuckPerms() {
|
|||||||
) : !owned ? (
|
) : !owned ? (
|
||||||
<NotYours title={t("players_not_yours_title")} body={t("players_not_yours_body")} />
|
<NotYours title={t("players_not_yours_title")} body={t("players_not_yours_body")} />
|
||||||
) : phase !== "Running" ? (
|
) : phase !== "Running" ? (
|
||||||
<NotRunning title={t("players_not_running_title")} body={t("players_not_running_body")} wakeLabel={t("wake")} onWake={() => api.wake(name).then(reload)} />
|
<NotRunning title={t("players_not_running_title")} body={t("players_not_running_body")} serverName={name} onWoken={reload} />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
|
||||||
{/* Left Directory Sidebar: Single card for Search + Online Players */}
|
{/* Left Directory Sidebar: Single card for Search + Online Players */}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function ServerPlayers() {
|
|||||||
) : !owned ? (
|
) : !owned ? (
|
||||||
<NotYours title={t("players_not_yours_title")} body={t("players_not_yours_body")} />
|
<NotYours title={t("players_not_yours_title")} body={t("players_not_yours_body")} />
|
||||||
) : phase !== "Running" ? (
|
) : phase !== "Running" ? (
|
||||||
<NotRunning title={t("players_not_running_title")} body={t("players_not_running_body")} wakeLabel={t("wake")} onWake={() => api.wake(name).then(reload)} />
|
<NotRunning title={t("players_not_running_title")} body={t("players_not_running_body")} serverName={name} onWoken={reload} />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Ordered as a who-may-be-here gradient: who is on right now → who may
|
{/* Ordered as a who-may-be-here gradient: who is on right now → who may
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Link } from "react-router-dom";
|
|||||||
import {
|
import {
|
||||||
Network,
|
Network,
|
||||||
Play,
|
Play,
|
||||||
Square,
|
|
||||||
Terminal,
|
Terminal,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -33,6 +32,7 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { PhaseBadge, PHASE_KEY, PHASE_COLOR } from "@/components/PhaseBadge";
|
import { PhaseBadge, PHASE_KEY, PHASE_COLOR } from "@/components/PhaseBadge";
|
||||||
|
import { PowerButton } from "@/components/PowerButton";
|
||||||
import { Loading, ErrorState, EmptyState } from "@/components/States";
|
import { Loading, ErrorState, EmptyState } from "@/components/States";
|
||||||
import { Pagination } from "@/components/Pagination";
|
import { Pagination } from "@/components/Pagination";
|
||||||
import { CreateServerDialog } from "@/components/CreateServerDialog";
|
import { CreateServerDialog } from "@/components/CreateServerDialog";
|
||||||
@@ -78,6 +78,7 @@ interface UnifiedServer {
|
|||||||
autostartPolicy?: AutostartPolicy;
|
autostartPolicy?: AutostartPolicy;
|
||||||
playersOnline: number;
|
playersOnline: number;
|
||||||
playersMax: number;
|
playersMax: number;
|
||||||
|
playerCountUnknown?: boolean;
|
||||||
owner?: string;
|
owner?: string;
|
||||||
endpointAddress?: string | null;
|
endpointAddress?: string | null;
|
||||||
claimable?: boolean;
|
claimable?: boolean;
|
||||||
@@ -139,6 +140,7 @@ export function ServersPage() {
|
|||||||
autostartPolicy: s.autostartPolicy,
|
autostartPolicy: s.autostartPolicy,
|
||||||
playersOnline: s.playersOnline ?? 0,
|
playersOnline: s.playersOnline ?? 0,
|
||||||
playersMax: s.playersMax ?? 0,
|
playersMax: s.playersMax ?? 0,
|
||||||
|
playerCountUnknown: s.playerCountUnknown,
|
||||||
owner: s.owned ? t("servers:owned_filter_mine") || "me" : undefined,
|
owner: s.owned ? t("servers:owned_filter_mine") || "me" : undefined,
|
||||||
claimable: s.claimable,
|
claimable: s.claimable,
|
||||||
owned: s.owned,
|
owned: s.owned,
|
||||||
@@ -402,11 +404,11 @@ function ServerRow({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("ops");
|
const { t } = useTranslation("ops");
|
||||||
const { t: ts } = useTranslation("servers");
|
const { t: ts } = useTranslation("servers");
|
||||||
const [busy, setBusy] = useState<null | "wake" | "stop" | "claim">(null);
|
const [busy, setBusy] = useState<null | "claim">(null);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function act(kind: "wake" | "stop" | "claim", fn: () => Promise<unknown>) {
|
async function act(kind: "claim", fn: () => Promise<unknown>) {
|
||||||
setBusy(kind);
|
setBusy(kind);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -538,25 +540,13 @@ function ServerRow({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{live ? (
|
<PowerButton
|
||||||
<Button
|
name={server.name}
|
||||||
size="sm"
|
live={live}
|
||||||
variant="destructive"
|
playersOnline={server.playersOnline}
|
||||||
disabled={busy !== null}
|
playerCountUnknown={server.playerCountUnknown}
|
||||||
onClick={() => act("stop", () => api.stop(server.name))}
|
onChanged={onChanged}
|
||||||
>
|
/>
|
||||||
<Square /> {busy === "stop" ? ts("stopping") : ts("stop")}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="default"
|
|
||||||
disabled={busy !== null}
|
|
||||||
onClick={() => act("wake", () => api.wake(server.name))}
|
|
||||||
>
|
|
||||||
<Play /> {busy === "wake" ? ts("waking") : ts("wake")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{(server.owned || isAdmin) && (
|
{(server.owned || isAdmin) && (
|
||||||
<Link to={`/servers/${server.name}`}>
|
<Link to={`/servers/${server.name}`}>
|
||||||
<Button size="sm" variant="outline">
|
<Button size="sm" variant="outline">
|
||||||
|
|||||||
Reference in new issue
Block a user