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")
|
||||||
|
}
|
||||||
+91
-179
@@ -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 & Control
|
Gateway Overview & 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 & 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 & 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user