From 94d86686a2e6807e3c0c64726ceae54375a12fb7 Mon Sep 17 00:00:00 2001 From: Nezumi-2711 Date: Wed, 19 Aug 2026 12:59:02 +0700 Subject: [PATCH] feat: implement ui for showing basic information --- src/components/ui/badge-variants.ts | 27 ++ src/components/ui/badge.tsx | 14 + src/components/ui/progress.tsx | 43 +++ src/components/ui/skeleton.tsx | 15 + src/features/status/api/status.api.ts | 35 +++ src/features/status/api/status.keys.ts | 5 + src/features/status/api/status.mock.ts | 96 +++++++ src/features/status/api/status.types.ts | 62 ++++ .../status/components/BucketStatsTable.tsx | 173 +++++++++++ .../status/components/DriveQuotaCard.tsx | 107 +++++++ .../status/components/GatewayConfigCard.tsx | 114 ++++++++ .../status/components/GatewayStatusCard.tsx | 109 +++++++ .../status/components/MultipartStatusCard.tsx | 61 ++++ src/features/status/hooks/useBucketStats.ts | 14 + src/features/status/hooks/useStatus.ts | 14 + src/lib/format.ts | 55 ++++ src/pages/Dashboard.tsx | 270 ++++++------------ 17 files changed, 1035 insertions(+), 179 deletions(-) create mode 100644 src/components/ui/badge-variants.ts create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/progress.tsx create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/features/status/api/status.api.ts create mode 100644 src/features/status/api/status.keys.ts create mode 100644 src/features/status/api/status.mock.ts create mode 100644 src/features/status/api/status.types.ts create mode 100644 src/features/status/components/BucketStatsTable.tsx create mode 100644 src/features/status/components/DriveQuotaCard.tsx create mode 100644 src/features/status/components/GatewayConfigCard.tsx create mode 100644 src/features/status/components/GatewayStatusCard.tsx create mode 100644 src/features/status/components/MultipartStatusCard.tsx create mode 100644 src/features/status/hooks/useBucketStats.ts create mode 100644 src/features/status/hooks/useStatus.ts create mode 100644 src/lib/format.ts diff --git a/src/components/ui/badge-variants.ts b/src/components/ui/badge-variants.ts new file mode 100644 index 0000000..2c5a5a6 --- /dev/null +++ b/src/components/ui/badge-variants.ts @@ -0,0 +1,27 @@ +import { cva } from "class-variance-authority" + +export const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow-sm hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + neutral: + "border-border bg-muted/60 text-muted-foreground", + success: + "border-emerald-500/20 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", + warning: + "border-amber-500/20 bg-amber-500/10 text-amber-600 dark:text-amber-400", + danger: + "border-red-500/20 bg-red-500/10 text-red-600 dark:text-red-400", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..38cbdb5 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,14 @@ +import * as React from "react" +import { type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" +import { badgeVariants } from "./badge-variants" + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +export function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..2feee02 --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +export interface ProgressProps extends React.HTMLAttributes { + value?: number | null + max?: number + indicatorClassName?: string +} + +const Progress = React.forwardRef( + ({ className, value, max = 100, indicatorClassName, ...props }, ref) => { + const percentage = + value != null && max > 0 + ? Math.min(100, Math.max(0, (value / max) * 100)) + : 0 + + return ( +
+
+
+ ) + } +) +Progress.displayName = "Progress" + +export { Progress } diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..e348f49 --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/src/features/status/api/status.api.ts b/src/features/status/api/status.api.ts new file mode 100644 index 0000000..68f47e2 --- /dev/null +++ b/src/features/status/api/status.api.ts @@ -0,0 +1,35 @@ +import { apiRequest, IS_MOCK_MODE } from "@/lib/api-client" +import { mockFetchBucketStats, mockFetchStatus } from "./status.mock" +import type { BucketStatsResponse, StatusResponse } from "./status.types" + +/** + * Fetch gateway overview and drive connection status. + */ +export async function fetchStatus(signal?: AbortSignal): Promise { + if (IS_MOCK_MODE) { + return mockFetchStatus() + } + + return apiRequest("/api/status", { + method: "GET", + signal, + }) +} + +/** + * Fetch bucket metrics and object statistics. + */ +export async function fetchBucketStats( + signal?: AbortSignal, + refresh?: boolean +): Promise { + if (IS_MOCK_MODE) { + return mockFetchBucketStats() + } + + const query = refresh ? "?refresh=1" : "" + return apiRequest(`/api/buckets${query}`, { + method: "GET", + signal, + }) +} diff --git a/src/features/status/api/status.keys.ts b/src/features/status/api/status.keys.ts new file mode 100644 index 0000000..b5ae066 --- /dev/null +++ b/src/features/status/api/status.keys.ts @@ -0,0 +1,5 @@ +export const statusKeys = { + all: ["status"] as const, + overview: () => [...statusKeys.all, "overview"] as const, + buckets: () => [...statusKeys.all, "buckets"] as const, +} diff --git a/src/features/status/api/status.mock.ts b/src/features/status/api/status.mock.ts new file mode 100644 index 0000000..684a5e3 --- /dev/null +++ b/src/features/status/api/status.mock.ts @@ -0,0 +1,96 @@ +import type { BucketStatsResponse, StatusResponse } from "./status.types" + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +export async function mockFetchStatus(): Promise { + await delay(400) + return { + gateway: { + status: "ok", + region: "auto", + multipartEnabled: true, + etagStyle: "md5", + docsEnabled: true, + buckets: ["documents", "media-storage", "backups", "public-assets"], + publicReadBuckets: ["public-assets"], + corsOrigins: ["http://localhost:5173"], + credentials: { + s3Keys: true, + googleOAuth: true, + dashboardPassword: true, + }, + }, + drive: { + connected: true, + account: { + email: "demo-storage@example.com", + displayName: "S3 Drive Bridge Admin", + }, + quota: { + limit: 15 * 1024 * 1024 * 1024, // 15 GB + usage: 6.4 * 1024 * 1024 * 1024, // 6.4 GB + usageInDrive: 6.2 * 1024 * 1024 * 1024, + usageInDriveTrash: 200 * 1024 * 1024, + free: 8.6 * 1024 * 1024 * 1024, + percentUsed: 42.7, + }, + error: null, + }, + checkedAt: new Date().toISOString(), + } +} + +export async function mockFetchBucketStats(): Promise { + await delay(600) + const buckets = [ + { + name: "documents", + objectCount: 142, + totalSize: 1024 * 1024 * 340, // 340 MB + lastModified: new Date(Date.now() - 1000 * 60 * 15).toISOString(), + truncated: false, + publicRead: false, + error: null, + }, + { + name: "media-storage", + objectCount: 890, + totalSize: 1024 * 1024 * 1024 * 5.2, // 5.2 GB + lastModified: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(), + truncated: false, + publicRead: false, + error: null, + }, + { + name: "backups", + objectCount: 24, + totalSize: 1024 * 1024 * 850, // 850 MB + lastModified: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), + truncated: false, + publicRead: false, + error: null, + }, + { + name: "public-assets", + objectCount: 65, + totalSize: 1024 * 1024 * 45, // 45 MB + lastModified: new Date(Date.now() - 1000 * 60 * 5).toISOString(), + truncated: false, + publicRead: true, + error: null, + }, + ] + + const totalCount = buckets.reduce((acc, b) => acc + b.objectCount, 0) + const totalSize = buckets.reduce((acc, b) => acc + b.totalSize, 0) + + return { + buckets, + totals: { + buckets: buckets.length, + objectCount: totalCount, + totalSize, + }, + cachedAt: new Date().toISOString(), + } +} diff --git a/src/features/status/api/status.types.ts b/src/features/status/api/status.types.ts new file mode 100644 index 0000000..094755e --- /dev/null +++ b/src/features/status/api/status.types.ts @@ -0,0 +1,62 @@ +export interface GatewayConfig { + status: "ok" | "degraded" + region: string + multipartEnabled: boolean + etagStyle: "md5" | "multipart" + docsEnabled: boolean + buckets: string[] + publicReadBuckets: string[] + corsOrigins: string[] + credentials: { + s3Keys: boolean + googleOAuth: boolean + dashboardPassword: boolean + } +} + +export interface DriveQuota { + limit: number | null + usage: number + usageInDrive: number + usageInDriveTrash: number + free: number | null + percentUsed: number | null +} + +export interface DriveAccount { + email: string | null + displayName: string | null +} + +export interface DriveStatus { + connected: boolean + account: DriveAccount | null + quota: DriveQuota | null + error: string | null +} + +export interface StatusResponse { + gateway: GatewayConfig + drive: DriveStatus + checkedAt: string +} + +export interface BucketStat { + name: string + objectCount: number + totalSize: number + lastModified: string | null + truncated: boolean + publicRead: boolean + error: string | null +} + +export interface BucketStatsResponse { + buckets: BucketStat[] + totals: { + buckets: number + objectCount: number + totalSize: number + } + cachedAt: string +} diff --git a/src/features/status/components/BucketStatsTable.tsx b/src/features/status/components/BucketStatsTable.tsx new file mode 100644 index 0000000..b1bdc79 --- /dev/null +++ b/src/features/status/components/BucketStatsTable.tsx @@ -0,0 +1,173 @@ +import { useState } from "react" +import { + FolderTree, + RotateCw, + Sparkles, + AlertCircle, + Globe, + Lock, + Files, +} from "lucide-react" +import { useQueryClient } from "@tanstack/react-query" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { Badge } from "@/components/ui/badge" +import { formatBytes, formatRelativeTime } from "@/lib/format" +import { fetchBucketStats } from "../api/status.api" +import { statusKeys } from "../api/status.keys" +import type { BucketStatsResponse } from "../api/status.types" + +interface BucketStatsTableProps { + stats?: BucketStatsResponse + isLoading: boolean + error?: Error | null +} + +export function BucketStatsTable({ stats, isLoading, error }: BucketStatsTableProps) { + const queryClient = useQueryClient() + const [isRefreshing, setIsRefreshing] = useState(false) + + const handleForceRefresh = async () => { + try { + setIsRefreshing(true) + const data = await fetchBucketStats(undefined, true) + queryClient.setQueryData(statusKeys.buckets(), data) + } finally { + setIsRefreshing(false) + } + } + + return ( + +
+ +
+
+
+ +
+ Bucket Explorer +
+
+ {stats && ( + + {stats.totals.buckets} {stats.totals.buckets === 1 ? "bucket" : "buckets"} · {stats.totals.objectCount} objects · {formatBytes(stats.totals.totalSize)} + + )} + +
+
+ + Allowlisted S3 buckets mapped to Google Drive folders with size and object counts. + +
+ + + {isLoading ? ( +
+ + + +
+ ) : error || !stats ? ( +
+ +
Failed to load bucket stats
+

{error?.message || "Unknown error occurred"}

+
+ ) : stats.buckets.length === 0 ? ( +
+ +
No buckets configured
+

+ Set ALLOWED_BUCKETS in your Worker configuration to expose buckets. +

+
+ ) : ( +
+ + + + + + + + + + + + {stats.buckets.map((b) => ( + + + + + + + + ))} + +
Bucket NameAccessObjectsTotal SizeLast Modified
+ {b.name} + + {b.publicRead ? ( + + + Public Read + + ) : ( + + + Private + + )} + + {b.error ? ( + Error + ) : ( + {b.truncated ? `≥ ${b.objectCount}` : b.objectCount} + )} + + {b.error ? "—" : formatBytes(b.totalSize)} + + {b.error ? ( + + Failed to scan + + ) : ( + formatRelativeTime(b.lastModified) + )} +
+
+ )} + + {/* S3 actions info bar */} +
+ Supported APIs: + {["s3:ListObjectsV2", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:CreateMultipartUpload"].map( + (api) => ( + + + {api} + + ) + )} +
+
+
+
+ ) +} diff --git a/src/features/status/components/DriveQuotaCard.tsx b/src/features/status/components/DriveQuotaCard.tsx new file mode 100644 index 0000000..c0e7b65 --- /dev/null +++ b/src/features/status/components/DriveQuotaCard.tsx @@ -0,0 +1,107 @@ +import { HardDrive, AlertCircle, User } from "lucide-react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Progress } from "@/components/ui/progress" +import { Skeleton } from "@/components/ui/skeleton" +import { formatBytes } from "@/lib/format" +import type { DriveStatus } from "../api/status.types" + +interface DriveQuotaCardProps { + drive?: DriveStatus + isLoading: boolean + error?: Error | null +} + +export function DriveQuotaCard({ drive, isLoading, error }: DriveQuotaCardProps) { + if (isLoading) { + return ( + + + + + + + + + + + + ) + } + + if (error || !drive || !drive.connected) { + return ( + +
+ + + S3 Storage Backend + +
+ +
+
+ +
Google Drive
+

+ + {drive?.error || "Drive connection error"} +

+
+ + ) + } + + const quota = drive.quota + const isUnlimited = quota?.limit == null + const percentUsed = quota?.percentUsed ?? 0 + + return ( + +
+ + + S3 Storage Backend + +
+ +
+
+ +
+
+ {formatBytes(quota?.usage)} +
+
+ {isUnlimited ? "Unlimited" : `/ ${formatBytes(quota?.limit)}`} +
+
+ + {!isUnlimited && ( +
+ 90 + ? "bg-red-500" + : percentUsed > 75 + ? "bg-amber-500" + : "bg-gradient-to-r from-blue-600 to-cyan-500" + } + /> +
+ {percentUsed}% used + {formatBytes(quota?.free)} free +
+
+ )} + +
+ + + {drive.account?.email || drive.account?.displayName || "Google Drive Connected"} + +
+
+ + ) +} diff --git a/src/features/status/components/GatewayConfigCard.tsx b/src/features/status/components/GatewayConfigCard.tsx new file mode 100644 index 0000000..d4439ea --- /dev/null +++ b/src/features/status/components/GatewayConfigCard.tsx @@ -0,0 +1,114 @@ +import { ShieldCheck, ArrowUpRight, CheckCircle2, XCircle } from "lucide-react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { Badge } from "@/components/ui/badge" +import type { GatewayConfig } from "../api/status.types" + +interface GatewayConfigCardProps { + gateway?: GatewayConfig + isLoading: boolean +} + +export function GatewayConfigCard({ gateway, isLoading }: GatewayConfigCardProps) { + if (isLoading) { + return ( + +
+ + + + + + + + +
+
+ ) + } + + const creds = gateway?.credentials + const hasS3Keys = creds?.s3Keys ?? false + const hasOAuth = creds?.googleOAuth ?? false + const hasPassword = creds?.dashboardPassword ?? false + + return ( + +
+ +
+
+ +
+ Security & Protocol +
+ + Authentication and credential verification + +
+ + {/* AWS SigV4 */} +
+
+ AWS Signature Version 4 + + {hasS3Keys ? "Configured" : "Missing Keys"} + +
+

+ {hasS3Keys ? ( + + ) : ( + + )} + HMAC-SHA256 request authentication +

+
+ + {/* Google OAuth */} +
+
+ Google OAuth 2.0 + + {hasOAuth ? "Auto-Refresh" : "Not Configured"} + +
+

+ {hasOAuth ? ( + + ) : ( + + )} + Token rotation via Cloudflare KV +

+
+ + {/* Dashboard Auth */} +
+
+ Dashboard Access + + {hasPassword ? "Password Protected" : "No Password"} + +
+

+ Bearer token session with rate limiting & lockout +

+
+
+
+ + +
+ ) +} diff --git a/src/features/status/components/GatewayStatusCard.tsx b/src/features/status/components/GatewayStatusCard.tsx new file mode 100644 index 0000000..6d60600 --- /dev/null +++ b/src/features/status/components/GatewayStatusCard.tsx @@ -0,0 +1,109 @@ +import { Activity, AlertTriangle, Server } from "lucide-react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { Badge } from "@/components/ui/badge" +import type { StatusResponse } from "../api/status.types" + +interface GatewayStatusCardProps { + status?: StatusResponse + isLoading: boolean + error?: Error | null +} + +export function GatewayStatusCard({ status, isLoading, error }: GatewayStatusCardProps) { + if (isLoading) { + return ( + + + + + + + + + + + ) + } + + if (error || !status) { + return ( + +
+ + + Gateway Engine + +
+ +
+
+ +
Unavailable
+

+ {error?.message || "Failed to query gateway status"} +

+
+ + ) + } + + const isDegraded = status.gateway.status === "degraded" || !status.drive.connected + + return ( + +
+ + + Gateway Engine + +
+ +
+
+ +
+ + {isDegraded ? "Degraded" : "Online & Healthy"} +
+
+
+ {isDegraded ? ( + + ) : ( + + )} + + {isDegraded + ? status.drive.error || "Drive connection degraded" + : `Edge routing · Region: ${status.gateway.region}`} + +
+ {status.gateway.docsEnabled && ( + + Docs + + )} +
+
+ + ) +} diff --git a/src/features/status/components/MultipartStatusCard.tsx b/src/features/status/components/MultipartStatusCard.tsx new file mode 100644 index 0000000..d609e47 --- /dev/null +++ b/src/features/status/components/MultipartStatusCard.tsx @@ -0,0 +1,61 @@ +import { Cpu, Layers } from "lucide-react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { Badge } from "@/components/ui/badge" +import type { GatewayConfig } from "../api/status.types" + +interface MultipartStatusCardProps { + gateway?: GatewayConfig + isLoading: boolean + error?: Error | null +} + +export function MultipartStatusCard({ gateway, isLoading }: MultipartStatusCardProps) { + if (isLoading) { + return ( + + + + + + + + + + + ) + } + + const isEnabled = gateway?.multipartEnabled ?? false + const etagStyle = gateway?.etagStyle ?? "md5" + + return ( + +
+ + + Multipart Uploads + +
+ +
+
+ +
+ {isEnabled ? "Enabled" : "Disabled"} + + {etagStyle.toUpperCase()} ETag + +
+

+ + + {isEnabled + ? "Durable Objects resumable state" + : "ALLOW_MULTIPART disabled"} + +

+
+ + ) +} diff --git a/src/features/status/hooks/useBucketStats.ts b/src/features/status/hooks/useBucketStats.ts new file mode 100644 index 0000000..8f52e31 --- /dev/null +++ b/src/features/status/hooks/useBucketStats.ts @@ -0,0 +1,14 @@ +import { queryOptions, useQuery } from "@tanstack/react-query" +import { fetchBucketStats } from "../api/status.api" +import { statusKeys } from "../api/status.keys" + +export const bucketStatsQueryOptions = () => + queryOptions({ + queryKey: statusKeys.buckets(), + queryFn: ({ signal }) => fetchBucketStats(signal), + staleTime: 5 * 60_000, // 5 min + }) + +export function useBucketStats() { + return useQuery(bucketStatsQueryOptions()) +} diff --git a/src/features/status/hooks/useStatus.ts b/src/features/status/hooks/useStatus.ts new file mode 100644 index 0000000..61da095 --- /dev/null +++ b/src/features/status/hooks/useStatus.ts @@ -0,0 +1,14 @@ +import { queryOptions, useQuery } from "@tanstack/react-query" +import { fetchStatus } from "../api/status.api" +import { statusKeys } from "../api/status.keys" + +export const statusQueryOptions = () => + queryOptions({ + queryKey: statusKeys.overview(), + queryFn: ({ signal }) => fetchStatus(signal), + staleTime: 60_000, // 60s + }) + +export function useStatus() { + return useQuery(statusQueryOptions()) +} diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..f293c3e --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,55 @@ +/** + * Format a number of bytes into a human-readable string (e.g., "8.7 GB", "512 KB"). + */ +export function formatBytes(bytes: number | null | undefined, decimals = 1): string { + if (bytes === null || bytes === undefined || Number.isNaN(bytes)) { + return "—" + } + if (bytes === 0) return "0 B" + + const k = 1024 + const dm = decimals < 0 ? 0 : decimals + const sizes = ["B", "KB", "MB", "GB", "TB", "PB"] + + const i = Math.floor(Math.log(bytes) / Math.log(k)) + const index = Math.min(i, sizes.length - 1) + const value = bytes / Math.pow(k, index) + + return `${new Intl.NumberFormat("en-US", { + minimumFractionDigits: 0, + maximumFractionDigits: dm, + }).format(value)} ${sizes[index]}` +} + +/** + * Format an ISO timestamp into a relative time string (e.g., "5 minutes ago", "just now"). + */ +export function formatRelativeTime(isoString: string | null | undefined): string { + if (!isoString) return "Never" + const date = new Date(isoString) + if (Number.isNaN(date.getTime())) return "Unknown" + + const now = Date.now() + const diffInSeconds = Math.round((date.getTime() - now) / 1000) + + if (Math.abs(diffInSeconds) < 10) { + return "just now" + } + + const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }) + + const absSeconds = Math.abs(diffInSeconds) + if (absSeconds < 60) { + return rtf.format(diffInSeconds, "second") + } + const diffInMinutes = Math.round(diffInSeconds / 60) + if (Math.abs(diffInMinutes) < 60) { + return rtf.format(diffInMinutes, "minute") + } + const diffInHours = Math.round(diffInMinutes / 60) + if (Math.abs(diffInHours) < 24) { + return rtf.format(diffInHours, "hour") + } + const diffInDays = Math.round(diffInHours / 24) + return rtf.format(diffInDays, "day") +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index b57f92e..9618334 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,40 +1,66 @@ +import { useState } from "react" import { useNavigate } from "react-router" +import { useQueryClient } from "@tanstack/react-query" import { Database, LogOut, ShieldCheck, - HardDrive, - Layers, - Server, - Activity, - FolderTree, - UploadCloud, - CheckCircle2, - Lock, - ArrowUpRight, + RotateCw, Radio, - Cpu, - Sparkles, + Lock, } from "lucide-react" import { useAuth } from "@/features/auth/hooks/useAuth" +import { useStatus } from "@/features/status/hooks/useStatus" +import { useBucketStats } from "@/features/status/hooks/useBucketStats" +import { statusKeys } from "@/features/status/api/status.keys" +import { GatewayStatusCard } from "@/features/status/components/GatewayStatusCard" +import { DriveQuotaCard } from "@/features/status/components/DriveQuotaCard" +import { MultipartStatusCard } from "@/features/status/components/MultipartStatusCard" +import { BucketStatsTable } from "@/features/status/components/BucketStatsTable" +import { GatewayConfigCard } from "@/features/status/components/GatewayConfigCard" import { Button } from "@/components/ui/button" -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" +import { formatRelativeTime } from "@/lib/format" export default function Dashboard() { const { signOut } = useAuth() const navigate = useNavigate() + const queryClient = useQueryClient() + const [isRefreshing, setIsRefreshing] = useState(false) + + const { + data: statusData, + isLoading: isStatusLoading, + error: statusError, + } = useStatus() + + const { + data: bucketStatsData, + isLoading: isBucketsLoading, + error: bucketsError, + } = useBucketStats() const handleSignOut = async () => { await signOut() navigate("/sign-in", { replace: true }) } + const handleRefreshAll = async () => { + try { + setIsRefreshing(true) + await queryClient.invalidateQueries({ queryKey: statusKeys.all }) + } finally { + setIsRefreshing(false) + } + } + + const checkedAtTime = statusData?.checkedAt + ? new Date(statusData.checkedAt).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + : null + return (
{/* Background ambient colorful glow effects */} @@ -99,7 +125,7 @@ export default function Dashboard() { {/* Welcome Banner */}
- +
@@ -110,11 +136,11 @@ export default function Dashboard() { Gateway Overview & Control

- Connected to Google Drive API with AWS S3 signature verification and multipart upload support via Cloudflare Workers. + Live metrics from Cloudflare Workers connected to Google Drive API with AWS S3 signature verification.

-
+
@@ -122,173 +148,59 @@ export default function Dashboard() {
Global CDN Active
+ +
+ {checkedAtTime && ( + + Updated {formatRelativeTime(statusData?.checkedAt)} ({checkedAtTime}) + + )} + +
{/* Status / Quick Overview cards */}
- {/* Card 1 */} - -
- - - Gateway Engine - -
- -
-
- -
- - Online & Healthy -
-

- - Cloudflare Worker edge routing -

-
- - - {/* Card 2 */} - -
- - - S3 Storage Backend - -
- -
-
- -
Google Drive
-

- - Root directory auto-mapped -

-
- - - {/* Card 3 */} - -
- - - Multipart Uploads - -
- -
-
- -
Durable Objects
-

- - Resumable state coordination -

-
- + + +
- {/* Feature Capabilities & Explorer Card */} + {/* Feature Capabilities & Specifications */}
- {/* Main Explorer placeholder */} - - -
-
-
- -
- Bucket Explorer -
- - Upcoming Module - -
- - Full-featured visual browser for files, folders, and storage metadata. - -
- - -
-
- -
-

Storage Bridge Ready

-

- Sign-in verification complete. Ready for bucket exploration, object uploads, downloads, and presigned URL operations. -

-
- - {/* Supported S3 Actions badges */} -
- Supported APIs: - {["s3:ListObjectsV2", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:CreateMultipartUpload"].map((api) => ( - - - {api} - - ))} -
-
-
- - {/* Quick Specifications Info card */} - -
- -
-
- -
- Security & Protocol -
- - Compliance and encryption standards - -
- -
-
- AWS Signature Version 4 - Enabled -
-

- Cryptographic HMAC-SHA256 request authentication -

-
- -
-
- Google OAuth 2.0 - Auto-Refresh -
-

- Bearer token rotation via Cloudflare KV store -

-
-
-
- - -
+ +