mirror of
https://github.com/Nezumi-2711/s3-drive-storage-manage.git
synced 2026-09-22 13:48:31 +00:00
feat: implement ui for showing basic information
This commit is contained in:
@@ -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",
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -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} />
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const statusKeys = {
|
||||
all: ["status"] as const,
|
||||
overview: () => [...statusKeys.all, "overview"] as const,
|
||||
buckets: () => [...statusKeys.all, "buckets"] as const,
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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 & 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 & 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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
+90
-178
@@ -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 (
|
||||
<div className="relative min-h-screen bg-background bg-grid-pattern flex flex-col overflow-x-hidden">
|
||||
{/* Background ambient colorful glow effects */}
|
||||
@@ -110,11 +136,11 @@ export default function Dashboard() {
|
||||
Gateway Overview & Control
|
||||
</h2>
|
||||
<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>
|
||||
</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">
|
||||
<Radio className="h-4 w-4 text-emerald-500 animate-pulse" />
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* Status / Quick Overview cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{/* Card 1 */}
|
||||
<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-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 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 bg-emerald-500 shadow-xs shadow-emerald-500/50" />
|
||||
Online & 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>
|
||||
<GatewayStatusCard
|
||||
status={statusData}
|
||||
isLoading={isStatusLoading}
|
||||
error={statusError}
|
||||
/>
|
||||
<DriveQuotaCard
|
||||
drive={statusData?.drive}
|
||||
isLoading={isStatusLoading}
|
||||
error={statusError}
|
||||
/>
|
||||
<MultipartStatusCard
|
||||
gateway={statusData?.gateway}
|
||||
isLoading={isStatusLoading}
|
||||
error={statusError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Feature Capabilities & Explorer Card */}
|
||||
{/* Feature Capabilities & Specifications */}
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main Explorer placeholder */}
|
||||
<Card className="lg:col-span-2 border-border/80 bg-card/85 backdrop-blur-sm shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<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 & 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>
|
||||
<BucketStatsTable
|
||||
stats={bucketStatsData}
|
||||
isLoading={isBucketsLoading}
|
||||
error={bucketsError}
|
||||
/>
|
||||
<GatewayConfigCard
|
||||
gateway={statusData?.gateway}
|
||||
isLoading={isStatusLoading}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user