feat: 관리자 대시보드 — 토큰·비용·응답시간 집계 API + 앱 /admin 페이지
- backend apps/stats: GET /api/v1/admin/stats?from&to → totals / byUser / byDay. ChatMessage(assistant) 집계만, is_superuser 아니면 403. 테스트 3개 - frontend features/admin: 요약 카드 4개(요청·토큰·비용·평균 응답), 일별 토큰 막대(div), 사용자별 표, 기간 프리셋(오늘/7/30/90일). 헤더에 ADMIN 만 아이콘. 테스트 2개 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
aa37cef13d
commit
d4a5f20787
@@ -5,6 +5,7 @@ export const PATHS = {
|
||||
SNAP_NEW: "/snap/new",
|
||||
SNAP_SESSION: "/snap/s/:id",
|
||||
SNIPPET: "/snippet",
|
||||
ADMIN: "/admin",
|
||||
} as const
|
||||
|
||||
export type Path = (typeof PATHS)[keyof typeof PATHS]
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { apiGet } from "@/lib/api/client"
|
||||
import type { AdminStatsResponse } from "@/types/api"
|
||||
|
||||
export const ADMIN_STATS_KEY = ["admin", "stats"] as const
|
||||
|
||||
export const adminApi = {
|
||||
stats: (from: string, to: string) =>
|
||||
apiGet<AdminStatsResponse>("/admin/stats", { params: { from, to } }),
|
||||
}
|
||||
|
||||
/** 관리자 집계 — 기간(YYYY-MM-DD) 바뀌면 재조회. 관리자 아니면 403 이라 enabled 로 막음. */
|
||||
export function useAdminStats(from: string, to: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: [...ADMIN_STATS_KEY, from, to],
|
||||
queryFn: () => adminApi.stats(from, to),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import AdminStatsPage from "./AdminStatsPage"
|
||||
import { useAuthStore } from "@/features/auth/store/authStore"
|
||||
import * as client from "@/lib/api/client"
|
||||
import type { AdminStatsResponse } from "@/types/api"
|
||||
|
||||
// 훅이 모듈 안 adminApi 를 직접 잡고 있어서 그 아래 apiGet 을 막음
|
||||
vi.mock("@/lib/api/client", async (orig) => ({
|
||||
...(await orig<typeof client>()),
|
||||
apiGet: vi.fn(),
|
||||
}))
|
||||
|
||||
const SAMPLE: AdminStatsResponse = {
|
||||
from: "2026-09-01",
|
||||
to: "2026-09-21",
|
||||
totals: {
|
||||
requests: 12,
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
totalTokens: 1500,
|
||||
costUsd: 0.1234,
|
||||
avgElapsedMs: 2500,
|
||||
users: 2,
|
||||
sessions: 3,
|
||||
},
|
||||
byUser: [
|
||||
{
|
||||
userId: "u1",
|
||||
email: "a@x.com",
|
||||
name: "김개발",
|
||||
sessions: 2,
|
||||
requests: 10,
|
||||
inputTokens: 900,
|
||||
outputTokens: 450,
|
||||
totalTokens: 1350,
|
||||
costUsd: 0.12,
|
||||
avgElapsedMs: 2000,
|
||||
},
|
||||
],
|
||||
byDay: [
|
||||
{
|
||||
day: "2026-09-20",
|
||||
requests: 5,
|
||||
inputTokens: 500,
|
||||
outputTokens: 250,
|
||||
totalTokens: 750,
|
||||
costUsd: 0.05,
|
||||
avgElapsedMs: 2000,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<AdminStatsPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const asUser = (role: "ADMIN" | "USER") =>
|
||||
useAuthStore.setState({
|
||||
user: {
|
||||
id: "u",
|
||||
email: "a@x.com",
|
||||
userName: null,
|
||||
role,
|
||||
employeeId: null,
|
||||
department: null,
|
||||
authProvider: "local",
|
||||
},
|
||||
})
|
||||
|
||||
describe("AdminStatsPage", () => {
|
||||
beforeEach(() => vi.mocked(client.apiGet).mockReset().mockResolvedValue(SAMPLE))
|
||||
|
||||
it("관리자 아니면 안내만 보이고 API 안 부름", () => {
|
||||
asUser("USER")
|
||||
mount()
|
||||
expect(screen.getByText("관리자만 볼 수 있어")).toBeTruthy()
|
||||
expect(client.apiGet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("관리자면 요약 카드·사용자 표를 그린다", async () => {
|
||||
asUser("ADMIN")
|
||||
mount()
|
||||
expect(await screen.findByText("1,500")).toBeTruthy() // 총 토큰
|
||||
expect(screen.getByText("$0.1234")).toBeTruthy()
|
||||
expect(screen.getByText("2.5s")).toBeTruthy()
|
||||
expect(screen.getByText("김개발")).toBeTruthy()
|
||||
expect(client.apiGet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
import { PATHS } from "@/config/routes"
|
||||
import { useAuthStore } from "@/features/auth/store/authStore"
|
||||
import { useEscapeKey } from "@/features/snap/hooks/useEscapeKey"
|
||||
import { Kbd } from "@/shared/components/Kbd"
|
||||
import { ApiError } from "@/lib/api/errors"
|
||||
import { useAdminStats } from "../api/admin.api"
|
||||
|
||||
const fmtInt = (n: number) => n.toLocaleString("ko-KR")
|
||||
const fmtUsd = (n: number) => `$${n.toFixed(n < 1 ? 4 : 2)}`
|
||||
const fmtSec = (ms: number | null) => (ms === null ? "–" : `${(ms / 1000).toFixed(1)}s`)
|
||||
const isoDay = (d: Date) => d.toISOString().slice(0, 10)
|
||||
|
||||
const PRESETS: { label: string; days: number }[] = [
|
||||
{ label: "오늘", days: 1 },
|
||||
{ label: "7일", days: 7 },
|
||||
{ label: "30일", days: 30 },
|
||||
{ label: "90일", days: 90 },
|
||||
]
|
||||
|
||||
/** 관리자 대시보드 — 총 토큰·비용·응답시간 + 사용자별·일별. 서버는 ChatMessage 집계만(새로 모으는 것 없음). */
|
||||
export default function AdminStatsPage() {
|
||||
const navigate = useNavigate()
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === "ADMIN")
|
||||
const [days, setDays] = useState(30)
|
||||
useEscapeKey(() => navigate(PATHS.SNAP))
|
||||
|
||||
const to = isoDay(new Date())
|
||||
const from = isoDay(new Date(Date.now() - (days - 1) * 86_400_000))
|
||||
const { data, isLoading, error } = useAdminStats(from, to, isAdmin)
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
|
||||
관리자만 볼 수 있어
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const t = data?.totals
|
||||
const maxDayTokens = Math.max(1, ...(data?.byDay.map((d) => d.totalTokens) ?? [0]))
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full max-w-[880px] flex-col gap-4 overflow-y-auto px-5 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(PATHS.SNAP)}
|
||||
className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring -ml-2 inline-flex h-7 items-center gap-1 rounded-md px-2 text-[11.5px] transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" aria-hidden="true" />
|
||||
목록
|
||||
<Kbd>Esc</Kbd>
|
||||
</button>
|
||||
<div className="flex items-center gap-1" role="group" aria-label="기간">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.days}
|
||||
type="button"
|
||||
onClick={() => setDays(p.days)}
|
||||
aria-pressed={days === p.days}
|
||||
className={`h-7 rounded-md px-2.5 text-[11.5px] font-medium transition-colors ${
|
||||
days === p.days
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">사용량</h1>
|
||||
<p className="text-muted-foreground text-[11.5px]">
|
||||
{from} ~ {to} · assistant 답변 기준
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error instanceof ApiError && <div className="text-destructive text-sm">{error.message}</div>}
|
||||
{isLoading && <div className="text-muted-foreground text-xs">불러오는 중…</div>}
|
||||
|
||||
{t && (
|
||||
<>
|
||||
{/* 요약 카드 4개 */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<Stat
|
||||
label="요청"
|
||||
value={fmtInt(t.requests)}
|
||||
sub={`${t.users}명 · ${t.sessions}세션`}
|
||||
/>
|
||||
<Stat
|
||||
label="토큰"
|
||||
value={fmtInt(t.totalTokens)}
|
||||
sub={`입력 ${fmtInt(t.inputTokens)} · 출력 ${fmtInt(t.outputTokens)}`}
|
||||
/>
|
||||
<Stat label="비용" value={fmtUsd(t.costUsd)} sub="OpenCode 집계 기준" />
|
||||
<Stat label="평균 응답" value={fmtSec(t.avgElapsedMs)} sub="첫 요청→완료" />
|
||||
</div>
|
||||
|
||||
{/* 일별 토큰 막대 — 라이브러리 없이 div 로 */}
|
||||
<section className="border-border bg-card rounded-lg border p-3">
|
||||
<div className="text-muted-foreground mb-2 text-[11px] font-semibold tracking-wide">
|
||||
일별 토큰
|
||||
</div>
|
||||
{data.byDay.length === 0 ? (
|
||||
<div className="text-muted-foreground py-6 text-center text-xs">
|
||||
기간 내 데이터 없음
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-28 items-end gap-[3px]" role="img" aria-label="일별 토큰 막대">
|
||||
{data.byDay.map((d) => (
|
||||
<div
|
||||
key={d.day}
|
||||
title={`${d.day}: ${fmtInt(d.totalTokens)} 토큰 · ${d.requests}건 · ${fmtUsd(d.costUsd)}`}
|
||||
className="bg-primary/80 hover:bg-primary min-w-[4px] flex-1 rounded-sm transition-colors"
|
||||
style={{ height: `${Math.max(2, (d.totalTokens / maxDayTokens) * 100)}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 사용자별 표 */}
|
||||
<section className="border-border bg-card overflow-hidden rounded-lg border">
|
||||
<table className="w-full text-[12px]">
|
||||
<thead className="text-muted-foreground bg-muted/50 text-left text-[11px]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-semibold">사용자</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">요청</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">세션</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">토큰</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">비용</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">평균 응답</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border/70 divide-y">
|
||||
{data.byUser.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-muted-foreground py-6 text-center text-xs">
|
||||
기간 내 사용자 없음
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{data.byUser.map((u) => (
|
||||
<tr key={u.userId} className="hover:bg-accent/40">
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium">{u.name || u.email}</div>
|
||||
{u.name && <div className="text-muted-foreground text-[11px]">{u.email}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{fmtInt(u.requests)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{fmtInt(u.sessions)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{fmtInt(u.totalTokens)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{fmtUsd(u.costUsd)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{fmtSec(u.avgElapsedMs)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value, sub }: { label: string; value: string; sub: string }) {
|
||||
return (
|
||||
<div className="border-border bg-card rounded-lg border px-3 py-2.5">
|
||||
<div className="text-muted-foreground text-[11px] font-semibold tracking-wide">{label}</div>
|
||||
<div className="mt-0.5 text-lg font-semibold tabular-nums">{value}</div>
|
||||
<div className="text-muted-foreground text-[11px]">{sub}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { LogOut } from "lucide-react"
|
||||
import { BarChart3, LogOut } from "lucide-react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { PATHS } from "@/config/routes"
|
||||
import { useLogout } from "@/features/auth/hooks/useLogout"
|
||||
import { useAuthStore } from "@/features/auth/store/authStore"
|
||||
import { ThemeToggle } from "@/shared/components/ThemeToggle"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
|
||||
@@ -9,6 +10,7 @@ import { Button } from "@/shared/ui/button"
|
||||
export function SnapUserControls() {
|
||||
const navigate = useNavigate()
|
||||
const logout = useLogout()
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === "ADMIN")
|
||||
|
||||
const handleLogout = () => {
|
||||
logout.mutate(undefined, {
|
||||
@@ -18,6 +20,19 @@ export function SnapUserControls() {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{isAdmin && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground size-7"
|
||||
aria-label="관리자 대시보드"
|
||||
title="관리자 대시보드"
|
||||
onClick={() => navigate(PATHS.ADMIN)}
|
||||
>
|
||||
<BarChart3 className="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -12,6 +12,7 @@ const SessionListPage = lazy(() => import("@/features/snap/pages/SessionListPage
|
||||
const NewChatPage = lazy(() => import("@/features/snap/pages/NewChatPage"))
|
||||
const SessionChatPage = lazy(() => import("@/features/snap/pages/SessionChatPage"))
|
||||
const SnippetPalettePage = lazy(() => import("@/features/snippets/pages/SnippetPalettePage"))
|
||||
const AdminStatsPage = lazy(() => import("@/features/admin/pages/AdminStatsPage"))
|
||||
|
||||
const fallback = <CenterSpinner />
|
||||
|
||||
@@ -35,6 +36,7 @@ export const routes: RouteObject[] = [
|
||||
{ path: PATHS.SNAP.slice(1), element: wrap(<SessionListPage />) },
|
||||
{ path: PATHS.SNAP_NEW.slice(1), element: wrap(<NewChatPage />) },
|
||||
{ path: PATHS.SNAP_SESSION.slice(1), element: wrap(<SessionChatPage />) },
|
||||
{ path: PATHS.ADMIN.slice(1), element: wrap(<AdminStatsPage />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -64,3 +64,21 @@ export interface EntraConfigResponse {
|
||||
authority: string
|
||||
tenantId: string
|
||||
}
|
||||
|
||||
// 관리자 대시보드 — GET /admin/stats
|
||||
export interface AdminStatRow {
|
||||
requests: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
totalTokens: number
|
||||
costUsd: number
|
||||
avgElapsedMs: number | null
|
||||
}
|
||||
|
||||
export interface AdminStatsResponse {
|
||||
from: string
|
||||
to: string
|
||||
totals: AdminStatRow & { users: number; sessions: number }
|
||||
byUser: (AdminStatRow & { userId: string; email: string; name: string; sessions: number })[]
|
||||
byDay: (AdminStatRow & { day: string })[]
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/config/env.ts","./src/config/routes.ts","./src/features/auth/index.ts","./src/features/auth/api/auth.api.ts","./src/features/auth/components/entraloginbutton.tsx","./src/features/auth/components/entraloginsection.tsx","./src/features/auth/components/loginform.tsx","./src/features/auth/components/sessionexpirydialog.tsx","./src/features/auth/hooks/useentraenabled.ts","./src/features/auth/hooks/useentralogin.ts","./src/features/auth/hooks/uselogin.ts","./src/features/auth/hooks/uselogout.ts","./src/features/auth/hooks/useme.ts","./src/features/auth/hooks/useslidingrefresh.ts","./src/features/auth/pages/loginpage.tsx","./src/features/auth/schemas/index.ts","./src/features/auth/store/authstore.ts","./src/features/auth/store/sessionexpirystore.ts","./src/features/auth/utils/saferedirectpath.ts","./src/features/snap/api/snap.api.ts","./src/features/snap/api/snap.stream.ts","./src/features/snap/components/chatheader.tsx","./src/features/snap/components/clipboardhistory.tsx","./src/features/snap/components/codeblock.tsx","./src/features/snap/components/composer.tsx","./src/features/snap/components/hero.tsx","./src/features/snap/components/message.tsx","./src/features/snap/components/navrail.tsx","./src/features/snap/components/searchhitcard.tsx","./src/features/snap/components/sessioncard.tsx","./src/features/snap/components/sessionsearch.tsx","./src/features/snap/components/snaplayout.tsx","./src/features/snap/components/snapusercontrols.tsx","./src/features/snap/components/abaphljs.ts","./src/features/snap/contract/types.ts","./src/features/snap/hooks/useescapekey.ts","./src/features/snap/hooks/usesnapchat.ts","./src/features/snap/lib/format.ts","./src/features/snap/pages/newchatpage.tsx","./src/features/snap/pages/sessionchatpage.tsx","./src/features/snap/pages/sessionlistpage.tsx","./src/features/snap/store/snapchatstore.ts","./src/features/snippets/types.ts","./src/features/snippets/api/snippets.api.ts","./src/features/snippets/components/categorychips.tsx","./src/features/snippets/components/editdialog.tsx","./src/features/snippets/components/previewpane.tsx","./src/features/snippets/components/snippetrow.tsx","./src/features/snippets/core/ranking.ts","./src/features/snippets/core/search.ts","./src/features/snippets/hooks/usesnippets.ts","./src/features/snippets/pages/snippetpalettepage.tsx","./src/lib/api/client.ts","./src/lib/api/errors.ts","./src/lib/auth/msal.ts","./src/lib/auth/tokenprovider.ts","./src/lib/bridge/bridgenavigate.ts","./src/lib/bridge/snippetbridge.ts","./src/lib/bridge/transport.ts","./src/lib/bridge/webviewbridge.ts","./src/lib/hooks/usedebounce.ts","./src/lib/query/queryclient.ts","./src/lib/streaming/stoppednotice.tsx","./src/lib/streaming/streamingtext.tsx","./src/lib/streaming/abort.ts","./src/lib/streaming/index.ts","./src/lib/streaming/sse.ts","./src/lib/streaming/streamllm.ts","./src/lib/streaming/usesmoothedtext.ts","./src/lib/streaming/usestreamsession.ts","./src/lib/utils/cn.ts","./src/lib/utils/randomid.ts","./src/lib/utils/relativetime.ts","./src/lib/utils/sleep.ts","./src/shared/components/centerspinner.tsx","./src/shared/components/desktopwindowframe.tsx","./src/shared/components/errorboundary.tsx","./src/shared/components/kbd.tsx","./src/shared/components/layout.tsx","./src/shared/components/logomark.tsx","./src/shared/components/paletteshell.tsx","./src/shared/components/pastetargetbadge.tsx","./src/shared/components/protectedroute.tsx","./src/shared/components/themetoggle.tsx","./src/shared/hooks/usepastetarget.ts","./src/shared/store/themestore.ts","./src/shared/theme/derive.ts","./src/shared/theme/palettes.ts","./src/shared/theme/usetheme.ts","./src/shared/ui/alert-dialog.tsx","./src/shared/ui/alert.tsx","./src/shared/ui/button.tsx","./src/shared/ui/dialog.tsx","./src/shared/ui/dropdown-menu.tsx","./src/shared/ui/input.tsx","./src/shared/ui/label.tsx","./src/shared/ui/separator.tsx","./src/shared/ui/skeleton.tsx","./src/shared/ui/textarea.tsx","./src/shared/ui/tooltip.tsx","./src/types/api.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/config/env.ts","./src/config/routes.ts","./src/features/admin/api/admin.api.ts","./src/features/admin/pages/adminstatspage.tsx","./src/features/auth/index.ts","./src/features/auth/api/auth.api.ts","./src/features/auth/components/entraloginbutton.tsx","./src/features/auth/components/entraloginsection.tsx","./src/features/auth/components/loginform.tsx","./src/features/auth/components/sessionexpirydialog.tsx","./src/features/auth/hooks/useentraenabled.ts","./src/features/auth/hooks/useentralogin.ts","./src/features/auth/hooks/uselogin.ts","./src/features/auth/hooks/uselogout.ts","./src/features/auth/hooks/useme.ts","./src/features/auth/hooks/useslidingrefresh.ts","./src/features/auth/pages/loginpage.tsx","./src/features/auth/schemas/index.ts","./src/features/auth/store/authstore.ts","./src/features/auth/store/sessionexpirystore.ts","./src/features/auth/utils/saferedirectpath.ts","./src/features/snap/api/snap.api.ts","./src/features/snap/api/snap.stream.ts","./src/features/snap/components/chatheader.tsx","./src/features/snap/components/clipboardhistory.tsx","./src/features/snap/components/codeblock.tsx","./src/features/snap/components/composer.tsx","./src/features/snap/components/hero.tsx","./src/features/snap/components/message.tsx","./src/features/snap/components/navrail.tsx","./src/features/snap/components/searchhitcard.tsx","./src/features/snap/components/sessioncard.tsx","./src/features/snap/components/sessionsearch.tsx","./src/features/snap/components/snaplayout.tsx","./src/features/snap/components/snapusercontrols.tsx","./src/features/snap/components/abaphljs.ts","./src/features/snap/contract/types.ts","./src/features/snap/hooks/useescapekey.ts","./src/features/snap/hooks/usesnapchat.ts","./src/features/snap/lib/format.ts","./src/features/snap/pages/newchatpage.tsx","./src/features/snap/pages/sessionchatpage.tsx","./src/features/snap/pages/sessionlistpage.tsx","./src/features/snap/store/snapchatstore.ts","./src/features/snippets/types.ts","./src/features/snippets/api/snippets.api.ts","./src/features/snippets/components/categorychips.tsx","./src/features/snippets/components/editdialog.tsx","./src/features/snippets/components/previewpane.tsx","./src/features/snippets/components/snippetrow.tsx","./src/features/snippets/core/ranking.ts","./src/features/snippets/core/search.ts","./src/features/snippets/hooks/usesnippets.ts","./src/features/snippets/pages/snippetpalettepage.tsx","./src/lib/api/client.ts","./src/lib/api/errors.ts","./src/lib/auth/msal.ts","./src/lib/auth/tokenprovider.ts","./src/lib/bridge/bridgenavigate.ts","./src/lib/bridge/snippetbridge.ts","./src/lib/bridge/transport.ts","./src/lib/bridge/webviewbridge.ts","./src/lib/hooks/usedebounce.ts","./src/lib/query/queryclient.ts","./src/lib/streaming/stoppednotice.tsx","./src/lib/streaming/streamingtext.tsx","./src/lib/streaming/abort.ts","./src/lib/streaming/index.ts","./src/lib/streaming/sse.ts","./src/lib/streaming/streamllm.ts","./src/lib/streaming/usesmoothedtext.ts","./src/lib/streaming/usestreamsession.ts","./src/lib/utils/cn.ts","./src/lib/utils/randomid.ts","./src/lib/utils/relativetime.ts","./src/lib/utils/sleep.ts","./src/shared/components/centerspinner.tsx","./src/shared/components/desktopwindowframe.tsx","./src/shared/components/errorboundary.tsx","./src/shared/components/kbd.tsx","./src/shared/components/layout.tsx","./src/shared/components/logomark.tsx","./src/shared/components/paletteshell.tsx","./src/shared/components/pastetargetbadge.tsx","./src/shared/components/protectedroute.tsx","./src/shared/components/themetoggle.tsx","./src/shared/hooks/usepastetarget.ts","./src/shared/store/themestore.ts","./src/shared/theme/derive.ts","./src/shared/theme/palettes.ts","./src/shared/theme/usetheme.ts","./src/shared/ui/alert-dialog.tsx","./src/shared/ui/alert.tsx","./src/shared/ui/button.tsx","./src/shared/ui/dialog.tsx","./src/shared/ui/dropdown-menu.tsx","./src/shared/ui/input.tsx","./src/shared/ui/label.tsx","./src/shared/ui/separator.tsx","./src/shared/ui/skeleton.tsx","./src/shared/ui/textarea.tsx","./src/shared/ui/tooltip.tsx","./src/types/api.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user