Browse Source

feat(dashboard): improve home layout with sidebar and responsive grid

- Refactor DashboardBento to use two-column layout with fixed-width
  sidebar (300px) for LiveSessionsPanel on admin view
- Update DashboardMain to remove max-w-7xl constraint on dashboard home
  page for wider content area
- Simplify ActiveSessionsSkeleton to match compact list style
- Add showTokensCost prop to SessionListItem and ActiveSessionsList
  for conditional token/cost display
- Fix router import to use i18n routing in ActiveSessionsList
- Add unit tests for layout behavior and SessionListItem props

Co-Authored-By: Claude Opus 4.5 <[email protected]>
ding113 2 months ago
parent
commit
d5b95cfd4b

+ 114 - 90
src/app/[locale]/dashboard/_components/bento/dashboard-bento.tsx

@@ -204,101 +204,125 @@ export function DashboardBento({
     <div className="space-y-6">
       {/* Top Section: Metrics + Live Sessions */}
       {isAdmin && (
-        <BentoGrid>
-          {/* Metric Cards */}
-          <BentoMetricCard
-            title={t("metrics.concurrent")}
-            value={metrics.concurrentSessions}
-            icon={Activity}
-            accentColor="emerald"
-            className="min-h-[120px]"
-            comparisons={[
-              { value: metrics.recentMinuteRequests, label: t("metrics.rpm"), isPercentage: false },
-            ]}
-          />
-          <BentoMetricCard
-            title={t("metrics.todayRequests")}
-            value={metrics.todayRequests}
-            icon={TrendingUp}
-            accentColor="blue"
-            className="min-h-[120px]"
-            comparisons={[{ value: requestsChange, label: t("metrics.vsYesterday") }]}
-          />
-          <BentoMetricCard
-            title={t("metrics.todayCost")}
-            value={formatCurrency(metrics.todayCost, currencyCode)}
-            icon={DollarSign}
-            accentColor="amber"
-            className="min-h-[120px]"
-            comparisons={[{ value: costChange, label: t("metrics.vsYesterday") }]}
-          />
-          <BentoMetricCard
-            title={t("metrics.avgResponse")}
-            value={metrics.avgResponseTime}
-            icon={Clock}
-            formatter={formatResponseTime}
-            accentColor="purple"
-            className="min-h-[120px]"
-            comparisons={[{ value: -responseTimeChange, label: t("metrics.vsYesterday") }]}
-          />
-        </BentoGrid>
+        <div className="mx-auto w-full max-w-7xl">
+          <BentoGrid>
+            {/* Metric Cards */}
+            <BentoMetricCard
+              title={t("metrics.concurrent")}
+              value={metrics.concurrentSessions}
+              icon={Activity}
+              accentColor="emerald"
+              className="min-h-[120px]"
+              comparisons={[
+                {
+                  value: metrics.recentMinuteRequests,
+                  label: t("metrics.rpm"),
+                  isPercentage: false,
+                },
+              ]}
+            />
+            <BentoMetricCard
+              title={t("metrics.todayRequests")}
+              value={metrics.todayRequests}
+              icon={TrendingUp}
+              accentColor="blue"
+              className="min-h-[120px]"
+              comparisons={[{ value: requestsChange, label: t("metrics.vsYesterday") }]}
+            />
+            <BentoMetricCard
+              title={t("metrics.todayCost")}
+              value={formatCurrency(metrics.todayCost, currencyCode)}
+              icon={DollarSign}
+              accentColor="amber"
+              className="min-h-[120px]"
+              comparisons={[{ value: costChange, label: t("metrics.vsYesterday") }]}
+            />
+            <BentoMetricCard
+              title={t("metrics.avgResponse")}
+              value={metrics.avgResponseTime}
+              icon={Clock}
+              formatter={formatResponseTime}
+              accentColor="purple"
+              className="min-h-[120px]"
+              comparisons={[{ value: -responseTimeChange, label: t("metrics.vsYesterday") }]}
+            />
+          </BentoGrid>
+        </div>
       )}
 
-      {/* Middle Section: Statistics Chart + Live Sessions (Admin) */}
-      <BentoGrid>
-        {/* Statistics Chart - 3 columns for admin, 4 columns for non-admin */}
-        {statistics && (
-          <StatisticsChartCard
-            data={statistics}
-            onTimeRangeChange={setTimeRange}
-            currencyCode={currencyCode}
-            colSpan={isAdmin ? 3 : 4}
-          />
-        )}
+      {/* Middle Section: Statistics Chart + Leaderboards (+ Live Sessions sidebar for admin) */}
+      <div
+        data-testid={isAdmin ? "dashboard-home-layout" : undefined}
+        className={
+          isAdmin ? "grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_300px]" : undefined
+        }
+      >
+        <div className="min-w-0">
+          <div
+            className={[
+              "grid gap-4 md:gap-6",
+              "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
+              "auto-rows-[minmax(140px,auto)]",
+            ].join(" ")}
+          >
+            {statistics && (
+              <StatisticsChartCard
+                data={statistics}
+                onTimeRangeChange={setTimeRange}
+                currencyCode={currencyCode}
+                colSpan={3}
+              />
+            )}
 
-        {/* Live Sessions Panel - Right sidebar, spans 2 rows */}
-        {isAdmin && (
-          <LiveSessionsPanel sessions={sessionsWithActivity} isLoading={sessionsLoading} />
-        )}
+            {canViewLeaderboard && (
+              <LeaderboardCard
+                title={tl("userRankings")}
+                entries={userLeaderboard}
+                currencyCode={currencyCode}
+                isLoading={userLeaderboardLoading}
+                emptyText={tl("noData")}
+                viewAllHref="/dashboard/leaderboard"
+                maxItems={3}
+                accentColor="primary"
+              />
+            )}
+            {canViewLeaderboard && (
+              <LeaderboardCard
+                title={tl("providerRankings")}
+                entries={providerLeaderboard}
+                currencyCode={currencyCode}
+                isLoading={providerLeaderboardLoading}
+                emptyText={tl("noData")}
+                viewAllHref="/dashboard/leaderboard"
+                maxItems={3}
+                accentColor="purple"
+              />
+            )}
+            {canViewLeaderboard && (
+              <LeaderboardCard
+                title={tl("modelRankings")}
+                entries={modelLeaderboard}
+                currencyCode={currencyCode}
+                isLoading={modelLeaderboardLoading}
+                emptyText={tl("noData")}
+                viewAllHref="/dashboard/leaderboard"
+                maxItems={3}
+                accentColor="blue"
+              />
+            )}
+          </div>
+        </div>
 
-        {/* Leaderboard Cards - Below chart, 3 columns */}
-        {canViewLeaderboard && (
-          <LeaderboardCard
-            title={tl("userRankings")}
-            entries={userLeaderboard}
-            currencyCode={currencyCode}
-            isLoading={userLeaderboardLoading}
-            emptyText={tl("noData")}
-            viewAllHref="/dashboard/leaderboard"
-            maxItems={3}
-            accentColor="primary"
-          />
-        )}
-        {canViewLeaderboard && (
-          <LeaderboardCard
-            title={tl("providerRankings")}
-            entries={providerLeaderboard}
-            currencyCode={currencyCode}
-            isLoading={providerLeaderboardLoading}
-            emptyText={tl("noData")}
-            viewAllHref="/dashboard/leaderboard"
-            maxItems={3}
-            accentColor="purple"
-          />
-        )}
-        {canViewLeaderboard && (
-          <LeaderboardCard
-            title={tl("modelRankings")}
-            entries={modelLeaderboard}
-            currencyCode={currencyCode}
-            isLoading={modelLeaderboardLoading}
-            emptyText={tl("noData")}
-            viewAllHref="/dashboard/leaderboard"
-            maxItems={3}
-            accentColor="blue"
-          />
+        {isAdmin && (
+          <aside data-testid="dashboard-home-sidebar" className="hidden lg:block">
+            <LiveSessionsPanel
+              sessions={sessionsWithActivity}
+              isLoading={sessionsLoading}
+              className="h-full"
+            />
+          </aside>
         )}
-      </BentoGrid>
+      </div>
     </div>
   );
 }

+ 10 - 1
src/app/[locale]/dashboard/_components/dashboard-main.tsx

@@ -10,16 +10,25 @@ interface DashboardMainProps {
 export function DashboardMain({ children }: DashboardMainProps) {
   const pathname = usePathname();
 
+  const normalizedPathname = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
+
   // Pattern to match /dashboard/sessions/[id]/messages
   // The usePathname hook from next-intl/routing might return the path without locale prefix if configured that way,
   // or we just check for the suffix.
   // Let's be safe and check if it includes "/dashboard/sessions/" and ends with "/messages"
   const isSessionMessagesPage =
-    pathname.includes("/dashboard/sessions/") && pathname.endsWith("/messages");
+    normalizedPathname.includes("/dashboard/sessions/") && normalizedPathname.endsWith("/messages");
+
+  const isDashboardHomePage =
+    normalizedPathname === "/dashboard" || normalizedPathname.endsWith("/dashboard");
 
   if (isSessionMessagesPage) {
     return <main className="h-[calc(100vh-64px)] w-full overflow-hidden">{children}</main>;
   }
 
+  if (isDashboardHomePage) {
+    return <main className="w-full px-6 py-8">{children}</main>;
+  }
+
   return <main className="mx-auto w-full max-w-7xl px-6 py-8">{children}</main>;
 }

+ 22 - 37
src/app/[locale]/dashboard/logs/_components/active-sessions-skeleton.tsx

@@ -1,47 +1,32 @@
-import { Card, CardContent, CardHeader } from "@/components/ui/card";
 import { Skeleton } from "@/components/ui/skeleton";
 
-function CardSkeleton() {
+export function ActiveSessionsSkeleton() {
   return (
-    <Card className="w-[280px] shrink-0">
-      <CardContent className="p-4 space-y-3">
-        <div className="flex items-center justify-between">
-          <Skeleton className="h-5 w-24" />
-          <Skeleton className="h-5 w-16" />
-        </div>
-        <Skeleton className="h-4 w-40" />
-        <Skeleton className="h-4 w-32" />
-        <div className="flex items-center justify-between pt-2 border-t">
-          <Skeleton className="h-4 w-24" />
-          <Skeleton className="h-4 w-16" />
+    <div className="border rounded-lg bg-card">
+      <div className="px-4 py-3 border-b flex items-center justify-between">
+        <div className="flex items-center gap-2">
+          <Skeleton className="h-4 w-4" />
+          <Skeleton className="h-4 w-28" />
+          <Skeleton className="h-3 w-40" />
         </div>
-      </CardContent>
-    </Card>
-  );
-}
+        <Skeleton className="h-3 w-20" />
+      </div>
 
-export function ActiveSessionsSkeleton() {
-  return (
-    <Card className="border-border/50">
-      <CardHeader className="pb-3">
-        <div className="flex items-center justify-between">
-          <div className="flex items-center gap-2">
-            <Skeleton className="h-8 w-8 rounded-lg" />
-            <div className="space-y-1">
-              <Skeleton className="h-5 w-28" />
-              <Skeleton className="h-3 w-40" />
+      <div style={{ maxHeight: "200px" }} className="overflow-y-auto">
+        <div className="divide-y">
+          {Array.from({ length: 5 }).map((_, idx) => (
+            <div key={idx} className="px-3 py-2">
+              <div className="flex items-center gap-2">
+                <Skeleton className="h-3.5 w-3.5 rounded-full" />
+                <Skeleton className="h-3 w-20" />
+                <Skeleton className="h-3 w-16" />
+                <Skeleton className="h-3 w-28" />
+                <Skeleton className="h-3 w-10 ml-auto" />
+              </div>
             </div>
-          </div>
-          <Skeleton className="h-4 w-20" />
-        </div>
-      </CardHeader>
-      <CardContent className="pt-0">
-        <div className="flex gap-3 pb-3 overflow-hidden">
-          {[1, 2, 3].map((i) => (
-            <CardSkeleton key={i} />
           ))}
         </div>
-      </CardContent>
-    </Card>
+      </div>
+    </div>
   );
 }

+ 8 - 2
src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx

@@ -1,5 +1,5 @@
 import { cache } from "react";
-import { ActiveSessionsCards } from "@/components/customs/active-sessions-cards";
+import { ActiveSessionsList } from "@/components/customs/active-sessions-list";
 import { getEnvConfig } from "@/lib/config";
 import { getSystemSettings } from "@/repository/system-config";
 import { UsageLogsViewVirtualized } from "./usage-logs-view-virtualized";
@@ -14,7 +14,13 @@ interface UsageLogsDataSectionProps {
 
 export async function UsageLogsActiveSessionsSection() {
   const systemSettings = await getCachedSystemSettings();
-  return <ActiveSessionsCards currencyCode={systemSettings.currencyDisplay} />;
+  return (
+    <ActiveSessionsList
+      currencyCode={systemSettings.currencyDisplay}
+      maxHeight="200px"
+      showTokensCost={false}
+    />
+  );
 }
 
 export async function UsageLogsDataSection({

+ 5 - 1
src/components/customs/active-sessions-list.tsx

@@ -2,9 +2,9 @@
 
 import { useQuery } from "@tanstack/react-query";
 import { Activity, Loader2 } from "lucide-react";
-import { useRouter } from "next/navigation";
 import { useTranslations } from "next-intl";
 import { getActiveSessions } from "@/actions/active-sessions";
+import { useRouter } from "@/i18n/routing";
 import type { CurrencyCode } from "@/lib/utils/currency";
 import type { ActiveSessionInfo } from "@/types/session";
 import { SessionListItem } from "./session-list-item";
@@ -28,6 +28,8 @@ interface ActiveSessionsListProps {
   showHeader?: boolean;
   /** 容器最大高度 */
   maxHeight?: string;
+  /** 是否显示 Token/成本(默认显示) */
+  showTokensCost?: boolean;
   /** 自定义类名 */
   className?: string;
 }
@@ -43,6 +45,7 @@ export function ActiveSessionsList({
   maxItems,
   showHeader = true,
   maxHeight = "200px",
+  showTokensCost = true,
   className = "",
 }: ActiveSessionsListProps) {
   const router = useRouter();
@@ -103,6 +106,7 @@ export function ActiveSessionsList({
                 key={session.sessionId}
                 session={session}
                 currencyCode={currencyCode}
+                showTokensCost={showTokensCost}
               />
             ))}
           </div>

+ 24 - 16
src/components/customs/session-list-item.tsx

@@ -41,13 +41,17 @@ function getStatusIcon(status: "in_progress" | "completed" | "error", statusCode
  * 简洁的 Session 列表项
  * 可复用组件,用于活跃 Session 列表的单项展示
  */
+export interface SessionListItemProps {
+  session: ActiveSessionInfo;
+  currencyCode?: CurrencyCode;
+  showTokensCost?: boolean;
+}
+
 export function SessionListItem({
   session,
   currencyCode = "USD",
-}: {
-  session: ActiveSessionInfo;
-  currencyCode?: CurrencyCode;
-}) {
+  showTokensCost = true,
+}: SessionListItemProps) {
   const statusInfo = getStatusIcon(session.status, session.statusCode);
   const StatusIcon = statusInfo.icon;
   const inputTokensDisplay =
@@ -106,18 +110,22 @@ export function SessionListItem({
         </div>
 
         {/* Token 和成本 */}
-        <div className="flex items-center gap-2 text-xs font-mono flex-shrink-0">
-          {(inputTokensDisplay || outputTokensDisplay) && (
-            <span className="text-muted-foreground">
-              {inputTokensDisplay && `↑${inputTokensDisplay}`}
-              {inputTokensDisplay && outputTokensDisplay && " "}
-              {outputTokensDisplay && `↓${outputTokensDisplay}`}
-            </span>
-          )}
-          {session.costUsd && (
-            <span className="font-medium">{formatCurrency(session.costUsd, currencyCode, 4)}</span>
-          )}
-        </div>
+        {showTokensCost && (
+          <div className="flex items-center gap-2 text-xs font-mono flex-shrink-0">
+            {(inputTokensDisplay || outputTokensDisplay) && (
+              <span className="text-muted-foreground">
+                {inputTokensDisplay && `↑${inputTokensDisplay}`}
+                {inputTokensDisplay && outputTokensDisplay && " "}
+                {outputTokensDisplay && `↓${outputTokensDisplay}`}
+              </span>
+            )}
+            {session.costUsd && (
+              <span className="font-medium">
+                {formatCurrency(session.costUsd, currencyCode, 4)}
+              </span>
+            )}
+          </div>
+        )}
       </div>
     </Link>
   );

+ 104 - 0
tests/unit/components/session-list-item.test.tsx

@@ -0,0 +1,104 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import type { ReactNode } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, test, vi } from "vitest";
+import { SessionListItem } from "@/components/customs/session-list-item";
+import type { CurrencyCode } from "@/lib/utils/currency";
+import type { ActiveSessionInfo } from "@/types/session";
+
+vi.mock("@/i18n/routing", () => ({
+  Link: ({
+    href,
+    children,
+    ...rest
+  }: {
+    href: string;
+    children: ReactNode;
+    className?: string;
+  }) => (
+    <a href={href} {...rest}>
+      {children}
+    </a>
+  ),
+}));
+
+vi.mock("@/lib/utils/currency", async () => {
+  const actual =
+    await vi.importActual<typeof import("@/lib/utils/currency")>("@/lib/utils/currency");
+  return {
+    ...actual,
+    formatCurrency: () => "__COST__",
+  };
+});
+
+const UP_ARROW = "\u2191";
+const DOWN_ARROW = "\u2193";
+const COST_SENTINEL = "__COST__";
+
+type SessionListItemProps = {
+  session: ActiveSessionInfo;
+  currencyCode?: CurrencyCode;
+  showTokensCost?: boolean;
+};
+
+const SessionListItemTest = SessionListItem as unknown as (
+  props: SessionListItemProps
+) => JSX.Element;
+
+const baseSession: ActiveSessionInfo = {
+  sessionId: "session-1",
+  userName: "alice",
+  userId: 1,
+  keyId: 2,
+  keyName: "key-1",
+  providerId: 3,
+  providerName: "openai",
+  model: "gpt-4.1",
+  apiType: "chat",
+  startTime: 1700000000000,
+  status: "completed",
+  durationMs: 1500,
+  inputTokens: 100,
+  outputTokens: 50,
+  costUsd: "0.0123",
+};
+
+function renderTextContent(options?: {
+  showTokensCost?: boolean;
+  sessionOverrides?: Partial<ActiveSessionInfo>;
+}) {
+  const session = { ...baseSession, ...(options?.sessionOverrides ?? {}) };
+  const html = renderToStaticMarkup(
+    <SessionListItemTest session={session} showTokensCost={options?.showTokensCost} />
+  );
+  const container = document.createElement("div");
+  container.innerHTML = html;
+  return container.textContent ?? "";
+}
+
+describe("SessionListItem showTokensCost", () => {
+  test("hides tokens and cost when disabled but keeps core fields", () => {
+    const text = renderTextContent({ showTokensCost: false });
+
+    expect(text).not.toContain(`${UP_ARROW}100`);
+    expect(text).not.toContain(`${DOWN_ARROW}50`);
+    expect(text).not.toContain(COST_SENTINEL);
+
+    expect(text).toContain("alice");
+    expect(text).toContain("key-1");
+    expect(text).toContain("gpt-4.1");
+    expect(text).toContain("@ openai");
+    expect(text).toContain("1.5s");
+  });
+
+  test("shows tokens and cost by default", () => {
+    const text = renderTextContent();
+
+    expect(text).toContain(`${UP_ARROW}100`);
+    expect(text).toContain(`${DOWN_ARROW}50`);
+    expect(text).toContain(COST_SENTINEL);
+  });
+});

+ 230 - 0
tests/unit/dashboard/dashboard-home-layout.test.tsx

@@ -0,0 +1,230 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import fs from "node:fs";
+import path from "node:path";
+import type { ReactNode } from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { NextIntlClientProvider } from "next-intl";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { DashboardBento } from "@/app/[locale]/dashboard/_components/bento/dashboard-bento";
+import { DashboardMain } from "@/app/[locale]/dashboard/_components/dashboard-main";
+import type { OverviewData } from "@/actions/overview";
+import type { UserStatisticsData } from "@/types/statistics";
+
+const routingMocks = vi.hoisted(() => ({
+  usePathname: vi.fn(),
+}));
+vi.mock("@/i18n/routing", () => ({
+  usePathname: routingMocks.usePathname,
+}));
+
+const overviewMocks = vi.hoisted(() => ({
+  getOverviewData: vi.fn(),
+}));
+vi.mock("@/actions/overview", () => overviewMocks);
+
+const activeSessionsMocks = vi.hoisted(() => ({
+  getActiveSessions: vi.fn(),
+}));
+vi.mock("@/actions/active-sessions", () => activeSessionsMocks);
+
+const statisticsMocks = vi.hoisted(() => ({
+  getUserStatistics: vi.fn(),
+}));
+vi.mock("@/actions/statistics", () => statisticsMocks);
+
+vi.mock("@/app/[locale]/dashboard/_components/bento/live-sessions-panel", () => ({
+  LiveSessionsPanel: () => <div data-testid="live-sessions-panel" />,
+}));
+
+vi.mock("@/app/[locale]/dashboard/_components/bento/leaderboard-card", () => ({
+  LeaderboardCard: () => <div data-testid="leaderboard-card" />,
+}));
+
+vi.mock("@/app/[locale]/dashboard/_components/bento/statistics-chart-card", () => ({
+  StatisticsChartCard: () => <div data-testid="statistics-chart-card" />,
+}));
+
+const customsMessages = JSON.parse(
+  fs.readFileSync(path.join(process.cwd(), "messages/en/customs.json"), "utf8")
+);
+const dashboardMessages = JSON.parse(
+  fs.readFileSync(path.join(process.cwd(), "messages/en/dashboard.json"), "utf8")
+);
+
+const mockOverviewData: OverviewData = {
+  concurrentSessions: 2,
+  todayRequests: 12,
+  todayCost: 1.23,
+  avgResponseTime: 456,
+  todayErrorRate: 0.1,
+  yesterdaySamePeriodRequests: 10,
+  yesterdaySamePeriodCost: 1.01,
+  yesterdaySamePeriodAvgResponseTime: 500,
+  recentMinuteRequests: 3,
+};
+
+const mockStatisticsData: UserStatisticsData = {
+  chartData: [],
+  users: [],
+  timeRange: "today",
+  resolution: "hour",
+  mode: "users",
+};
+
+function renderSimple(node: ReactNode) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  const root = createRoot(container);
+
+  act(() => {
+    root.render(node);
+  });
+
+  return {
+    container,
+    unmount: () => {
+      act(() => root.unmount());
+      container.remove();
+    },
+  };
+}
+
+function renderWithProviders(node: ReactNode) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  const root = createRoot(container);
+  const queryClient = new QueryClient({
+    defaultOptions: {
+      queries: {
+        retry: false,
+        refetchOnWindowFocus: false,
+      },
+    },
+  });
+
+  act(() => {
+    root.render(
+      <QueryClientProvider client={queryClient}>
+        <NextIntlClientProvider
+          locale="en"
+          messages={{ customs: customsMessages, dashboard: dashboardMessages }}
+          timeZone="UTC"
+        >
+          {node}
+        </NextIntlClientProvider>
+      </QueryClientProvider>
+    );
+  });
+
+  return {
+    container,
+    unmount: () => {
+      act(() => root.unmount());
+      container.remove();
+    },
+  };
+}
+
+function findByClassToken(root: ParentNode, token: string) {
+  return Array.from(root.querySelectorAll<HTMLElement>("*")).find((el) =>
+    el.classList.contains(token)
+  );
+}
+
+function findClosestWithClasses(element: Element | null, classes: string[]) {
+  let current = element?.parentElement ?? null;
+  while (current) {
+    const hasAll = classes.every((cls) => current.classList.contains(cls));
+    if (hasAll) return current;
+    current = current.parentElement;
+  }
+  return null;
+}
+
+async function flushPromises() {
+  await act(async () => {
+    await Promise.resolve();
+  });
+}
+
+beforeEach(() => {
+  vi.clearAllMocks();
+  document.body.innerHTML = "";
+  overviewMocks.getOverviewData.mockResolvedValue({ ok: true, data: mockOverviewData });
+  activeSessionsMocks.getActiveSessions.mockResolvedValue({ ok: true, data: [] });
+  statisticsMocks.getUserStatistics.mockResolvedValue({ ok: true, data: mockStatisticsData });
+  vi.stubGlobal(
+    "fetch",
+    vi.fn(async () => ({
+      ok: true,
+      json: async () => [],
+    }))
+  );
+});
+
+afterEach(() => {
+  vi.unstubAllGlobals();
+});
+
+describe("DashboardMain layout classes", () => {
+  test("pathname /dashboard removes max-w-7xl but keeps px-6", () => {
+    routingMocks.usePathname.mockReturnValue("/dashboard");
+    const { container, unmount } = renderSimple(
+      <DashboardMain>
+        <div data-testid="content" />
+      </DashboardMain>
+    );
+
+    const main = container.querySelector("main");
+    expect(main).toBeTruthy();
+    expect(main?.className).toContain("px-6");
+    expect(main?.className).not.toContain("max-w-7xl");
+
+    unmount();
+  });
+
+  test("pathname /dashboard/logs keeps max-w-7xl", () => {
+    routingMocks.usePathname.mockReturnValue("/dashboard/logs");
+    const { container, unmount } = renderSimple(
+      <DashboardMain>
+        <div data-testid="content" />
+      </DashboardMain>
+    );
+
+    const main = container.querySelector("main");
+    expect(main).toBeTruthy();
+    expect(main?.className).toContain("max-w-7xl");
+
+    unmount();
+  });
+});
+
+describe("DashboardBento admin layout", () => {
+  test("renders two-column layout with right sidebar LiveSessionsPanel", async () => {
+    const { container, unmount } = renderWithProviders(
+      <DashboardBento
+        isAdmin={true}
+        currencyCode="USD"
+        allowGlobalUsageView={false}
+        initialStatistics={mockStatisticsData}
+      />
+    );
+    await flushPromises();
+
+    const grid = findByClassToken(container, "lg:grid-cols-[minmax(0,1fr)_300px]");
+    expect(grid).toBeTruthy();
+
+    const livePanel = container.querySelector('[data-testid="live-sessions-panel"]');
+    expect(livePanel).toBeTruthy();
+
+    const sidebar = findClosestWithClasses(livePanel, ["hidden", "lg:block"]);
+    expect(sidebar).toBeTruthy();
+    expect(grid?.contains(sidebar as HTMLElement)).toBe(true);
+
+    unmount();
+  });
+});