- New /api/dashboard/funnel (5-stage stack health, 30s TTL cache) and /api/dashboard/summary (containers, daily uptime jsonl, ops activity) - Token system (tokens.css + Tailwind sp-* aliases); legacy bg/card/accent remapped onto the tokens; Schibsted Grotesk bundled via fontsource - TopNav pill navigation + AppShell replace the sidebar layout (off-canvas drawer below 1024px); central display-weight page titles - Dashboard redesign: FunnelChart (gradient/hatch SVG waterfall), container count card with per-host bars + Insights chip, UptimeChart, OpsGrid, AiPromptBar; 30/7-day range selector; host sections retained below - Stacks page honours ?q= / ?filter= deep links + new status-filter select Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
584 lines
19 KiB
TypeScript
584 lines
19 KiB
TypeScript
import { useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Cpu,
|
|
MemoryStick,
|
|
HardDrive,
|
|
Container,
|
|
Clock,
|
|
ArrowUpCircle,
|
|
RefreshCw,
|
|
Server,
|
|
Database,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Card } from "@/components/ui";
|
|
import { HostHeader } from "@/components/hosts/HostHeader";
|
|
import { StacksTable } from "@/components/stacks/StacksTable";
|
|
import { FunnelChart } from "@/components/dashboard/FunnelChart";
|
|
import { UptimeChart } from "@/components/dashboard/UptimeChart";
|
|
import { OpsGrid } from "@/components/dashboard/OpsGrid";
|
|
import { AiPromptBar } from "@/components/dashboard/AiPromptBar";
|
|
import { stacksApi } from "@/api/stacks";
|
|
import { systemApi } from "@/api/system";
|
|
import { imagesApi } from "@/api/images";
|
|
import { agentsApi } from "@/api/agents";
|
|
import { volumesApi } from "@/api/volumes";
|
|
import { dashboardApi } from "@/api/dashboard";
|
|
import { apiErrorMessage } from "@/api/client";
|
|
import { cn, formatBytes, relativeTime } from "@/lib/utils";
|
|
import { useAuthStore } from "@/store/auth";
|
|
import { useStackActions } from "@/hooks/useStackActions";
|
|
import type { Agent } from "@/types";
|
|
|
|
const sumSizes = (m: Record<string, number | null>) =>
|
|
Object.values(m).reduce<number>((a, b) => a + (b ?? 0), 0);
|
|
|
|
type RangeDays = 7 | 30;
|
|
|
|
export function Dashboard() {
|
|
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
|
const { busyId, start, stop, restart } = useStackActions();
|
|
const [range, setRange] = useState<RangeDays>(30);
|
|
|
|
const funnel = useQuery({
|
|
queryKey: ["dashboard-funnel"],
|
|
queryFn: () => dashboardApi.funnel(),
|
|
refetchInterval: 30000,
|
|
});
|
|
const summary = useQuery({
|
|
queryKey: ["dashboard-summary"],
|
|
queryFn: dashboardApi.summary,
|
|
refetchInterval: 60000,
|
|
});
|
|
|
|
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
|
|
const stats = useQuery({ queryKey: ["stack-stats"], queryFn: stacksApi.stats, refetchInterval: 5000 });
|
|
const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 5000 });
|
|
const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 });
|
|
const updates = useQuery({ queryKey: ["image-updates"], queryFn: () => imagesApi.updates(), refetchInterval: 60000 });
|
|
const agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list(), refetchInterval: 15000 });
|
|
// Volumes total is from `docker system df` (slow, cached ~60s server-side).
|
|
const volSize = useQuery({
|
|
queryKey: ["volumes-size", "local"],
|
|
queryFn: () => volumesApi.sizes().then(sumSizes),
|
|
refetchInterval: 60000,
|
|
});
|
|
|
|
const updateCount = Object.values(updates.data ?? {}).filter((u) => u.update_available).length;
|
|
const hasAgents = (agents.data?.length ?? 0) > 0;
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* ---- Overview header ---- */}
|
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
|
<h1 className="sp-display text-[40px] leading-none sm:text-[50px]">Overview</h1>
|
|
<div className="flex items-center gap-1.5">
|
|
{([30, 7] as RangeDays[]).map((d) => (
|
|
<button
|
|
key={d}
|
|
onClick={() => setRange(d)}
|
|
className={cn(
|
|
"rounded-pill px-3.5 py-1.5 text-[13px] font-medium transition-colors",
|
|
range === d
|
|
? "bg-sp-pill text-sp-pill-text"
|
|
: "border border-sp-border bg-sp-surface text-sp-text-2 hover:text-sp-text-1"
|
|
)}
|
|
>
|
|
Last {d} days
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{updateCount > 0 && (
|
|
<Link
|
|
to="/images"
|
|
className="flex items-center gap-2 rounded-card border border-sp-amber/40 bg-sp-amber/10 px-4 py-3 text-sm text-sp-amber hover:bg-sp-amber/20"
|
|
>
|
|
<ArrowUpCircle className="h-5 w-5" />
|
|
{updateCount} image update{updateCount > 1 ? "s" : ""} available — view on the Images page.
|
|
</Link>
|
|
)}
|
|
|
|
{/* ---- Analytics row: funnel + container count ---- */}
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_300px]">
|
|
<StackHealthCard funnel={funnel.data} loading={funnel.isLoading} />
|
|
<ContainerCountCard
|
|
localRunning={summary.data?.total_containers}
|
|
loading={summary.isLoading}
|
|
agents={agents.data ?? []}
|
|
healthyRate={
|
|
funnel.data && funnel.data.running > 0
|
|
? Math.round((funnel.data.healthy / funnel.data.running) * 100)
|
|
: null
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* ---- Bottom row: uptime + ops ---- */}
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
<UptimeCard
|
|
series={summary.data?.uptime_series.slice(-range)}
|
|
loading={summary.isLoading}
|
|
range={range}
|
|
/>
|
|
<OpsCard
|
|
series={summary.data?.ops_last_30d.slice(-range)}
|
|
peakDay={summary.data?.ops_peak_day ?? null}
|
|
loading={summary.isLoading}
|
|
range={range}
|
|
/>
|
|
</div>
|
|
|
|
{/* ---- Local host ---- */}
|
|
<section>
|
|
{hasAgents ? (
|
|
<HostHeader />
|
|
) : (
|
|
<h2 className="sp-label mb-3">This host</h2>
|
|
)}
|
|
<ResourceBar
|
|
cpuCores={info.data?.cpu_cores ?? 0}
|
|
memUsed={info.data?.ram.used ?? 0}
|
|
memTotal={info.data?.ram.total ?? 0}
|
|
diskUsed={info.data?.disk.used ?? 0}
|
|
diskTotal={info.data?.disk.total ?? 0}
|
|
volumesSize={volSize.data}
|
|
containersRunning={info.data?.containers_running ?? 0}
|
|
containersTotal={info.data?.containers_total ?? 0}
|
|
dockerVersion={info.data?.docker_version ?? ""}
|
|
/>
|
|
<StacksTable
|
|
stacks={stacks.data}
|
|
stats={stats.data}
|
|
hostCpus={info.data?.cpu_cores ?? 0}
|
|
hostMem={info.data?.ram.total ?? 0}
|
|
isAdmin={isAdmin}
|
|
busyId={busyId}
|
|
loading={stacks.isLoading}
|
|
onStart={start}
|
|
onStop={stop}
|
|
onRestart={restart}
|
|
emptyText="No stacks yet. Create one from the Stacks page."
|
|
/>
|
|
</section>
|
|
|
|
{/* Remote hosts */}
|
|
{agents.data?.map((agent) => (
|
|
<AgentDashboardSection key={agent.id} agent={agent} isAdmin={isAdmin} />
|
|
))}
|
|
|
|
{/* Recent activity */}
|
|
<section>
|
|
<h2 className="sp-label mb-3 flex items-center gap-2">
|
|
<Clock className="h-4 w-4" /> Recent activity
|
|
</h2>
|
|
<Card>
|
|
{audit.data && audit.data.length > 0 ? (
|
|
<ul className="divide-y divide-slate-100 text-sm dark:divide-slate-700">
|
|
{audit.data.map((a) => (
|
|
<li key={a.id} className="flex items-center justify-between py-2">
|
|
<span>
|
|
<span className="font-medium">{a.user}</span>{" "}
|
|
<span className="text-slate-500">{a.action}</span>{" "}
|
|
<span className="font-mono text-xs text-accent dark:text-accent-dark">{a.target}</span>
|
|
</span>
|
|
<span className="text-xs text-slate-400">{relativeTime(a.timestamp)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p className="text-sm text-slate-500">No activity yet.</p>
|
|
)}
|
|
</Card>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------- */
|
|
/* Analytics cards */
|
|
/* ---------------------------------------------------------------------- */
|
|
|
|
const FUNNEL_STAGES = [
|
|
{ key: "discovered", label: "Discovered" },
|
|
{ key: "running", label: "Running" },
|
|
{ key: "healthy", label: "Healthy" },
|
|
{ key: "updated", label: "Up to date" },
|
|
{ key: "monitored", label: "Monitored" },
|
|
] as const;
|
|
|
|
function StackHealthCard({
|
|
funnel,
|
|
loading,
|
|
}: {
|
|
funnel?: import("@/api/dashboard").FunnelData;
|
|
loading: boolean;
|
|
}) {
|
|
const qc = useQueryClient();
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [hovered, setHovered] = useState<number | null>(null);
|
|
|
|
const refresh = async () => {
|
|
setRefreshing(true);
|
|
try {
|
|
const data = await dashboardApi.funnel(true);
|
|
qc.setQueryData(["dashboard-funnel"], data);
|
|
} catch (e) {
|
|
toast.error(apiErrorMessage(e));
|
|
} finally {
|
|
setRefreshing(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="sp-card sp-rise flex flex-col gap-5 p-5 sm:p-6">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="sp-label">Stack health</h2>
|
|
<button
|
|
onClick={refresh}
|
|
className="flex items-center gap-1.5 rounded-pill border border-sp-border bg-sp-surface-2 px-2.5 py-1 text-xs text-sp-text-2 hover:text-sp-text-1"
|
|
title="Refresh now"
|
|
>
|
|
<RefreshCw className={cn("h-3 w-3", refreshing && "animate-spin")} />
|
|
{funnel ? relativeTime(funnel.as_of) : "…"}
|
|
</button>
|
|
</div>
|
|
|
|
{loading || !funnel ? (
|
|
<div className="space-y-4">
|
|
<div className="sp-skeleton h-12" />
|
|
<div className="sp-skeleton h-52" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="grid grid-cols-5 gap-2">
|
|
{FUNNEL_STAGES.map(({ key, label }, i) => (
|
|
<div
|
|
key={key}
|
|
className={cn(
|
|
"min-w-0 transition-opacity",
|
|
hovered !== null && hovered !== i && "opacity-40"
|
|
)}
|
|
>
|
|
<p className="sp-label truncate">{label}</p>
|
|
<p className="sp-display mt-0.5 text-2xl sm:text-3xl">{funnel[key]}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<FunnelChart
|
|
stages={FUNNEL_STAGES.map(({ key, label }) => ({ label, value: funnel[key] }))}
|
|
onHover={setHovered}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<AiPromptBar />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ContainerCountCard({
|
|
localRunning,
|
|
loading,
|
|
agents,
|
|
healthyRate,
|
|
}: {
|
|
localRunning?: number;
|
|
loading: boolean;
|
|
agents: Agent[];
|
|
healthyRate: number | null;
|
|
}) {
|
|
const online = agents.filter((a) => a.status === "online");
|
|
const remote = useQueries({
|
|
queries: online.map((a) => ({
|
|
queryKey: ["agent-system", a.id],
|
|
queryFn: () => agentsApi.system(a.id),
|
|
refetchInterval: 30000,
|
|
})),
|
|
});
|
|
|
|
const hosts: { name: string; count: number }[] = [
|
|
{ name: "local", count: localRunning ?? 0 },
|
|
...online.map((a, i) => ({ name: a.name, count: remote[i].data?.containers_running ?? 0 })),
|
|
];
|
|
const total = hosts.reduce((s, h) => s + h.count, 0);
|
|
const max = Math.max(...hosts.map((h) => h.count), 1);
|
|
|
|
return (
|
|
<div className="sp-card sp-rise flex flex-col p-5 sm:p-6" style={{ animationDelay: "60ms" }}>
|
|
<h2 className="sp-label">Containers running</h2>
|
|
{loading ? (
|
|
<div className="mt-3 space-y-3">
|
|
<div className="sp-skeleton h-14 w-28" />
|
|
<div className="sp-skeleton h-20" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
<p className="sp-display mt-1 text-5xl">{total}</p>
|
|
<div className="mt-4 space-y-2.5">
|
|
{hosts.map((h) => (
|
|
<div key={h.name}>
|
|
<div className="mb-1 flex items-center justify-between text-xs">
|
|
<span className="truncate font-medium text-sp-text-2">{h.name}</span>
|
|
<span className="font-semibold text-sp-text-1">{h.count}</span>
|
|
</div>
|
|
<div className="h-1.5 rounded-pill bg-sp-surface-2">
|
|
<div
|
|
className="h-1.5 rounded-pill bg-sp-blue"
|
|
style={{ width: `${(h.count / max) * 100}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className="mt-auto pt-5">
|
|
<div className="flex items-center gap-2.5 rounded-2xl bg-sp-pill px-3.5 py-3 text-sp-pill-text">
|
|
<span className="text-base leading-none">✦</span>
|
|
<p className="text-xs font-medium leading-snug">
|
|
{healthyRate === null
|
|
? "Insights appear once stacks are running."
|
|
: `${healthyRate}% of running stacks are healthy.`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UptimeCard({
|
|
series,
|
|
loading,
|
|
range,
|
|
}: {
|
|
series?: { date: string; value: number | null }[];
|
|
loading: boolean;
|
|
range: RangeDays;
|
|
}) {
|
|
const values = series?.map((p) => p.value) ?? [];
|
|
const latest = [...values].reverse().find((v): v is number => v !== null);
|
|
return (
|
|
<div className="sp-card sp-rise p-5 sm:p-6" style={{ animationDelay: "120ms" }}>
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h2 className="sp-label">Uptime</h2>
|
|
<p className="sp-display mt-1 text-4xl">
|
|
{latest === undefined ? "—" : `${latest}%`}
|
|
</p>
|
|
</div>
|
|
<span className="sp-label">{range}d</span>
|
|
</div>
|
|
<div className="mt-4">
|
|
{loading || !series ? (
|
|
<div className="sp-skeleton h-24" />
|
|
) : (
|
|
<UptimeChart data={values} />
|
|
)}
|
|
</div>
|
|
<p className="mt-2 text-xs text-sp-text-3">Daily share of compose containers running, this host.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OpsCard({
|
|
series,
|
|
peakDay,
|
|
loading,
|
|
range,
|
|
}: {
|
|
series?: { date: string; count: number }[];
|
|
peakDay: string | null;
|
|
loading: boolean;
|
|
range: RangeDays;
|
|
}) {
|
|
const total = series?.reduce((s, p) => s + p.count, 0) ?? 0;
|
|
const max = Math.max(...(series?.map((p) => p.count) ?? []), 1);
|
|
return (
|
|
<div className="sp-card sp-rise p-5 sm:p-6" style={{ animationDelay: "180ms" }}>
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h2 className="sp-label">Operations</h2>
|
|
<p className="sp-display mt-1 text-4xl">{total}</p>
|
|
</div>
|
|
<span className="sp-label">{range}d</span>
|
|
</div>
|
|
<div className="mt-4">
|
|
{loading || !series ? (
|
|
<div className="sp-skeleton h-16" />
|
|
) : (
|
|
<OpsGrid data={series.map((p) => p.count / max)} cols={range} />
|
|
)}
|
|
</div>
|
|
<p className="mt-2 text-xs text-sp-text-3">
|
|
{peakDay ? `Busiest day: ${peakDay}.` : "Audit-log actions per day."}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------- */
|
|
/* Host sections (pre-Phase-24, retained) */
|
|
/* ---------------------------------------------------------------------- */
|
|
|
|
function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: boolean }) {
|
|
const qc = useQueryClient();
|
|
const online = agent.status === "online";
|
|
const [busyId, setBusyId] = useState<string | null>(null);
|
|
|
|
const stacks = useQuery({
|
|
queryKey: ["agent-stacks", agent.id],
|
|
queryFn: () => agentsApi.stacks(agent.id),
|
|
enabled: online,
|
|
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 volSize = useQuery({
|
|
queryKey: ["volumes-size", agent.id],
|
|
queryFn: () => volumesApi.sizes(false, agent.id).then(sumSizes),
|
|
enabled: online,
|
|
refetchInterval: 60000,
|
|
});
|
|
|
|
const run = async (action: string, label: string, id: string) => {
|
|
setBusyId(id);
|
|
const t = toast.loading(`${label} ${id} on ${agent.name}…`);
|
|
try {
|
|
await agentsApi.action(agent.id, id, action);
|
|
toast.success(`${label} ${id} ✓`, { id: t });
|
|
qc.invalidateQueries({ queryKey: ["agent-stacks", agent.id] });
|
|
qc.invalidateQueries({ queryKey: ["agent-stack-stats", agent.id] });
|
|
} catch (e) {
|
|
toast.error(apiErrorMessage(e), { id: t });
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<section>
|
|
<HostHeader agent={agent} />
|
|
{!online ? (
|
|
<Card>
|
|
<p className="text-sm text-slate-500">
|
|
Host is {agent.status}. Check it under Settings → Remote hosts.
|
|
</p>
|
|
</Card>
|
|
) : (
|
|
<>
|
|
<ResourceBar
|
|
cpuCores={sys.data?.cpu_cores ?? 0}
|
|
memUsed={sys.data?.mem_used ?? 0}
|
|
memTotal={sys.data?.mem_total ?? 0}
|
|
diskUsed={sys.data?.disk_used ?? 0}
|
|
diskTotal={sys.data?.disk_total ?? 0}
|
|
volumesSize={volSize.data}
|
|
containersRunning={sys.data?.containers_running ?? 0}
|
|
containersTotal={sys.data?.containers_total ?? 0}
|
|
dockerVersion={sys.data?.docker_version ?? ""}
|
|
/>
|
|
<StacksTable
|
|
stacks={stacks.data}
|
|
stats={stats.data}
|
|
hostCpus={sys.data?.cpu_cores ?? 0}
|
|
hostMem={sys.data?.mem_total ?? 0}
|
|
isAdmin={isAdmin}
|
|
busyId={busyId}
|
|
loading={stacks.isLoading}
|
|
linkBase={`/hosts/${agent.id}/stacks`}
|
|
onStart={(id) => run("start", "Starting", id)}
|
|
onStop={(id) => run("stop", "Stopping", id)}
|
|
onRestart={(id) => run("restart", "Restarting", id)}
|
|
emptyText="No stacks on this host."
|
|
/>
|
|
</>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ResourceBar({
|
|
cpuCores,
|
|
memUsed,
|
|
memTotal,
|
|
diskUsed,
|
|
diskTotal,
|
|
volumesSize,
|
|
containersRunning,
|
|
containersTotal,
|
|
dockerVersion,
|
|
}: {
|
|
cpuCores: number;
|
|
memUsed: number;
|
|
memTotal: number;
|
|
diskUsed: number;
|
|
diskTotal: number;
|
|
volumesSize?: number;
|
|
containersRunning: number;
|
|
containersTotal: number;
|
|
dockerVersion: string;
|
|
}) {
|
|
return (
|
|
<div className="mb-4 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
|
<Stat icon={<Cpu className="h-5 w-5" />} label="CPU cores" value={cpuCores || "—"} />
|
|
<Stat
|
|
icon={<MemoryStick className="h-5 w-5" />}
|
|
label="Memory"
|
|
value={memTotal ? `${formatBytes(memUsed)} / ${formatBytes(memTotal)}` : "—"}
|
|
/>
|
|
<Stat
|
|
icon={<HardDrive className="h-5 w-5" />}
|
|
label="Disk"
|
|
value={diskTotal ? `${formatBytes(diskUsed)} / ${formatBytes(diskTotal)}` : "—"}
|
|
/>
|
|
<Stat
|
|
icon={<Database className="h-5 w-5" />}
|
|
label="Volumes"
|
|
value={volumesSize === undefined ? "…" : formatBytes(volumesSize)}
|
|
/>
|
|
<Stat
|
|
icon={<Container className="h-5 w-5" />}
|
|
label="Containers"
|
|
value={`${containersRunning} / ${containersTotal}`}
|
|
/>
|
|
<Stat icon={<Server className="h-5 w-5" />} label="Docker" value={dockerVersion || "—"} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Stat({
|
|
icon,
|
|
label,
|
|
value,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
value: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<Card className="flex items-center gap-3">
|
|
<div className="rounded-lg bg-accent/10 p-2 text-accent dark:bg-accent-dark/10 dark:text-accent-dark">
|
|
{icon}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-xs text-slate-500">{label}</p>
|
|
<p className="truncate text-sm font-semibold">{value}</p>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|