feat: implement ui for showing basic information

This commit is contained in:
2026-08-19 12:59:02 +07:00
parent fb91d32b50
commit 94d86686a2
17 changed files with 1035 additions and 179 deletions
+27
View File
@@ -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",
},
}
)
+14
View File
@@ -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<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
+43
View File
@@ -0,0 +1,43 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value?: number | null
max?: number
indicatorClassName?: string
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value, max = 100, indicatorClassName, ...props }, ref) => {
const percentage =
value != null && max > 0
? Math.min(100, Math.max(0, (value / max) * 100))
: 0
return (
<div
ref={ref}
role="progressbar"
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={value ?? undefined}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-secondary/80",
className
)}
{...props}
>
<div
className={cn(
"h-full w-full flex-1 bg-gradient-to-r from-blue-600 to-cyan-500 transition-all duration-300",
indicatorClassName
)}
style={{ transform: `translateX(-${100 - percentage}%)` }}
/>
</div>
)
}
)
Progress.displayName = "Progress"
export { Progress }
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted/60", className)}
{...props}
/>
)
}
export { Skeleton }
+35
View File
@@ -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<StatusResponse> {
if (IS_MOCK_MODE) {
return mockFetchStatus()
}
return apiRequest<StatusResponse>("/api/status", {
method: "GET",
signal,
})
}
/**
* Fetch bucket metrics and object statistics.
*/
export async function fetchBucketStats(
signal?: AbortSignal,
refresh?: boolean
): Promise<BucketStatsResponse> {
if (IS_MOCK_MODE) {
return mockFetchBucketStats()
}
const query = refresh ? "?refresh=1" : ""
return apiRequest<BucketStatsResponse>(`/api/buckets${query}`, {
method: "GET",
signal,
})
}
+5
View File
@@ -0,0 +1,5 @@
export const statusKeys = {
all: ["status"] as const,
overview: () => [...statusKeys.all, "overview"] as const,
buckets: () => [...statusKeys.all, "buckets"] as const,
}
+96
View File
@@ -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<StatusResponse> {
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<BucketStatsResponse> {
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(),
}
}
+62
View File
@@ -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
}
@@ -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 (
<Card className="lg:col-span-2 border-border/80 bg-card/85 backdrop-blur-sm shadow-md flex flex-col justify-between">
<div>
<CardHeader>
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-blue-500/10 text-blue-600 dark:text-blue-400">
<FolderTree className="h-4 w-4" />
</div>
<CardTitle className="text-base font-bold">Bucket Explorer</CardTitle>
</div>
<div className="flex items-center gap-2">
{stats && (
<span className="text-xs text-muted-foreground hidden sm:inline">
{stats.totals.buckets} {stats.totals.buckets === 1 ? "bucket" : "buckets"} · {stats.totals.objectCount} objects · {formatBytes(stats.totals.totalSize)}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={handleForceRefresh}
disabled={isLoading || isRefreshing}
className="h-7 text-xs px-2.5 rounded-lg gap-1"
title="Bypass KV cache and recalculate bucket statistics"
>
<RotateCw className={`h-3 w-3 ${isRefreshing ? "animate-spin" : ""}`} />
<span>{isRefreshing ? "Scanning..." : "Recalculate"}</span>
</Button>
</div>
</div>
<CardDescription className="text-xs text-muted-foreground">
Allowlisted S3 buckets mapped to Google Drive folders with size and object counts.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
</div>
) : error || !stats ? (
<div className="p-6 rounded-xl border border-destructive/30 bg-destructive/5 text-center space-y-2">
<AlertCircle className="h-6 w-6 text-destructive mx-auto" />
<div className="text-sm font-semibold text-destructive">Failed to load bucket stats</div>
<p className="text-xs text-muted-foreground">{error?.message || "Unknown error occurred"}</p>
</div>
) : stats.buckets.length === 0 ? (
<div className="h-36 rounded-xl border border-dashed border-border flex flex-col items-center justify-center p-6 text-center bg-muted/20">
<Files className="h-6 w-6 text-muted-foreground mb-2" />
<div className="text-sm font-medium text-foreground">No buckets configured</div>
<p className="text-xs text-muted-foreground mt-0.5">
Set ALLOWED_BUCKETS in your Worker configuration to expose buckets.
</p>
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-border/70">
<table className="w-full text-xs text-left">
<thead className="bg-muted/50 text-muted-foreground font-semibold border-b border-border/70">
<tr>
<th className="py-2.5 px-3">Bucket Name</th>
<th className="py-2.5 px-3">Access</th>
<th className="py-2.5 px-3 text-right">Objects</th>
<th className="py-2.5 px-3 text-right">Total Size</th>
<th className="py-2.5 px-3 text-right">Last Modified</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{stats.buckets.map((b) => (
<tr key={b.name} className="hover:bg-muted/30 transition-colors">
<td className="py-2.5 px-3 font-medium text-foreground flex items-center gap-1.5 font-mono">
<span>{b.name}</span>
</td>
<td className="py-2.5 px-3">
{b.publicRead ? (
<Badge variant="warning" className="text-[10px] gap-1 px-1.5 py-0">
<Globe className="h-2.5 w-2.5" />
Public Read
</Badge>
) : (
<Badge variant="neutral" className="text-[10px] gap-1 px-1.5 py-0">
<Lock className="h-2.5 w-2.5" />
Private
</Badge>
)}
</td>
<td className="py-2.5 px-3 text-right font-mono text-foreground">
{b.error ? (
<span className="text-destructive">Error</span>
) : (
<span>{b.truncated ? `${b.objectCount}` : b.objectCount}</span>
)}
</td>
<td className="py-2.5 px-3 text-right font-mono text-foreground">
{b.error ? "—" : formatBytes(b.totalSize)}
</td>
<td className="py-2.5 px-3 text-right text-muted-foreground">
{b.error ? (
<span className="text-destructive text-[11px]" title={b.error}>
Failed to scan
</span>
) : (
formatRelativeTime(b.lastModified)
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* S3 actions info bar */}
<div className="flex flex-wrap items-center gap-2 pt-1">
<span className="text-[11px] font-medium text-muted-foreground mr-1">Supported APIs:</span>
{["s3:ListObjectsV2", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:CreateMultipartUpload"].map(
(api) => (
<span
key={api}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-[11px] font-mono font-medium bg-secondary/80 text-secondary-foreground border border-border/60"
>
<Sparkles className="h-2.5 w-2.5 text-blue-500" />
{api}
</span>
)
)}
</div>
</CardContent>
</div>
</Card>
)
}
@@ -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 (
<Card className="relative overflow-hidden border-border/80 bg-card/90 backdrop-blur-sm shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-8 w-8 rounded-lg" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-2 w-full" />
<Skeleton className="h-3 w-40" />
</CardContent>
</Card>
)
}
if (error || !drive || !drive.connected) {
return (
<Card className="relative overflow-hidden border-border/80 bg-card/90 backdrop-blur-sm shadow-md">
<div className="absolute top-0 left-0 right-0 h-1 bg-amber-500" />
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
S3 Storage Backend
</CardTitle>
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
<AlertCircle className="h-4 w-4" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-foreground">Google Drive</div>
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1.5 flex items-center gap-1">
<AlertCircle className="h-3 w-3 shrink-0" />
<span className="truncate">{drive?.error || "Drive connection error"}</span>
</p>
</CardContent>
</Card>
)
}
const quota = drive.quota
const isUnlimited = quota?.limit == null
const percentUsed = quota?.percentUsed ?? 0
return (
<Card className="relative overflow-hidden border-border/80 bg-card/90 backdrop-blur-sm shadow-md hover:shadow-lg transition-all group">
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 to-indigo-500" />
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
S3 Storage Backend
</CardTitle>
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500 group-hover:scale-110 transition-transform">
<HardDrive className="h-4 w-4" />
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-baseline justify-between gap-2">
<div className="text-2xl font-bold text-foreground">
{formatBytes(quota?.usage)}
</div>
<div className="text-xs text-muted-foreground font-medium">
{isUnlimited ? "Unlimited" : `/ ${formatBytes(quota?.limit)}`}
</div>
</div>
{!isUnlimited && (
<div className="space-y-1">
<Progress
value={percentUsed}
indicatorClassName={
percentUsed > 90
? "bg-red-500"
: percentUsed > 75
? "bg-amber-500"
: "bg-gradient-to-r from-blue-600 to-cyan-500"
}
/>
<div className="flex justify-between text-[11px] text-muted-foreground">
<span>{percentUsed}% used</span>
<span>{formatBytes(quota?.free)} free</span>
</div>
</div>
)}
<div className="text-xs text-muted-foreground flex items-center gap-1 pt-0.5 truncate">
<User className="h-3 w-3 text-blue-500 shrink-0" />
<span className="truncate">
{drive.account?.email || drive.account?.displayName || "Google Drive Connected"}
</span>
</div>
</CardContent>
</Card>
)
}
@@ -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 (
<Card className="border-border/80 bg-card/85 backdrop-blur-sm shadow-md flex flex-col justify-between">
<div>
<CardHeader>
<Skeleton className="h-5 w-40" />
<Skeleton className="h-3 w-56 mt-1" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-16 w-full rounded-xl" />
<Skeleton className="h-16 w-full rounded-xl" />
</CardContent>
</div>
</Card>
)
}
const creds = gateway?.credentials
const hasS3Keys = creds?.s3Keys ?? false
const hasOAuth = creds?.googleOAuth ?? false
const hasPassword = creds?.dashboardPassword ?? false
return (
<Card className="border-border/80 bg-card/85 backdrop-blur-sm shadow-md flex flex-col justify-between">
<div>
<CardHeader>
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-indigo-500/10 text-indigo-600 dark:text-indigo-400">
<ShieldCheck className="h-4 w-4" />
</div>
<CardTitle className="text-base font-bold">Security &amp; Protocol</CardTitle>
</div>
<CardDescription className="text-xs">
Authentication and credential verification
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* AWS SigV4 */}
<div className="p-3 rounded-xl bg-secondary/40 border border-border/60 space-y-1">
<div className="text-xs font-semibold text-foreground flex items-center justify-between">
<span>AWS Signature Version 4</span>
<Badge variant={hasS3Keys ? "success" : "danger"} className="text-[10px] px-1.5 py-0">
{hasS3Keys ? "Configured" : "Missing Keys"}
</Badge>
</div>
<p className="text-[11px] text-muted-foreground flex items-center gap-1">
{hasS3Keys ? (
<CheckCircle2 className="h-3 w-3 text-emerald-500 shrink-0" />
) : (
<XCircle className="h-3 w-3 text-red-500 shrink-0" />
)}
<span>HMAC-SHA256 request authentication</span>
</p>
</div>
{/* Google OAuth */}
<div className="p-3 rounded-xl bg-secondary/40 border border-border/60 space-y-1">
<div className="text-xs font-semibold text-foreground flex items-center justify-between">
<span>Google OAuth 2.0</span>
<Badge variant={hasOAuth ? "success" : "danger"} className="text-[10px] px-1.5 py-0">
{hasOAuth ? "Auto-Refresh" : "Not Configured"}
</Badge>
</div>
<p className="text-[11px] text-muted-foreground flex items-center gap-1">
{hasOAuth ? (
<CheckCircle2 className="h-3 w-3 text-blue-500 shrink-0" />
) : (
<XCircle className="h-3 w-3 text-red-500 shrink-0" />
)}
<span>Token rotation via Cloudflare KV</span>
</p>
</div>
{/* Dashboard Auth */}
<div className="p-3 rounded-xl bg-secondary/40 border border-border/60 space-y-1">
<div className="text-xs font-semibold text-foreground flex items-center justify-between">
<span>Dashboard Access</span>
<Badge variant={hasPassword ? "success" : "warning"} className="text-[10px] px-1.5 py-0">
{hasPassword ? "Password Protected" : "No Password"}
</Badge>
</div>
<p className="text-[11px] text-muted-foreground">
Bearer token session with rate limiting &amp; lockout
</p>
</div>
</CardContent>
</div>
<div className="p-4 border-t border-border/60 bg-muted/10">
<a
href="https://developers.google.com/drive"
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-between w-full text-xs font-semibold text-blue-600 dark:text-blue-400 hover:underline"
>
<span>Google Drive API Reference</span>
<ArrowUpRight className="h-3.5 w-3.5" />
</a>
</div>
</Card>
)
}
@@ -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 (
<Card className="relative overflow-hidden border-border/80 bg-card/90 backdrop-blur-sm shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-8 w-8 rounded-lg" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-3 w-48" />
</CardContent>
</Card>
)
}
if (error || !status) {
return (
<Card className="relative overflow-hidden border-destructive/40 bg-card/90 backdrop-blur-sm shadow-md">
<div className="absolute top-0 left-0 right-0 h-1 bg-destructive" />
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Gateway Engine
</CardTitle>
<div className="p-2 rounded-lg bg-destructive/10 text-destructive">
<AlertTriangle className="h-4 w-4" />
</div>
</CardHeader>
<CardContent>
<div className="text-xl font-bold text-destructive">Unavailable</div>
<p className="text-xs text-muted-foreground mt-1.5 truncate">
{error?.message || "Failed to query gateway status"}
</p>
</CardContent>
</Card>
)
}
const isDegraded = status.gateway.status === "degraded" || !status.drive.connected
return (
<Card className="relative overflow-hidden border-border/80 bg-card/90 backdrop-blur-sm shadow-md hover:shadow-lg transition-all group">
<div
className={`absolute top-0 left-0 right-0 h-1 ${
isDegraded
? "bg-gradient-to-r from-amber-500 to-red-500"
: "bg-gradient-to-r from-emerald-500 to-teal-400"
}`}
/>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Gateway Engine
</CardTitle>
<div
className={`p-2 rounded-lg ${
isDegraded
? "bg-amber-500/10 text-amber-500"
: "bg-emerald-500/10 text-emerald-500"
} group-hover:scale-110 transition-transform`}
>
<Server className="h-4 w-4" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold flex items-center gap-2 text-foreground">
<span
className={`h-2.5 w-2.5 rounded-full ${
isDegraded
? "bg-amber-500 shadow-xs shadow-amber-500/50"
: "bg-emerald-500 shadow-xs shadow-emerald-500/50"
}`}
/>
{isDegraded ? "Degraded" : "Online & Healthy"}
</div>
<div className="text-xs text-muted-foreground mt-1.5 flex items-center justify-between gap-1">
<div className="flex items-center gap-1 min-w-0">
{isDegraded ? (
<AlertTriangle className="h-3 w-3 text-amber-500 shrink-0" />
) : (
<Activity className="h-3 w-3 text-emerald-500 shrink-0" />
)}
<span className="truncate">
{isDegraded
? status.drive.error || "Drive connection degraded"
: `Edge routing · Region: ${status.gateway.region}`}
</span>
</div>
{status.gateway.docsEnabled && (
<Badge variant="neutral" className="text-[10px] px-1.5 py-0 shrink-0">
Docs
</Badge>
)}
</div>
</CardContent>
</Card>
)
}
@@ -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 (
<Card className="relative overflow-hidden border-border/80 bg-card/90 backdrop-blur-sm shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-8 w-8 rounded-lg" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-3 w-44" />
</CardContent>
</Card>
)
}
const isEnabled = gateway?.multipartEnabled ?? false
const etagStyle = gateway?.etagStyle ?? "md5"
return (
<Card className="relative overflow-hidden border-border/80 bg-card/90 backdrop-blur-sm shadow-md hover:shadow-lg transition-all group">
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-cyan-500 to-blue-500" />
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Multipart Uploads
</CardTitle>
<div className="p-2 rounded-lg bg-cyan-500/10 text-cyan-500 group-hover:scale-110 transition-transform">
<Layers className="h-4 w-4" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-foreground flex items-center justify-between">
<span>{isEnabled ? "Enabled" : "Disabled"}</span>
<Badge variant={isEnabled ? "success" : "neutral"} className="text-[10px]">
{etagStyle.toUpperCase()} ETag
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1.5 flex items-center gap-1">
<Cpu className="h-3 w-3 text-cyan-500 shrink-0" />
<span>
{isEnabled
? "Durable Objects resumable state"
: "ALLOW_MULTIPART disabled"}
</span>
</p>
</CardContent>
</Card>
)
}
@@ -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())
}
+14
View File
@@ -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())
}
+55
View File
@@ -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")
}
+91 -179
View File
@@ -1,40 +1,66 @@
import { useState } from "react"
import { useNavigate } from "react-router" import { useNavigate } from "react-router"
import { useQueryClient } from "@tanstack/react-query"
import { import {
Database, Database,
LogOut, LogOut,
ShieldCheck, ShieldCheck,
HardDrive, RotateCw,
Layers,
Server,
Activity,
FolderTree,
UploadCloud,
CheckCircle2,
Lock,
ArrowUpRight,
Radio, Radio,
Cpu, Lock,
Sparkles,
} from "lucide-react" } from "lucide-react"
import { useAuth } from "@/features/auth/hooks/useAuth" 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 { Button } from "@/components/ui/button"
import { import { formatRelativeTime } from "@/lib/format"
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
export default function Dashboard() { export default function Dashboard() {
const { signOut } = useAuth() const { signOut } = useAuth()
const navigate = useNavigate() 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 () => { const handleSignOut = async () => {
await signOut() await signOut()
navigate("/sign-in", { replace: true }) 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 ( return (
<div className="relative min-h-screen bg-background bg-grid-pattern flex flex-col overflow-x-hidden"> <div className="relative min-h-screen bg-background bg-grid-pattern flex flex-col overflow-x-hidden">
{/* Background ambient colorful glow effects */} {/* Background ambient colorful glow effects */}
@@ -99,7 +125,7 @@ export default function Dashboard() {
{/* Welcome Banner */} {/* Welcome Banner */}
<div className="relative overflow-hidden rounded-2xl border border-blue-500/20 bg-gradient-to-br from-card via-card to-blue-500/5 p-6 sm:p-7 shadow-lg shadow-blue-950/5"> <div className="relative overflow-hidden rounded-2xl border border-blue-500/20 bg-gradient-to-br from-card via-card to-blue-500/5 p-6 sm:p-7 shadow-lg shadow-blue-950/5">
<div className="absolute top-0 right-0 h-40 w-40 bg-gradient-to-bl from-cyan-500/10 via-blue-500/5 to-transparent rounded-bl-full pointer-events-none" /> <div className="absolute top-0 right-0 h-40 w-40 bg-gradient-to-bl from-cyan-500/10 via-blue-500/5 to-transparent rounded-bl-full pointer-events-none" />
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-xs font-semibold"> <div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-xs font-semibold">
@@ -110,11 +136,11 @@ export default function Dashboard() {
Gateway Overview &amp; Control Gateway Overview &amp; Control
</h2> </h2>
<p className="text-sm text-muted-foreground max-w-2xl font-medium"> <p className="text-sm text-muted-foreground max-w-2xl font-medium">
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.
</p> </p>
</div> </div>
<div className="flex items-center gap-2 self-start sm:self-center"> <div className="flex flex-wrap sm:flex-col items-start sm:items-end gap-2 shrink-0">
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border/80 shadow-xs"> <div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border/80 shadow-xs">
<Radio className="h-4 w-4 text-emerald-500 animate-pulse" /> <Radio className="h-4 w-4 text-emerald-500 animate-pulse" />
<div className="text-left"> <div className="text-left">
@@ -122,173 +148,59 @@ export default function Dashboard() {
<div className="text-xs font-bold text-foreground">Global CDN Active</div> <div className="text-xs font-bold text-foreground">Global CDN Active</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
{checkedAtTime && (
<span className="text-[11px] text-muted-foreground font-medium">
Updated {formatRelativeTime(statusData?.checkedAt)} ({checkedAtTime})
</span>
)}
<Button
variant="outline"
size="sm"
onClick={handleRefreshAll}
disabled={isStatusLoading || isBucketsLoading || isRefreshing}
className="h-7 text-xs px-2 rounded-lg gap-1"
title="Refresh status and bucket overview"
>
<RotateCw className={`h-3 w-3 ${isRefreshing ? "animate-spin" : ""}`} />
<span className="hidden min-[420px]:inline">Refresh</span>
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>
{/* Status / Quick Overview cards */} {/* Status / Quick Overview cards */}
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
{/* Card 1 */} <GatewayStatusCard
<Card className="relative overflow-hidden border-border/80 bg-card/90 backdrop-blur-sm shadow-md hover:shadow-lg hover:border-blue-500/30 transition-all group"> status={statusData}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-emerald-500 to-teal-400" /> isLoading={isStatusLoading}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> error={statusError}
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground"> />
Gateway Engine <DriveQuotaCard
</CardTitle> drive={statusData?.drive}
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500 group-hover:scale-110 transition-transform"> isLoading={isStatusLoading}
<Server className="h-4 w-4" /> error={statusError}
</div> />
</CardHeader> <MultipartStatusCard
<CardContent> gateway={statusData?.gateway}
<div className="text-2xl font-bold flex items-center gap-2 text-foreground"> isLoading={isStatusLoading}
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500 shadow-xs shadow-emerald-500/50" /> error={statusError}
Online &amp; Healthy />
</div>
<p className="text-xs text-muted-foreground mt-1.5 flex items-center gap-1">
<Activity className="h-3 w-3 text-emerald-500" />
<span>Cloudflare Worker edge routing</span>
</p>
</CardContent>
</Card>
{/* Card 2 */}
<Card className="relative overflow-hidden border-border/80 bg-card/90 backdrop-blur-sm shadow-md hover:shadow-lg hover:border-blue-500/30 transition-all group">
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 to-indigo-500" />
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
S3 Storage Backend
</CardTitle>
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500 group-hover:scale-110 transition-transform">
<HardDrive className="h-4 w-4" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-foreground">Google Drive</div>
<p className="text-xs text-muted-foreground mt-1.5 flex items-center gap-1">
<CheckCircle2 className="h-3 w-3 text-blue-500" />
<span>Root directory auto-mapped</span>
</p>
</CardContent>
</Card>
{/* Card 3 */}
<Card className="relative overflow-hidden border-border/80 bg-card/90 backdrop-blur-sm shadow-md hover:shadow-lg hover:border-cyan-500/30 transition-all group">
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-cyan-500 to-blue-500" />
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Multipart Uploads
</CardTitle>
<div className="p-2 rounded-lg bg-cyan-500/10 text-cyan-500 group-hover:scale-110 transition-transform">
<Layers className="h-4 w-4" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-foreground">Durable Objects</div>
<p className="text-xs text-muted-foreground mt-1.5 flex items-center gap-1">
<Cpu className="h-3 w-3 text-cyan-500" />
<span>Resumable state coordination</span>
</p>
</CardContent>
</Card>
</div> </div>
{/* Feature Capabilities & Explorer Card */} {/* Feature Capabilities & Specifications */}
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
{/* Main Explorer placeholder */} <BucketStatsTable
<Card className="lg:col-span-2 border-border/80 bg-card/85 backdrop-blur-sm shadow-md"> stats={bucketStatsData}
<CardHeader> isLoading={isBucketsLoading}
<div className="flex items-center justify-between"> error={bucketsError}
<div className="flex items-center gap-2"> />
<div className="p-1.5 rounded-md bg-blue-500/10 text-blue-600 dark:text-blue-400"> <GatewayConfigCard
<FolderTree className="h-4 w-4" /> gateway={statusData?.gateway}
</div> isLoading={isStatusLoading}
<CardTitle className="text-base font-bold">Bucket Explorer</CardTitle> />
</div>
<span className="text-[11px] font-semibold text-blue-600 dark:text-blue-400 bg-blue-500/10 border border-blue-500/20 px-2 py-0.5 rounded-full">
Upcoming Module
</span>
</div>
<CardDescription className="text-xs text-muted-foreground">
Full-featured visual browser for files, folders, and storage metadata.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="h-48 rounded-xl border border-dashed border-border flex flex-col items-center justify-center p-6 text-center bg-muted/20">
<div className="p-3 rounded-2xl bg-gradient-to-tr from-blue-500/10 to-cyan-500/10 border border-blue-500/20 text-blue-500 mb-3">
<UploadCloud className="h-6 w-6 stroke-[1.8]" />
</div>
<h4 className="text-sm font-semibold text-foreground">Storage Bridge Ready</h4>
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
Sign-in verification complete. Ready for bucket exploration, object uploads, downloads, and presigned URL operations.
</p>
</div>
{/* Supported S3 Actions badges */}
<div className="flex flex-wrap items-center gap-2 pt-2">
<span className="text-[11px] font-medium text-muted-foreground mr-1">Supported APIs:</span>
{["s3:ListObjectsV2", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:CreateMultipartUpload"].map((api) => (
<span
key={api}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-[11px] font-mono font-medium bg-secondary/80 text-secondary-foreground border border-border/60"
>
<Sparkles className="h-2.5 w-2.5 text-blue-500" />
{api}
</span>
))}
</div>
</CardContent>
</Card>
{/* Quick Specifications Info card */}
<Card className="border-border/80 bg-card/85 backdrop-blur-sm shadow-md flex flex-col justify-between">
<div>
<CardHeader>
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-indigo-500/10 text-indigo-600 dark:text-indigo-400">
<ShieldCheck className="h-4 w-4" />
</div>
<CardTitle className="text-base font-bold">Security &amp; Protocol</CardTitle>
</div>
<CardDescription className="text-xs">
Compliance and encryption standards
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="p-3 rounded-xl bg-secondary/40 border border-border/60 space-y-1">
<div className="text-xs font-semibold text-foreground flex items-center justify-between">
<span>AWS Signature Version 4</span>
<span className="text-[10px] text-emerald-600 font-bold bg-emerald-500/10 px-1.5 py-0.5 rounded">Enabled</span>
</div>
<p className="text-[11px] text-muted-foreground">
Cryptographic HMAC-SHA256 request authentication
</p>
</div>
<div className="p-3 rounded-xl bg-secondary/40 border border-border/60 space-y-1">
<div className="text-xs font-semibold text-foreground flex items-center justify-between">
<span>Google OAuth 2.0</span>
<span className="text-[10px] text-blue-600 font-bold bg-blue-500/10 px-1.5 py-0.5 rounded">Auto-Refresh</span>
</div>
<p className="text-[11px] text-muted-foreground">
Bearer token rotation via Cloudflare KV store
</p>
</div>
</CardContent>
</div>
<div className="p-4 border-t border-border/60 bg-muted/10">
<a
href="https://developers.google.com/drive"
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-between w-full text-xs font-semibold text-blue-600 dark:text-blue-400 hover:underline"
>
<span>Google Drive API Reference</span>
<ArrowUpRight className="h-3.5 w-3.5" />
</a>
</div>
</Card>
</div> </div>
</main> </main>
</div> </div>