From d70bf820e15597991245796e8135ae9aeb1b73ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:21:39 +0000 Subject: [PATCH 1/3] feat: add admin stats endpoint and observability dashboard updates Co-authored-by: MinecraftFuns <25814618+MinecraftFuns@users.noreply.github.com> Agent-Logs-Url: https://github.com/BTreeMap/OneShot/sessions/d47e0923-07d8-4f34-b78c-abef458ea389 --- api/app/uploads/router.py | 47 +++ api/openapi.json | 67 +++- api/tests/test_oneshot_integration.py | 13 + web/src/api/openapi.ts | 55 ++++ web/src/api/types.ts | 5 + web/src/pages/Admin.tsx | 54 +++- web/src/pages/Dashboard.tsx | 348 ++++++++------------- web/src/pages/__tests__/Admin.test.tsx | 3 + web/src/pages/__tests__/Dashboard.test.tsx | 46 +++ 9 files changed, 408 insertions(+), 230 deletions(-) create mode 100644 web/src/pages/__tests__/Dashboard.test.tsx diff --git a/api/app/uploads/router.py b/api/app/uploads/router.py index 3f18f89..ae714fe 100644 --- a/api/app/uploads/router.py +++ b/api/app/uploads/router.py @@ -56,6 +56,15 @@ class OneShotTokenAuditItem(BaseModel): target_email: str | None is_used: bool created_at: datetime + expires_at: datetime + + +class OneShotStatsResponse(BaseModel): + total_files: int + total_storage_bytes: int + tokens_issued: int + tokens_used: int + active_tokens: int class FileAuditItem(BaseModel): @@ -239,11 +248,49 @@ async def list_oneshot_tokens( target_email=row.target_email, is_used=row.is_used, created_at=row.created_at, + expires_at=row.expires_at, ) for row in rows ] +@router.get("/admin/stats", response_model=OneShotStatsResponse) +async def get_oneshot_stats( + db: AsyncSession = Depends(_db_dep), + _admin_user: User = require_admin(), +) -> OneShotStatsResponse: + total_files = ( + await db.execute(select(func.count(FileMetadata.id))) + ).scalar_one() + total_storage_bytes = ( + await db.execute(select(func.coalesce(func.sum(FileMetadata.size_bytes), 0))) + ).scalar_one() + tokens_issued = ( + await db.execute(select(func.count(OneShotToken.id))) + ).scalar_one() + tokens_used = ( + await db.execute( + select(func.count(OneShotToken.id)).where(OneShotToken.is_used.is_(True)) + ) + ).scalar_one() + active_tokens = ( + await db.execute( + select(func.count(OneShotToken.id)).where( + OneShotToken.is_used.is_(False), + OneShotToken.expires_at > func.now(), + ) + ) + ).scalar_one() + + return OneShotStatsResponse( + total_files=total_files, + total_storage_bytes=total_storage_bytes, + tokens_issued=tokens_issued, + tokens_used=tokens_used, + active_tokens=active_tokens, + ) + + @router.get("/admin/files", response_model=list[FileAuditItem]) async def list_uploaded_files( db: AsyncSession = Depends(_db_dep), diff --git a/api/openapi.json b/api/openapi.json index f977427..3cea4f4 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -214,6 +214,11 @@ "title": "Created At", "type": "string" }, + "expires_at": { + "format": "date-time", + "title": "Expires At", + "type": "string" + }, "id": { "title": "Id", "type": "string" @@ -238,11 +243,45 @@ "id", "target_email", "is_used", - "created_at" + "created_at", + "expires_at" ], "title": "OneShotTokenAuditItem", "type": "object" }, + "OneShotStatsResponse": { + "properties": { + "active_tokens": { + "title": "Active Tokens", + "type": "integer" + }, + "tokens_issued": { + "title": "Tokens Issued", + "type": "integer" + }, + "tokens_used": { + "title": "Tokens Used", + "type": "integer" + }, + "total_files": { + "title": "Total Files", + "type": "integer" + }, + "total_storage_bytes": { + "title": "Total Storage Bytes", + "type": "integer" + } + }, + "required": [ + "total_files", + "total_storage_bytes", + "tokens_issued", + "tokens_used", + "active_tokens" + ], + "title": "OneShotStatsResponse", + "type": "object" + }, "OneShotUploadResponse": { "properties": { "file_id": { @@ -763,6 +802,32 @@ ] } }, + "/api/admin/stats": { + "get": { + "operationId": "get_oneshot_stats_api_admin_stats_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OneShotStatsResponse" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "DeviceJWT": [] + } + ], + "summary": "Get Oneshot Stats", + "tags": [ + "oneshot" + ] + } + }, "/api/admin/files/{file_id}/download": { "get": { "operationId": "download_file_api_admin_files__file_id__download_get", diff --git a/api/tests/test_oneshot_integration.py b/api/tests/test_oneshot_integration.py index 0f617c1..b3fcf91 100644 --- a/api/tests/test_oneshot_integration.py +++ b/api/tests/test_oneshot_integration.py @@ -110,6 +110,7 @@ async def _assert_expiry_is_set() -> None: assert token_row["target_email"] == "recipient@example.com" assert token_row["is_used"] is True assert token_row["created_at"] + assert token_row["expires_at"] files_response = client.get( "/api/admin/files", @@ -122,6 +123,18 @@ async def _assert_expiry_is_set() -> None: assert file_row["size_bytes"] == len(b"classified-bytes") assert file_row["created_at"] + stats_response = client.get( + "/api/admin/stats", + headers={"Authorization": f"Bearer {admin_jwt}"}, + ) + assert stats_response.status_code == 200 + stats = stats_response.json() + assert stats["total_files"] >= 1 + assert stats["total_storage_bytes"] >= len(b"classified-bytes") + assert stats["tokens_issued"] >= 1 + assert stats["tokens_used"] >= 1 + assert stats["active_tokens"] >= 0 + download_response = client.get( f"/api/admin/files/{file_id}/download", headers={"Authorization": f"Bearer {admin_jwt}"}, diff --git a/web/src/api/openapi.ts b/web/src/api/openapi.ts index 1c30e20..868f435 100644 --- a/web/src/api/openapi.ts +++ b/web/src/api/openapi.ts @@ -41,6 +41,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Oneshot Stats */ + get: operations["get_oneshot_stats_api_admin_stats_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/files/{file_id}/download": { parameters: { query?: never; @@ -479,6 +496,11 @@ export interface components { * Format: date-time */ created_at: string; + /** + * Expires At + * Format: date-time + */ + expires_at: string; /** Id */ id: string; /** Is Used */ @@ -486,6 +508,19 @@ export interface components { /** Target Email */ target_email: string | null; }; + /** OneShotStatsResponse */ + OneShotStatsResponse: { + /** Active Tokens */ + active_tokens: number; + /** Tokens Issued */ + tokens_issued: number; + /** Tokens Used */ + tokens_used: number; + /** Total Files */ + total_files: number; + /** Total Storage Bytes */ + total_storage_bytes: number; + }; /** OneShotUploadResponse */ OneShotUploadResponse: { /** File Id */ @@ -784,6 +819,26 @@ export interface operations { }; }; }; + get_oneshot_stats_api_admin_stats_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OneShotStatsResponse"]; + }; + }; + }; + }; download_file_api_admin_files__file_id__download_get: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index de8da5c..70101e6 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -40,6 +40,9 @@ export type EchoResponse = components["schemas"]["EchoResponse"]; /** Item body for GET /api/admin/oneshot-tokens */ export type OneShotTokenAuditItem = components["schemas"]["OneShotTokenAuditItem"]; +/** Response body for GET /api/admin/stats */ +export type OneShotStatsResponse = components["schemas"]["OneShotStatsResponse"]; + /** Item body for GET /api/admin/files */ export type FileAuditItem = components["schemas"]["FileAuditItem"]; @@ -51,6 +54,7 @@ type _AssertDemoEchoPost = paths["/demo/echo"]["post"]; type _AssertDemoPingGet = paths["/demo/ping"]["get"]; type _AssertDemoSseGet = paths["/demo/sse"]["get"]; type _AssertAdminOneShotTokensGet = paths["/api/admin/oneshot-tokens"]["get"]; +type _AssertAdminStatsGet = paths["/api/admin/stats"]["get"]; type _AssertAdminFilesGet = paths["/api/admin/files"]["get"]; type _AssertAdminFileDownloadGet = paths["/api/admin/files/{file_id}/download"]["get"]; @@ -62,6 +66,7 @@ export type { _AssertDemoPingGet, _AssertDemoSseGet, _AssertAdminOneShotTokensGet, + _AssertAdminStatsGet, _AssertAdminFilesGet, _AssertAdminFileDownloadGet, }; diff --git a/web/src/pages/Admin.tsx b/web/src/pages/Admin.tsx index a98470b..f061fb5 100644 --- a/web/src/pages/Admin.tsx +++ b/web/src/pages/Admin.tsx @@ -26,6 +26,23 @@ function formatBytes(sizeBytes: number): string { return `${sizeBytes} B`; } +function tokenStatus(tokenRow: OneShotTokenAuditItem): "Used" | "Expired" | "Active" { + if (tokenRow.is_used) { + return "Used"; + } + return new Date(tokenRow.expires_at) < new Date() ? "Expired" : "Active"; +} + +function statusClassName(status: "Used" | "Expired" | "Active"): string { + if (status === "Used") { + return "bg-success/15 text-success"; + } + if (status === "Expired") { + return "bg-danger/15 text-danger"; + } + return "bg-primary/15 text-primary"; +} + function parseDispositionFilename(contentDisposition: string | null): string | null { if (!contentDisposition) { return null; @@ -249,21 +266,36 @@ export function Admin() { Token Email - Used + Status Created - {tokens.map((tokenRow) => ( - - {tokenRow.id.slice(0, 8)}… - {tokenRow.target_email ?? "—"} - {tokenRow.is_used ? "Yes" : "No"} - - {new Date(tokenRow.created_at).toLocaleString()} - - - ))} + {tokens.map((tokenRow) => { + const status = tokenStatus(tokenRow); + return ( + + {tokenRow.id.slice(0, 8)}… + {tokenRow.target_email ?? "—"} + + + {status} + + + +
{new Date(tokenRow.created_at).toLocaleString()}
+
+ Expires: {new Date(tokenRow.expires_at).toLocaleString()} +
+ + + ); + })} {tokens.length === 0 && ( diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index b96afba..b2b3742 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -1,13 +1,7 @@ -import { - Activity, - CreditCard, - DollarSign, - Users, - ArrowUpRight, - ArrowDownRight, - MousePointer2, -} from "lucide-react"; -import { useAuth } from "../auth"; +import { useEffect, useState } from "react"; +import { FileText, HardDrive, Link, ShieldCheck } from "lucide-react"; +import { getOrMintToken, useAuth } from "../auth"; +import type { OneShotStatsResponse } from "../api/types"; import { Card, CardHeader, @@ -15,229 +9,147 @@ import { CardContent, CardDescription, } from "../components/Card"; -import { Avatar, AvatarFallback, AvatarImage } from "../components/Avatar"; -import { Button } from "../components/Button"; +import { Alert } from "../components/Alert"; -export function Dashboard() { - const { user } = useAuth(); +function formatBytes(sizeBytes: number): string { + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = sizeBytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + if (unitIndex === 0) { + return `${value} ${units[unitIndex]}`; + } + return `${value.toFixed(2)} ${units[unitIndex]}`; +} - const stats = [ - { - title: "Total Revenue", - value: "$45,231.89", - change: "+20.1% from last month", - icon: DollarSign, - }, - { - title: "Subscriptions", - value: "+2350", - change: "+180.1% from last month", - icon: Users, - }, - { - title: "Sales", - value: "+12,234", - change: "+19% from last month", - icon: CreditCard, - }, - { - title: "Active Now", - value: "+573", - change: "+201 since last hour", - icon: Activity, - }, - ]; +export function Dashboard() { + const { userId } = useAuth(); + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(null); - const recentSales = [ - { - name: "Olivia Martin", - email: "olivia.martin@email.com", - amount: "+$1,999.00", - avatar: "https://ui.shadcn.com/avatars/01.png", - initials: "OM", - }, - { - name: "Jackson Lee", - email: "jackson.lee@email.com", - amount: "+$39.00", - avatar: "https://ui.shadcn.com/avatars/02.png", - initials: "JL", - }, - { - name: "Isabella Nguyen", - email: "isabella.nguyen@email.com", - amount: "+$299.00", - avatar: "https://ui.shadcn.com/avatars/03.png", - initials: "IN", - }, - { - name: "William Kim", - email: "will@email.com", - amount: "+$99.00", - avatar: "https://ui.shadcn.com/avatars/04.png", - initials: "WK", - }, - { - name: "Sofia Davis", - email: "sofia.davis@email.com", - amount: "+$39.00", - avatar: "https://ui.shadcn.com/avatars/05.png", - initials: "SD", - }, - ]; + useEffect(() => { + let isMounted = true; + const loadStats = async () => { + setIsLoading(true); + setErrorMessage(null); + try { + const token = await getOrMintToken("http"); + const response = await fetch("/api/admin/stats", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) { + throw new Error(`Failed to load dashboard stats (status: ${response.status})`); + } + const payload = (await response.json()) as OneShotStatsResponse; + if (isMounted) { + setStats(payload); + } + } catch (e) { + if (isMounted) { + setErrorMessage( + e instanceof Error ? e.message : "Failed to load dashboard stats.", + ); + } + } finally { + if (isMounted) { + setIsLoading(false); + } + } + }; + void loadStats(); + return () => { + isMounted = false; + }; + }, []); return (
-
-
-

- Dashboard -

-

- Overview for {user?.id}. -

-
-
- -
+
+

+ Dashboard +

+

+ OneShot observability for {userId}. +

-
- {stats.map((stat, i) => { - const Icon = stat.icon; - return ( - + {isLoading && Loading OneShot operational metrics…} + {errorMessage && {errorMessage}} + + {stats && ( + <> +
+ - - {stat.title} - - + Total Files Received + -
{stat.value}
-

{stat.change}

+
{stats.total_files}
- ); - })} -
- -
- - - Overview - - Your application's monthly recurring revenue. - - - -
- {/* Mock Chart Bars */} - {[45, 23, 78, 56, 34, 89, 45, 67, 23, 89, 45, 76].map( - (height, i) => ( -
-
-
-
- - {new Date(0, i).toLocaleString("default", { - month: "short", - })} - -
- ), - )} -
- - - - - Recent Sales - - You made 265 sales this month. - - - -
- {recentSales.map((sale, i) => ( -
- - - {sale.initials} - -
-

- {sale.name} -

-

{sale.email}

-
-
{sale.amount}
+ + + Storage Used + + + +
+ {formatBytes(stats.total_storage_bytes)}
- ))} -
- - -
+
+
+ + + Active Upload Links + + + +
{stats.active_tokens}
+
+
+ + + Total Tokens Issued + + + +
{stats.tokens_issued}
+
+
+
-
- - - User Acquisition - New users over time - - -
-
-
- -
-
+12.5%
-

vs last 30 days

-
-
-
-
- - - Bounce Rate - Users leaving quickly - - -
-
-
- -
-
-2.4%
-

vs last 30 days

-
-
-
-
- - - Click Through Rate - Ad performance - - -
-
-
- -
-
4.3%
-

avg. per campaign

-
-
-
-
-
+ + + Zero-Trust Mode + + OneShot enforces strict upload security invariants by design. + + + +

+ No standard external accounts are provisioned. Access is granted through + ephemeral single-use upload links only. +

+

+ Uploaded files are neutralized at rest as opaque extensionless blobs, with + original metadata preserved only in controlled metadata records. +

+

+ Token redemption is atomic and irreversible, preventing replay and ensuring + links expire exactly once. +

+
+
+ + )}
); } diff --git a/web/src/pages/__tests__/Admin.test.tsx b/web/src/pages/__tests__/Admin.test.tsx index 6b0bf06..19bc104 100644 --- a/web/src/pages/__tests__/Admin.test.tsx +++ b/web/src/pages/__tests__/Admin.test.tsx @@ -60,6 +60,7 @@ describe("Admin", () => { target_email: "alice@example.com", is_used: true, created_at: "2026-03-24T00:00:00Z", + expires_at: "2026-03-25T00:00:00Z", }, ]), { status: 200, headers: { "Content-Type": "application/json" } }, @@ -89,6 +90,8 @@ describe("Admin", () => { await waitFor(() => { expect(screen.getAllByText("alice@example.com")).toHaveLength(2); }); + expect(screen.getByText("Used")).toBeInTheDocument(); + expect(screen.getByText(/Expires:/)).toBeInTheDocument(); expect(screen.getByText("report.pdf")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Download" })).toBeInTheDocument(); }); diff --git a/web/src/pages/__tests__/Dashboard.test.tsx b/web/src/pages/__tests__/Dashboard.test.tsx new file mode 100644 index 0000000..fbb8ab3 --- /dev/null +++ b/web/src/pages/__tests__/Dashboard.test.tsx @@ -0,0 +1,46 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { Dashboard } from "../Dashboard"; + +vi.mock("../../auth", () => ({ + useAuth: () => ({ + userId: "u-admin", + }), + getOrMintToken: vi.fn().mockResolvedValue("dev-token"), +})); + +describe("Dashboard", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("renders OneShot metrics from admin stats endpoint", async () => { + const mockFetch = vi.spyOn(window, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + total_files: 3, + total_storage_bytes: 1536, + tokens_issued: 7, + tokens_used: 4, + active_tokens: 2, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + render(); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith("/api/admin/stats", { + headers: { Authorization: "Bearer dev-token" }, + }); + }); + + expect(await screen.findByText("Total Files Received")).toBeInTheDocument(); + expect(screen.getByText("3")).toBeInTheDocument(); + expect(screen.getByText("1.50 KB")).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + expect(screen.getByText("7")).toBeInTheDocument(); + expect(screen.getByText("Zero-Trust Mode")).toBeInTheDocument(); + }); +}); From 7b1b1ec2781c241e0ec95e3096cb1fe1ea1d9b65 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:23:05 +0000 Subject: [PATCH 2/3] fix: allow npm run gen to reuse existing openapi schema when uv is unavailable Co-authored-by: MinecraftFuns <25814618+MinecraftFuns@users.noreply.github.com> Agent-Logs-Url: https://github.com/BTreeMap/OneShot/sessions/d47e0923-07d8-4f34-b78c-abef458ea389 --- web/scripts/gen-openapi.mjs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/web/scripts/gen-openapi.mjs b/web/scripts/gen-openapi.mjs index a976f92..6e6d4fd 100644 --- a/web/scripts/gen-openapi.mjs +++ b/web/scripts/gen-openapi.mjs @@ -26,6 +26,7 @@ const outputTs = resolve(webDir, "src/api/openapi.ts"); // ── Step 1: Generate OpenAPI JSON from the backend ───────────────────────── console.log(`→ Using uv project: ${uvProject}`); console.log("→ Dumping OpenAPI schema from backend…"); +let openapiDumped = false; try { execFileSync( "uv", @@ -46,15 +47,25 @@ try { env: { ...process.env, PYTHONPATH: apiDir }, }, ); -} catch { - console.error("✗ Failed to dump OpenAPI schema from backend."); - process.exit(1); + openapiDumped = true; +} catch (error) { + if (existsSync(openapiJson)) { + const reason = error instanceof Error ? error.message : "unknown error"; + console.warn(`⚠ Failed to dump OpenAPI schema from backend (${reason}).`); + console.warn(`⚠ Reusing existing schema at ${openapiJson}`); + } else { + console.error("✗ Failed to dump OpenAPI schema from backend."); + process.exit(1); + } } if (!existsSync(openapiJson)) { console.error(`✗ Expected OpenAPI file not found: ${openapiJson}`); process.exit(1); } +if (openapiDumped) { + console.log(`✓ OpenAPI schema written to ${openapiJson}`); +} // ── Step 2: Generate TypeScript types ────────────────────────────────────── console.log("→ Generating TypeScript types from OpenAPI schema…"); From 531f7e6fab42dc523d471b6f3e3462bec734c7a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:28:03 +0000 Subject: [PATCH 3/3] fix: resolve npm run gen blocker and refactor shared byte formatting Co-authored-by: MinecraftFuns <25814618+MinecraftFuns@users.noreply.github.com> Agent-Logs-Url: https://github.com/BTreeMap/OneShot/sessions/d47e0923-07d8-4f34-b78c-abef458ea389 --- web/scripts/gen-openapi.mjs | 13 ++++++++++--- web/src/pages/Admin.tsx | 21 ++++++++------------- web/src/pages/Dashboard.tsx | 15 +-------------- web/src/utils/formatBytes.ts | 13 +++++++++++++ 4 files changed, 32 insertions(+), 30 deletions(-) create mode 100644 web/src/utils/formatBytes.ts diff --git a/web/scripts/gen-openapi.mjs b/web/scripts/gen-openapi.mjs index 6e6d4fd..4f7285f 100644 --- a/web/scripts/gen-openapi.mjs +++ b/web/scripts/gen-openapi.mjs @@ -49,9 +49,16 @@ try { ); openapiDumped = true; } catch (error) { - if (existsSync(openapiJson)) { - const reason = error instanceof Error ? error.message : "unknown error"; - console.warn(`⚠ Failed to dump OpenAPI schema from backend (${reason}).`); + const isMissingUv = + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT"; + const message = error instanceof Error ? error.message : "unknown error"; + const canFallback = existsSync(openapiJson) && isMissingUv; + + if (canFallback) { + console.warn(`⚠ Failed to dump OpenAPI schema from backend (${message}).`); console.warn(`⚠ Reusing existing schema at ${openapiJson}`); } else { console.error("✗ Failed to dump OpenAPI schema from backend."); diff --git a/web/src/pages/Admin.tsx b/web/src/pages/Admin.tsx index f061fb5..dee047e 100644 --- a/web/src/pages/Admin.tsx +++ b/web/src/pages/Admin.tsx @@ -15,22 +15,16 @@ import { Shield, Users } from "lucide-react"; import { Alert } from "../components/Alert"; import { Input } from "../components/Input"; import { Button } from "../components/Button"; +import { formatBytes } from "../utils/formatBytes"; -function formatBytes(sizeBytes: number): string { - if (sizeBytes >= 1024 * 1024) { - return `${(sizeBytes / (1024 * 1024)).toFixed(2)} MB`; - } - if (sizeBytes >= 1024) { - return `${(sizeBytes / 1024).toFixed(2)} KB`; - } - return `${sizeBytes} B`; -} - -function tokenStatus(tokenRow: OneShotTokenAuditItem): "Used" | "Expired" | "Active" { +function tokenStatus( + tokenRow: OneShotTokenAuditItem, + nowMs: number, +): "Used" | "Expired" | "Active" { if (tokenRow.is_used) { return "Used"; } - return new Date(tokenRow.expires_at) < new Date() ? "Expired" : "Active"; + return new Date(tokenRow.expires_at).getTime() < nowMs ? "Expired" : "Active"; } function statusClassName(status: "Used" | "Expired" | "Active"): string { @@ -70,6 +64,7 @@ export function Admin() { const [generatedLink, setGeneratedLink] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(null); + const nowMs = Date.now(); const fetchAuditData = useCallback(async () => { setIsAuditLoading(true); @@ -272,7 +267,7 @@ export function Admin() { {tokens.map((tokenRow) => { - const status = tokenStatus(tokenRow); + const status = tokenStatus(tokenRow, nowMs); return ( {tokenRow.id.slice(0, 8)}… diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index b2b3742..a14d5d7 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { FileText, HardDrive, Link, ShieldCheck } from "lucide-react"; import { getOrMintToken, useAuth } from "../auth"; import type { OneShotStatsResponse } from "../api/types"; +import { formatBytes } from "../utils/formatBytes"; import { Card, CardHeader, @@ -11,20 +12,6 @@ import { } from "../components/Card"; import { Alert } from "../components/Alert"; -function formatBytes(sizeBytes: number): string { - const units = ["B", "KB", "MB", "GB", "TB"]; - let value = sizeBytes; - let unitIndex = 0; - while (value >= 1024 && unitIndex < units.length - 1) { - value /= 1024; - unitIndex += 1; - } - if (unitIndex === 0) { - return `${value} ${units[unitIndex]}`; - } - return `${value.toFixed(2)} ${units[unitIndex]}`; -} - export function Dashboard() { const { userId } = useAuth(); const [stats, setStats] = useState(null); diff --git a/web/src/utils/formatBytes.ts b/web/src/utils/formatBytes.ts new file mode 100644 index 0000000..9e46abe --- /dev/null +++ b/web/src/utils/formatBytes.ts @@ -0,0 +1,13 @@ +export function formatBytes(sizeBytes: number): string { + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = sizeBytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + if (unitIndex === 0) { + return `${value} ${units[unitIndex]}`; + } + return `${value.toFixed(2)} ${units[unitIndex]}`; +}