Stacks page: table layout with usage meters, matching the dashboard (0.21.4)
The Stacks tab now lists stacks in the shared StacksTable (status, CPU and memory meters, inline start/stop/restart) instead of cards, for both the local host and per-agent sections — same look as the dashboard. StacksTable gained optional showEdit/showDelete props so the management surface keeps the Edit link and local Delete (with confirm). Search and sort are unchanged. Removed the now-unused StackCard component. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ffb4ee39a0
commit
8fc61b1531
@@ -62,7 +62,7 @@ def _map_docker(exc: DockerError):
|
|||||||
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
||||||
raise exc # falls through to the global 502 DockerError handler
|
raise exc # falls through to the global 502 DockerError handler
|
||||||
|
|
||||||
AGENT_VERSION = "0.21.3"
|
AGENT_VERSION = "0.21.4"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ async def lifespan(app: FastAPI):
|
|||||||
schedule_task.cancel()
|
schedule_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="StackPilot", version="0.21.3", lifespan=lifespan)
|
app = FastAPI(title="StackPilot", version="0.21.4", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.21.3",
|
"version": "0.21.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { Server } from "lucide-react";
|
import { Server } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Card } from "@/components/ui";
|
import { Card } from "@/components/ui";
|
||||||
import { StackCard } from "@/components/stacks/StackCard";
|
import { StacksTable } from "@/components/stacks/StacksTable";
|
||||||
import { RestoreButton } from "@/components/stacks/BackupRestore";
|
import { RestoreButton } from "@/components/stacks/BackupRestore";
|
||||||
import { HostDot } from "@/components/hosts/HostDot";
|
import { HostDot } from "@/components/hosts/HostDot";
|
||||||
import { agentsApi } from "@/api/agents";
|
import { agentsApi } from "@/api/agents";
|
||||||
@@ -21,6 +21,18 @@ export function AgentStacksSection({ agent, isAdmin }: { agent: Agent; isAdmin:
|
|||||||
enabled: online,
|
enabled: online,
|
||||||
refetchInterval: 8000,
|
refetchInterval: 8000,
|
||||||
});
|
});
|
||||||
|
const stats = useQuery({
|
||||||
|
queryKey: ["agent-stack-stats", agent.id],
|
||||||
|
queryFn: () => agentsApi.stackStats(agent.id),
|
||||||
|
enabled: online,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
const sys = useQuery({
|
||||||
|
queryKey: ["agent-system", agent.id],
|
||||||
|
queryFn: () => agentsApi.system(agent.id),
|
||||||
|
enabled: online,
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
const run = async (action: string, label: string, id: string) => {
|
const run = async (action: string, label: string, id: string) => {
|
||||||
setBusyId(id);
|
setBusyId(id);
|
||||||
@@ -56,26 +68,21 @@ export function AgentStacksSection({ agent, isAdmin }: { agent: Agent; isAdmin:
|
|||||||
Host is {agent.status}. Check it under Settings → Remote hosts.
|
Host is {agent.status}. Check it under Settings → Remote hosts.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
) : stacks.data && stacks.data.length > 0 ? (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<StacksTable
|
||||||
{stacks.data.map((s) => (
|
stacks={stacks.data}
|
||||||
<StackCard
|
stats={stats.data}
|
||||||
key={s.id}
|
hostCpus={sys.data?.cpu_cores ?? 0}
|
||||||
stack={s}
|
hostMem={sys.data?.mem_total ?? 0}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
busy={busyId === s.id}
|
busyId={busyId}
|
||||||
|
loading={stacks.isLoading}
|
||||||
linkBase={`/hosts/${agent.id}/stacks`}
|
linkBase={`/hosts/${agent.id}/stacks`}
|
||||||
showEdit={false}
|
|
||||||
onStart={(id) => run("start", "Starting", id)}
|
onStart={(id) => run("start", "Starting", id)}
|
||||||
onStop={(id) => run("stop", "Stopping", id)}
|
onStop={(id) => run("stop", "Stopping", id)}
|
||||||
onRestart={(id) => run("restart", "Restarting", id)}
|
onRestart={(id) => run("restart", "Restarting", id)}
|
||||||
|
emptyText="No stacks on this host."
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<p className="text-sm text-slate-500">No stacks on this host.</p>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { Play, Square, RotateCw, Pencil, Trash2 } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Card, StatusDot, Badge } from "@/components/ui";
|
|
||||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
|
||||||
import { cn, relativeTime } from "@/lib/utils";
|
|
||||||
import { stacksApi } from "@/api/stacks";
|
|
||||||
import { apiErrorMessage } from "@/api/client";
|
|
||||||
import type { StackSummary } from "@/types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
stack: StackSummary;
|
|
||||||
onStart: (id: string) => void;
|
|
||||||
onStop: (id: string) => void;
|
|
||||||
onRestart: (id: string) => void;
|
|
||||||
busy?: boolean;
|
|
||||||
isAdmin?: boolean;
|
|
||||||
linkBase?: string; // detail/edit route prefix, default "/stacks"
|
|
||||||
showEdit?: boolean; // hide edit for remote stacks (no remote editor yet)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StackCard({
|
|
||||||
stack,
|
|
||||||
onStart,
|
|
||||||
onStop,
|
|
||||||
onRestart,
|
|
||||||
busy,
|
|
||||||
isAdmin,
|
|
||||||
linkBase = "/stacks",
|
|
||||||
showEdit = true,
|
|
||||||
}: Props) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const [confirming, setConfirming] = useState(false);
|
|
||||||
const [deleting, setDeleting] = useState(false);
|
|
||||||
const isLocal = !stack.agent_id;
|
|
||||||
|
|
||||||
const remove = async () => {
|
|
||||||
setDeleting(true);
|
|
||||||
const t = toast.loading(`Deleting ${stack.id}…`);
|
|
||||||
try {
|
|
||||||
await stacksApi.remove(stack.id, true);
|
|
||||||
toast.success(`Deleted ${stack.id}`, { id: t });
|
|
||||||
qc.invalidateQueries({ queryKey: ["stacks"] });
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(apiErrorMessage(e), { id: t });
|
|
||||||
} finally {
|
|
||||||
setDeleting(false);
|
|
||||||
setConfirming(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="flex flex-col gap-3 transition-shadow hover:shadow-md">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<Link to={`${linkBase}/${stack.id}`} className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusDot status={stack.status} />
|
|
||||||
<span className="truncate font-semibold hover:underline">
|
|
||||||
{stack.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{stack.description && (
|
|
||||||
<p className="mt-1 truncate text-sm text-slate-500">
|
|
||||||
{stack.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
<Badge status={stack.status}>{stack.status}</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-xs text-slate-500">
|
|
||||||
<span>
|
|
||||||
{stack.running_count}/{stack.service_count} services
|
|
||||||
</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>updated {relativeTime(stack.updated_at)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<div className="flex gap-1 border-t border-slate-100 pt-3 dark:border-slate-700">
|
|
||||||
<IconBtn title="Start" onClick={() => onStart(stack.id)} disabled={busy}>
|
|
||||||
<Play className="h-4 w-4 text-green-500" />
|
|
||||||
</IconBtn>
|
|
||||||
<IconBtn title="Stop" onClick={() => onStop(stack.id)} disabled={busy}>
|
|
||||||
<Square className="h-4 w-4 text-red-500" />
|
|
||||||
</IconBtn>
|
|
||||||
<IconBtn title="Restart" onClick={() => onRestart(stack.id)} disabled={busy}>
|
|
||||||
<RotateCw className="h-4 w-4 text-sky-500" />
|
|
||||||
</IconBtn>
|
|
||||||
{showEdit && (
|
|
||||||
<Link
|
|
||||||
to={`${linkBase}/${stack.id}/edit`}
|
|
||||||
title="Edit"
|
|
||||||
className="ml-auto rounded-lg p-2 hover:bg-slate-100 dark:hover:bg-slate-700"
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4 text-slate-500" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{isLocal && (
|
|
||||||
<IconBtn
|
|
||||||
title="Delete"
|
|
||||||
onClick={() => setConfirming(true)}
|
|
||||||
className={showEdit ? "" : "ml-auto"}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-red-500" />
|
|
||||||
</IconBtn>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{confirming && (
|
|
||||||
<ConfirmDialog
|
|
||||||
title={`Delete stack “${stack.id}”?`}
|
|
||||||
message="The stack is stopped and its compose files are removed. This cannot be undone."
|
|
||||||
confirmLabel="Delete stack"
|
|
||||||
danger
|
|
||||||
busy={deleting}
|
|
||||||
onConfirm={remove}
|
|
||||||
onCancel={() => setConfirming(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IconBtn({
|
|
||||||
children,
|
|
||||||
title,
|
|
||||||
onClick,
|
|
||||||
disabled,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
title: string;
|
|
||||||
onClick: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
title={title}
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg p-2 hover:bg-slate-100 disabled:opacity-40 dark:hover:bg-slate-700",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Play, Square, RotateCw } from "lucide-react";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Play, Square, RotateCw, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Card, Spinner, StatusDot, Badge } from "@/components/ui";
|
import { Card, Spinner, StatusDot, Badge } from "@/components/ui";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||||
import { formatBytes } from "@/lib/utils";
|
import { formatBytes } from "@/lib/utils";
|
||||||
|
import { stacksApi } from "@/api/stacks";
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import type { StackStats, StackSummary } from "@/types";
|
import type { StackStats, StackSummary } from "@/types";
|
||||||
|
|
||||||
/** Stack list rendered as a table with live CPU/memory usage meters and inline
|
/** Stack list rendered as a table with live CPU/memory usage meters and inline
|
||||||
@@ -15,6 +21,8 @@ export function StacksTable({
|
|||||||
busyId,
|
busyId,
|
||||||
loading,
|
loading,
|
||||||
linkBase = "/stacks",
|
linkBase = "/stacks",
|
||||||
|
showEdit = false,
|
||||||
|
showDelete = false,
|
||||||
onStart,
|
onStart,
|
||||||
onStop,
|
onStop,
|
||||||
onRestart,
|
onRestart,
|
||||||
@@ -28,6 +36,8 @@ export function StacksTable({
|
|||||||
busyId: string | null;
|
busyId: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
linkBase?: string;
|
linkBase?: string;
|
||||||
|
showEdit?: boolean;
|
||||||
|
showDelete?: boolean;
|
||||||
onStart: (id: string) => void;
|
onStart: (id: string) => void;
|
||||||
onStop: (id: string) => void;
|
onStop: (id: string) => void;
|
||||||
onRestart: (id: string) => void;
|
onRestart: (id: string) => void;
|
||||||
@@ -63,6 +73,8 @@ export function StacksTable({
|
|||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
busy={busyId === s.id}
|
busy={busyId === s.id}
|
||||||
linkBase={linkBase}
|
linkBase={linkBase}
|
||||||
|
showEdit={showEdit}
|
||||||
|
showDelete={showDelete}
|
||||||
onStart={onStart}
|
onStart={onStart}
|
||||||
onStop={onStop}
|
onStop={onStop}
|
||||||
onRestart={onRestart}
|
onRestart={onRestart}
|
||||||
@@ -82,6 +94,8 @@ function StackRow({
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
busy,
|
busy,
|
||||||
linkBase,
|
linkBase,
|
||||||
|
showEdit,
|
||||||
|
showDelete,
|
||||||
onStart,
|
onStart,
|
||||||
onStop,
|
onStop,
|
||||||
onRestart,
|
onRestart,
|
||||||
@@ -93,11 +107,32 @@ function StackRow({
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
linkBase: string;
|
linkBase: string;
|
||||||
|
showEdit: boolean;
|
||||||
|
showDelete: boolean;
|
||||||
onStart: (id: string) => void;
|
onStart: (id: string) => void;
|
||||||
onStop: (id: string) => void;
|
onStop: (id: string) => void;
|
||||||
onRestart: (id: string) => void;
|
onRestart: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
const running = stack.running_count > 0;
|
const running = stack.running_count > 0;
|
||||||
|
const canDelete = showDelete && !stack.agent_id;
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
const t = toast.loading(`Deleting ${stack.id}…`);
|
||||||
|
try {
|
||||||
|
await stacksApi.remove(stack.id, true);
|
||||||
|
toast.success(`Deleted ${stack.id}`, { id: t });
|
||||||
|
qc.invalidateQueries({ queryKey: ["stacks"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(apiErrorMessage(e), { id: t });
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
setConfirming(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
<tr className="hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||||
@@ -160,7 +195,32 @@ function StackRow({
|
|||||||
<Play className="h-4 w-4 text-green-500" />
|
<Play className="h-4 w-4 text-green-500" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
)}
|
)}
|
||||||
|
{showEdit && (
|
||||||
|
<Link
|
||||||
|
to={`${linkBase}/${stack.id}/edit`}
|
||||||
|
title="Edit"
|
||||||
|
className="rounded-lg p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<IconBtn title="Delete" onClick={() => setConfirming(true)} disabled={busy}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</IconBtn>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{confirming && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={`Delete stack “${stack.id}”?`}
|
||||||
|
message="The stack is stopped and its compose files are removed. This cannot be undone."
|
||||||
|
confirmLabel="Delete stack"
|
||||||
|
danger
|
||||||
|
busy={deleting}
|
||||||
|
onConfirm={remove}
|
||||||
|
onCancel={() => setConfirming(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { useMemo, useState } from "react";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Plus, Search, HardDrive } from "lucide-react";
|
import { Plus, Search, HardDrive } from "lucide-react";
|
||||||
import { Button, Input, Spinner, Card } from "@/components/ui";
|
import { Button, Input } from "@/components/ui";
|
||||||
import { StackCard } from "@/components/stacks/StackCard";
|
import { StacksTable } from "@/components/stacks/StacksTable";
|
||||||
import { RestoreButton } from "@/components/stacks/BackupRestore";
|
import { RestoreButton } from "@/components/stacks/BackupRestore";
|
||||||
import { AgentStacksSection } from "@/components/stacks/AgentStacksSection";
|
import { AgentStacksSection } from "@/components/stacks/AgentStacksSection";
|
||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
|
import { systemApi } from "@/api/system";
|
||||||
import { agentsApi } from "@/api/agents";
|
import { agentsApi } from "@/api/agents";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
import { useStackActions } from "@/hooks/useStackActions";
|
import { useStackActions } from "@/hooks/useStackActions";
|
||||||
@@ -25,6 +26,17 @@ export function Stacks() {
|
|||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const stats = useQuery({
|
||||||
|
queryKey: ["stack-stats"],
|
||||||
|
queryFn: stacksApi.stats,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
const info = useQuery({
|
||||||
|
queryKey: ["system"],
|
||||||
|
queryFn: systemApi.info,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
const agents = useQuery({
|
const agents = useQuery({
|
||||||
queryKey: ["agents"],
|
queryKey: ["agents"],
|
||||||
queryFn: () => agentsApi.list(),
|
queryFn: () => agentsApi.list(),
|
||||||
@@ -83,27 +95,21 @@ export function Stacks() {
|
|||||||
<HardDrive className="h-4 w-4" /> This host
|
<HardDrive className="h-4 w-4" /> This host
|
||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
{isLoading ? (
|
<StacksTable
|
||||||
<Spinner />
|
stacks={filtered}
|
||||||
) : filtered.length > 0 ? (
|
stats={stats.data}
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
hostCpus={info.data?.cpu_cores ?? 0}
|
||||||
{filtered.map((s) => (
|
hostMem={info.data?.ram.total ?? 0}
|
||||||
<StackCard
|
|
||||||
key={s.id}
|
|
||||||
stack={s}
|
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
busy={busyId === s.id}
|
busyId={busyId}
|
||||||
|
loading={isLoading}
|
||||||
|
showEdit
|
||||||
|
showDelete
|
||||||
onStart={start}
|
onStart={start}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
onRestart={restart}
|
onRestart={restart}
|
||||||
|
emptyText={q ? "No stacks match your search." : "No stacks yet. Create one with “New Stack”."}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<p className="text-sm text-slate-500">No stacks match your search.</p>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{agents.data?.map((agent) => (
|
{agents.data?.map((agent) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user