feat(panel): add back up now and recent operations to the backups page
The backups page could list and restore archives but not create one,
and nothing surfaced backup/restore Job outcomes — a failed 202 was
visible only through kubectl. Add a Back up now action (enabled only on
a stopped server, the backend's own gate; a raced 409 is surfaced in
its words) and a Recent operations card fed by GET /servers/{name}/jobs
that shows running/succeeded/failed with the Job's failure message,
re-reads on an interval while a Job is running, and persists across
reloads. Wire-shape tests pin both endpoints.
This commit is contained in:
6 files changed
+255
-12
No files matched your search
@@ -9,11 +9,22 @@
|
|||||||
"reason_inactive": "Idle archive",
|
"reason_inactive": "Idle archive",
|
||||||
"reason_manual": "Manual backup",
|
"reason_manual": "Manual backup",
|
||||||
"reason_label": "Reason: {{reason}}",
|
"reason_label": "Reason: {{reason}}",
|
||||||
|
"backup_now": "Back up now",
|
||||||
|
"backup_in_progress": "Starting backup…",
|
||||||
|
"backup_started": "Backup started — its progress shows under recent operations below.",
|
||||||
|
"backup_requires_stopped": "Stop the server before backing it up.",
|
||||||
"expires_in": "Expires {{when}}",
|
"expires_in": "Expires {{when}}",
|
||||||
"expired": "Expired",
|
"expired": "Expired",
|
||||||
"former_owner": "Former owner: {{owner}}",
|
"former_owner": "Former owner: {{owner}}",
|
||||||
"empty_title": "No backups for this server",
|
"empty_title": "No backups for this server",
|
||||||
"empty_hint": "A backup is saved only when a world is archived after long inactivity.",
|
"empty_hint": "A backup is saved when a world is archived after long inactivity — or create one now with “Back up now”.",
|
||||||
|
"jobs_title": "Recent operations",
|
||||||
|
"jobs_empty": "No recent backup or restore operations.",
|
||||||
|
"job_backup": "Backup",
|
||||||
|
"job_restore": "Restore",
|
||||||
|
"job_running": "Running",
|
||||||
|
"job_succeeded": "Succeeded",
|
||||||
|
"job_failed": "Failed",
|
||||||
"restore_btn": "Restore this backup",
|
"restore_btn": "Restore this backup",
|
||||||
"restore_btn_short": "Restore",
|
"restore_btn_short": "Restore",
|
||||||
"restore_confirm": "Restoring a backup will stop the server (all online players will be disconnected) and completely overwrite the current world with this backup. This operation is irreversible. Do you want to continue?",
|
"restore_confirm": "Restoring a backup will stop the server (all online players will be disconnected) and completely overwrite the current world with this backup. This operation is irreversible. Do you want to continue?",
|
||||||
|
|||||||
@@ -9,11 +9,22 @@
|
|||||||
"reason_inactive": "闲置自动回收",
|
"reason_inactive": "闲置自动回收",
|
||||||
"reason_manual": "手动备份",
|
"reason_manual": "手动备份",
|
||||||
"reason_label": "原因:{{reason}}",
|
"reason_label": "原因:{{reason}}",
|
||||||
|
"backup_now": "立即备份",
|
||||||
|
"backup_in_progress": "正在启动备份……",
|
||||||
|
"backup_started": "已开始备份——进度可在下方“最近操作”中查看。",
|
||||||
|
"backup_requires_stopped": "备份前需要先停止服务器。",
|
||||||
"expires_in": "{{when}}过期",
|
"expires_in": "{{when}}过期",
|
||||||
"expired": "已过期",
|
"expired": "已过期",
|
||||||
"former_owner": "原世界所有者:{{owner}}",
|
"former_owner": "原世界所有者:{{owner}}",
|
||||||
"empty_title": "暂无备份",
|
"empty_title": "暂无备份",
|
||||||
"empty_hint": "仅在服务器长期闲置被系统自动回收时才会生成备份。",
|
"empty_hint": "服务器长期闲置被自动回收时会生成备份,也可以点“立即备份”马上创建。",
|
||||||
|
"jobs_title": "最近操作",
|
||||||
|
"jobs_empty": "暂无备份或恢复操作记录。",
|
||||||
|
"job_backup": "备份",
|
||||||
|
"job_restore": "恢复",
|
||||||
|
"job_running": "进行中",
|
||||||
|
"job_succeeded": "成功",
|
||||||
|
"job_failed": "失败",
|
||||||
"restore_btn": "恢复此备份",
|
"restore_btn": "恢复此备份",
|
||||||
"restore_btn_short": "恢复",
|
"restore_btn_short": "恢复",
|
||||||
"restore_confirm": "恢复备份将停止服务器(所有在线玩家将被断开),并用该备份完全覆盖当前世界的全部数据。此操作不可逆,请确认是否继续?",
|
"restore_confirm": "恢复备份将停止服务器(所有在线玩家将被断开),并用该备份完全覆盖当前世界的全部数据。此操作不可逆,请确认是否继续?",
|
||||||
|
|||||||
@@ -549,4 +549,36 @@ describe("image whitelist and builds wire shapes", () => {
|
|||||||
expect(JSON.parse((opts as RequestInit).body as string)).toEqual(win);
|
expect(JSON.parse((opts as RequestInit).body as string)).toEqual(win);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("backup now and server jobs wire shapes", () => {
|
||||||
|
it("backupNow POSTs to /servers/{name}/backup with no body and parses the 202", async () => {
|
||||||
|
const fetchSpy = fakeFetch({ name: "survival", status: "backing_up" }, { status: 202 });
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const res = await api.backupNow("survival");
|
||||||
|
expect(res.status).toBe("backing_up");
|
||||||
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(String(url)).toBe("/servers/survival/backup");
|
||||||
|
expect((opts as RequestInit).method).toBe("POST");
|
||||||
|
expect((opts as RequestInit).body).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serverJobs GETs /servers/{name}/jobs and unwraps the jobs array", async () => {
|
||||||
|
const job = {
|
||||||
|
name: "felis-backup-survival-123",
|
||||||
|
kind: "backup",
|
||||||
|
state: "failed",
|
||||||
|
message: "pod terminated",
|
||||||
|
started_at: "2026-07-03T12:00:00Z",
|
||||||
|
};
|
||||||
|
const fetchSpy = fakeFetch({ server: "survival", jobs: [job] });
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const res = await api.serverJobs("survival");
|
||||||
|
expect(res).toHaveLength(1);
|
||||||
|
expect(res[0].state).toBe("failed");
|
||||||
|
expect(res[0].message).toBe("pod terminated");
|
||||||
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(String(url)).toBe("/servers/survival/jobs");
|
||||||
|
expect((opts as RequestInit).method).toBe("GET");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
PlayersResult,
|
PlayersResult,
|
||||||
QuotaInput,
|
QuotaInput,
|
||||||
QuotaView,
|
QuotaView,
|
||||||
|
ServerJob,
|
||||||
ServerInfo,
|
ServerInfo,
|
||||||
SessionView,
|
SessionView,
|
||||||
UserDetail,
|
UserDetail,
|
||||||
@@ -339,6 +340,25 @@ export const api = {
|
|||||||
backupId ? { backup_id: backupId } : undefined,
|
backupId ? { backup_id: backupId } : undefined,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// backupNow enqueues a manual backup (spec §7 POST backup). Preconditions are
|
||||||
|
// enforced server-side and surfaced as codes: owner-or-admin (403) and the
|
||||||
|
// server MUST be fully stopped (409 not_stopped — the world volume is RWO), so
|
||||||
|
// callers gate the action on phase === "Stopped". The reply is 202
|
||||||
|
// {name, status:"backing_up"}: the Job is enqueued, not done — watch
|
||||||
|
// serverJobs for the outcome.
|
||||||
|
backupNow: (name: string) =>
|
||||||
|
request<{ name: string; status: string }>("POST", `/servers/${name}/backup`),
|
||||||
|
|
||||||
|
// serverJobs lists the newest backup/restore Jobs of one server, newest first
|
||||||
|
// (GET /servers/{name}/jobs). Owner-or-admin gated server-side; a Job's
|
||||||
|
// failure text rides `message`. The backend answers 503 until the job-status
|
||||||
|
// reader is wired, so callers should tolerate that error.
|
||||||
|
serverJobs: (name: string) =>
|
||||||
|
request<{ server: string; jobs: ServerJob[] }>(
|
||||||
|
"GET",
|
||||||
|
`/servers/${name}/jobs`,
|
||||||
|
).then((r) => r.jobs ?? []),
|
||||||
|
|
||||||
// Account linking (spec §10). Both are POST: start reports status from the
|
// Account linking (spec §10). Both are POST: start reports status from the
|
||||||
// session principal (no body, side-effect-free), verify consumes a code the
|
// session principal (no body, side-effect-free), verify consumes a code the
|
||||||
// player was shown in-game. The panel can never mint a code — that is the
|
// player was shown in-game. The panel can never mint a code — that is the
|
||||||
|
|||||||
@@ -146,6 +146,18 @@ export interface BackupView {
|
|||||||
expires_at: string;
|
expires_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ServerJob is one row of GET /api/v1/servers/{name}/jobs — the observable
|
||||||
|
* outcome of an async backup/restore Job. The API only enqueues Jobs, so this
|
||||||
|
* projection is how a 202 that later failed becomes visible in the panel. */
|
||||||
|
export interface ServerJob {
|
||||||
|
name: string;
|
||||||
|
kind: string; // "backup" | "restore"
|
||||||
|
state: string; // "running" | "succeeded" | "failed"
|
||||||
|
message?: string;
|
||||||
|
started_at?: string;
|
||||||
|
finished_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** WhitelistImage is one row of GET /images (the create-form dropdown source). */
|
/** WhitelistImage is one row of GET /images (the create-form dropdown source). */
|
||||||
export interface WhitelistImage {
|
export interface WhitelistImage {
|
||||||
image_ref: string;
|
image_ref: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
@@ -8,12 +8,14 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
UserMinus,
|
UserMinus,
|
||||||
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { BackLink } from "@/components/BackLink";
|
import { BackLink } from "@/components/BackLink";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { ConfirmFooter } from "@/components/ConfirmFooter";
|
import { ConfirmFooter } from "@/components/ConfirmFooter";
|
||||||
|
import { MessageLine } from "@/components/MessageLine";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -134,6 +136,37 @@ function BackupRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** JobStateBadge renders one async Job's state (backup/restore Job history). The
|
||||||
|
* state vocabulary is the API's ("running" | "succeeded" | "failed"); anything
|
||||||
|
* unknown is shown verbatim rather than hidden. */
|
||||||
|
function JobStateBadge({ state }: { state: string }) {
|
||||||
|
const { t } = useTranslation("backups");
|
||||||
|
if (state === "running") {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-md bg-sky-500/10 px-1.5 py-0.5 text-[10px] font-medium text-sky-500 ring-1 ring-inset ring-sky-500/20">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
{t("job_running")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === "succeeded") {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-500 ring-1 ring-inset ring-emerald-500/20">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
{t("job_succeeded")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === "failed") {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-md bg-destructive/10 px-1.5 py-0.5 text-[10px] font-medium text-destructive ring-1 ring-inset ring-destructive/20">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
{t("job_failed")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="text-[10px] font-medium text-muted-foreground">{state}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/** RestoreControls is the restore ACTION, living only on the latest backup card.
|
/** RestoreControls is the restore ACTION, living only on the latest backup card.
|
||||||
@@ -339,6 +372,50 @@ export function ServerBackups() {
|
|||||||
);
|
);
|
||||||
const backupsQ = useAsync(() => api.listBackups(), []);
|
const backupsQ = useAsync(() => api.listBackups(), []);
|
||||||
|
|
||||||
|
// Ownership resolves from /me/servers for a non-admin (status carries no `owned`).
|
||||||
|
// While it is pending show the header with a spinner rather than flashing the list
|
||||||
|
// at someone who may not own it; if that read itself failed, break to a retry so a
|
||||||
|
// real owner never fails closed to NotYours on a transient blip.
|
||||||
|
const ownershipPending = tierLoading || (!isAdmin && mineQ.data === null && !mineQ.error);
|
||||||
|
const owned = isAdmin || (mineQ.data ?? []).some((s) => s.name === name && s.owned === true);
|
||||||
|
|
||||||
|
// The async world-operation history (the backup/restore Jobs behind every 202).
|
||||||
|
// Read only once the viewer is resolved as owner-or-admin (the route 403s
|
||||||
|
// otherwise); while anything is still running it re-reads on an interval so the
|
||||||
|
// enqueue converges to succeeded/failed here instead of only in kubectl.
|
||||||
|
const jobsQ = useAsync(
|
||||||
|
() => (owned ? api.serverJobs(name) : Promise.resolve([])),
|
||||||
|
[name, owned],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!(jobsQ.data ?? []).some((j) => j.state === "running")) return;
|
||||||
|
const id = setInterval(jobsQ.reload, 5000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [jobsQ.data, jobsQ.reload]);
|
||||||
|
|
||||||
|
const [backingUp, setBackingUp] = useState(false);
|
||||||
|
const [backupMsg, setBackupMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||||
|
|
||||||
|
// Manual backup (POST /backup): the backend enforces the stopped gate, so the
|
||||||
|
// button only enables on Stopped and a raced 409 is surfaced in its own words.
|
||||||
|
async function handleBackupNow() {
|
||||||
|
if (backingUp) return;
|
||||||
|
setBackingUp(true);
|
||||||
|
setBackupMsg(null);
|
||||||
|
try {
|
||||||
|
await api.backupNow(name);
|
||||||
|
setBackupMsg({ kind: "success", text: t("backup_started") });
|
||||||
|
jobsQ.reload();
|
||||||
|
} catch (e: any) {
|
||||||
|
setBackupMsg({
|
||||||
|
kind: "error",
|
||||||
|
text: e && e.code === "not_stopped" ? t("backup_requires_stopped") : humanizeError(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBackingUp(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const back = (
|
const back = (
|
||||||
<BackLink to={`/servers/${name}`} label={t("back_to_console")} />
|
<BackLink to={`/servers/${name}`} label={t("back_to_console")} />
|
||||||
);
|
);
|
||||||
@@ -361,13 +438,6 @@ export function ServerBackups() {
|
|||||||
}
|
}
|
||||||
if (!statusQ.data) return back;
|
if (!statusQ.data) return back;
|
||||||
|
|
||||||
// Ownership resolves from /me/servers for a non-admin (status carries no `owned`).
|
|
||||||
// While it is pending show the header with a spinner rather than flashing the list
|
|
||||||
// at someone who may not own it; if that read itself failed, break to a retry so a
|
|
||||||
// real owner never fails closed to NotYours on a transient blip.
|
|
||||||
const ownershipPending = tierLoading || (!isAdmin && mineQ.data === null && !mineQ.error);
|
|
||||||
const owned = isAdmin || (mineQ.data ?? []).some((s) => s.name === name && s.owned === true);
|
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const locale = i18n.language;
|
const locale = i18n.language;
|
||||||
// The global list, narrowed to this server. Already created_at-descending from the
|
// The global list, narrowed to this server. Already created_at-descending from the
|
||||||
@@ -381,7 +451,29 @@ export function ServerBackups() {
|
|||||||
icon={Archive}
|
icon={Archive}
|
||||||
title={statusQ.data.displayName || statusQ.data.name}
|
title={statusQ.data.displayName || statusQ.data.name}
|
||||||
subtitle={t("title")}
|
subtitle={t("title")}
|
||||||
actions={<PhaseBadge phase={statusQ.data.phase} />}
|
actions={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{owned && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleBackupNow}
|
||||||
|
disabled={backingUp || statusQ.data.phase !== "Stopped"}
|
||||||
|
title={
|
||||||
|
statusQ.data.phase !== "Stopped" ? t("backup_requires_stopped") : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{backingUp ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Archive className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{backingUp ? t("backup_in_progress") : t("backup_now")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<PhaseBadge phase={statusQ.data.phase} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
className="mb-6"
|
className="mb-6"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -398,6 +490,7 @@ export function ServerBackups() {
|
|||||||
<NotYours title={t("not_yours_title")} body={t("not_yours_body")} />
|
<NotYours title={t("not_yours_title")} body={t("not_yours_body")} />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{backupMsg && <MessageLine kind={backupMsg.kind} message={backupMsg.text} />}
|
||||||
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
|
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||||
{backupsQ.loading && !backupsQ.data ? (
|
{backupsQ.loading && !backupsQ.data ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
@@ -429,7 +522,10 @@ export function ServerBackups() {
|
|||||||
locale={locale}
|
locale={locale}
|
||||||
showOwner={isAdmin}
|
showOwner={isAdmin}
|
||||||
serverName={name}
|
serverName={name}
|
||||||
onReloadStatus={statusQ.reload}
|
onReloadStatus={() => {
|
||||||
|
statusQ.reload();
|
||||||
|
jobsQ.reload();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -444,6 +540,67 @@ export function ServerBackups() {
|
|||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Async world-operation history: every backup/restore 202 lands here, so a
|
||||||
|
Job that later failed stays visible (with its message) without kubectl. */}
|
||||||
|
{jobsQ.error ? (
|
||||||
|
<ErrorState error={jobsQ.error} onRetry={jobsQ.reload} />
|
||||||
|
) : (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
{t("jobs_title")}
|
||||||
|
</div>
|
||||||
|
{(jobsQ.data ?? []).length === 0 ? (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">{t("jobs_empty")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-2 divide-y divide-border">
|
||||||
|
{(jobsQ.data ?? []).map((j) => {
|
||||||
|
const at = j.started_at || j.finished_at;
|
||||||
|
return (
|
||||||
|
<li key={j.name} className="flex items-start justify-between gap-3 py-2.5">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{j.kind === "restore" ? (
|
||||||
|
<RotateCcw className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
|
||||||
|
) : (
|
||||||
|
<Archive className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm">
|
||||||
|
{j.kind === "restore"
|
||||||
|
? t("job_restore")
|
||||||
|
: j.kind === "backup"
|
||||||
|
? t("job_backup")
|
||||||
|
: j.kind}
|
||||||
|
</span>
|
||||||
|
{at && (
|
||||||
|
<span
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
title={formatAbsolute(at, locale)}
|
||||||
|
>
|
||||||
|
{formatRelative(at, now, locale)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-col items-end gap-0.5">
|
||||||
|
<JobStateBadge state={j.state} />
|
||||||
|
{j.state === "failed" && j.message && (
|
||||||
|
<span
|
||||||
|
className="max-w-[22rem] truncate text-xs text-destructive"
|
||||||
|
title={j.message}
|
||||||
|
>
|
||||||
|
{j.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in new issue
Block a user