feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle
Made-with: Cursor
This commit is contained in:
110
frontend/src/app/admin/AdminHeader.tsx
Normal file
110
frontend/src/app/admin/AdminHeader.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Languages, Menu, X, ChevronLeft, Shield, LogOut } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminLogin } from "./login/useAdminLogin";
|
||||
import { adminNavItems } from "./constants";
|
||||
|
||||
export function AdminHeader() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const { logout } = useAdminLogin();
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-card px-3 lg:px-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden h-8 w-8"
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileOpen ? <X className="size-4" /> : <Menu className="size-4" />}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1.5 lg:hidden">
|
||||
<div className="flex size-5 items-center justify-center rounded bg-foreground">
|
||||
<Languages className="size-2.5 text-background" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-foreground">Admin</span>
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<h1 className="text-xs font-semibold text-foreground">System Administration</h1>
|
||||
<Separator orientation="vertical" className="h-3" />
|
||||
<span className="text-xs text-muted-foreground">Monitor infrastructure and manage users</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-destructive/30 bg-destructive/5 text-destructive text-[10px] px-1.5 py-0"
|
||||
>
|
||||
<Shield className="mr-1 size-2.5" />
|
||||
Superadmin
|
||||
</Badge>
|
||||
<Avatar className="size-6">
|
||||
<AvatarFallback className="bg-foreground text-background text-[10px] font-semibold">
|
||||
SA
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{mobileOpen && (
|
||||
<div className="border-b border-border bg-card px-3 py-2 lg:hidden">
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{adminNavItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-secondary text-foreground"
|
||||
: "text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-3.5 shrink-0" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<Separator className="my-1" />
|
||||
<Link
|
||||
href="/dashboard"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="size-3.5 shrink-0" />
|
||||
User Dashboard
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 justify-start gap-2 text-xs text-muted-foreground hover:text-destructive"
|
||||
onClick={() => {
|
||||
setMobileOpen(false);
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
<LogOut className="size-3.5 shrink-0" />
|
||||
Logout
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
frontend/src/app/admin/AdminSidebar.tsx
Normal file
87
frontend/src/app/admin/AdminSidebar.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ChevronLeft, Shield, Languages, LogOut } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAdminLogin } from "./login/useAdminLogin";
|
||||
import { adminNavItems } from "./constants";
|
||||
|
||||
export function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { logout } = useAdminLogin();
|
||||
|
||||
return (
|
||||
<aside className="hidden w-56 shrink-0 border-r border-border bg-card lg:flex lg:flex-col">
|
||||
<div className="flex h-12 items-center gap-2 px-4">
|
||||
<div className="flex size-6 items-center justify-center rounded-md bg-foreground">
|
||||
<Languages className="size-3 text-background" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold tracking-tight text-foreground">
|
||||
Office Translator
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-auto px-1.5 py-0 text-[10px] font-medium text-muted-foreground"
|
||||
>
|
||||
Admin
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-0.5 px-2 py-3">
|
||||
{adminNavItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-secondary text-foreground"
|
||||
: "text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-3.5 shrink-0" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-1 px-2 py-2">
|
||||
<div className="flex items-center gap-2 px-2.5 py-1">
|
||||
<Shield className="size-3 text-muted-foreground" />
|
||||
<span className="text-[10px] text-muted-foreground">Superadmin access</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 justify-start gap-2 text-xs text-muted-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href="/dashboard">
|
||||
<ChevronLeft className="size-3" />
|
||||
User Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 justify-start gap-2 text-xs text-muted-foreground hover:text-destructive"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut className="size-3" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
42
frontend/src/app/admin/DateRangeFilter.tsx
Normal file
42
frontend/src/app/admin/DateRangeFilter.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { Calendar } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { StatsPeriod } from "./types";
|
||||
|
||||
interface DateRangeFilterProps {
|
||||
value: StatsPeriod;
|
||||
onChange: (value: StatsPeriod) => void;
|
||||
}
|
||||
|
||||
const periodOptions: { value: StatsPeriod; label: string }[] = [
|
||||
{ value: "today", label: "Aujourd'hui" },
|
||||
{ value: "week", label: "7 derniers jours" },
|
||||
{ value: "month", label: "30 derniers jours" },
|
||||
];
|
||||
|
||||
export function DateRangeFilter({ value, onChange }: DateRangeFilterProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<Select value={value} onValueChange={(v) => onChange(v as StatsPeriod)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sélectionner une période" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{periodOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
frontend/src/app/admin/FormatBreakdownChart.tsx
Normal file
119
frontend/src/app/admin/FormatBreakdownChart.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { FileSpreadsheet, FileText, Presentation } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type { TranslationStatsData, FormatBreakdownItem } from "./types";
|
||||
|
||||
interface FormatBreakdownChartProps {
|
||||
data: TranslationStatsData | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const formatConfig: Record<string, { label: string; icon: React.ReactNode; color: string }> = {
|
||||
xlsx: {
|
||||
label: "Excel (.xlsx)",
|
||||
icon: <FileSpreadsheet className="h-4 w-4 text-green-500" />,
|
||||
color: "bg-green-500",
|
||||
},
|
||||
docx: {
|
||||
label: "Word (.docx)",
|
||||
icon: <FileText className="h-4 w-4 text-blue-500" />,
|
||||
color: "bg-blue-500",
|
||||
},
|
||||
pptx: {
|
||||
label: "PowerPoint (.pptx)",
|
||||
icon: <Presentation className="h-4 w-4 text-orange-500" />,
|
||||
color: "bg-orange-500",
|
||||
},
|
||||
};
|
||||
|
||||
export function FormatBreakdownChart({ data, isLoading }: FormatBreakdownChartProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Répartition par Format</CardTitle>
|
||||
<CardDescription>Chargement...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
|
||||
<div className="h-2 w-full animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.format_breakdown) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Répartition par Format</CardTitle>
|
||||
<CardDescription>Aucune donnée disponible</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<p className="text-sm">Aucune donnée de format</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const formats = Object.entries(data.format_breakdown).filter(
|
||||
([, value]) => value.count > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Répartition par Format</CardTitle>
|
||||
<CardDescription>
|
||||
Distribution des traductions par type de fichier
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{formats.map(([format, value]) => {
|
||||
const config = formatConfig[format] || {
|
||||
label: format.toUpperCase(),
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
color: "bg-gray-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={format} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
{config.icon}
|
||||
<span className="font-medium">{config.label}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
{value.count} ({value.percentage.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className={`h-full ${config.color} transition-all duration-500`}
|
||||
style={{ width: `${value.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
115
frontend/src/app/admin/ProviderBreakdownChart.tsx
Normal file
115
frontend/src/app/admin/ProviderBreakdownChart.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { Cpu } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type { TranslationStatsData } from "./types";
|
||||
|
||||
interface ProviderBreakdownChartProps {
|
||||
data: TranslationStatsData | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const providerLabels: Record<string, string> = {
|
||||
google: "Google Translate",
|
||||
deepl: "DeepL",
|
||||
ollama: "Ollama (Local)",
|
||||
openai: "OpenAI",
|
||||
};
|
||||
|
||||
const providerColors: Record<string, string> = {
|
||||
google: "bg-blue-500",
|
||||
deepl: "bg-indigo-500",
|
||||
ollama: "bg-green-500",
|
||||
openai: "bg-purple-500",
|
||||
};
|
||||
|
||||
export function ProviderBreakdownChart({ data, isLoading }: ProviderBreakdownChartProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5" />
|
||||
Répartition par Provider
|
||||
</CardTitle>
|
||||
<CardDescription>Chargement...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
|
||||
<div className="h-2 w-full animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.provider_breakdown) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5" />
|
||||
Répartition par Provider
|
||||
</CardTitle>
|
||||
<CardDescription>Aucune donnée disponible</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<p className="text-sm">Aucune donnée de provider</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const providers = Object.entries(data.provider_breakdown).filter(
|
||||
([, value]) => value.count > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5" />
|
||||
Répartition par Provider
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Distribution des traductions par fournisseur de service
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{providers.map(([provider, value]) => (
|
||||
<div key={provider} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">
|
||||
{providerLabels[provider] || provider}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{value.count} ({value.percentage.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className={`h-full ${providerColors[provider] || "bg-primary"} transition-all duration-500`}
|
||||
style={{ width: `${value.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
141
frontend/src/app/admin/ProviderStatus.tsx
Normal file
141
frontend/src/app/admin/ProviderStatus.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { AdminDashboardData, ProviderStatus } from "./types";
|
||||
|
||||
interface ProviderStatusProps {
|
||||
data: AdminDashboardData | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
google: "Google Translate",
|
||||
deepl: "DeepL",
|
||||
ollama: "Ollama (Local)",
|
||||
openai: "OpenAI",
|
||||
openrouter: "OpenRouter",
|
||||
};
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
online: {
|
||||
dotClass: "bg-green-500",
|
||||
label: "Online",
|
||||
badgeClass:
|
||||
"border-green-200/30 bg-green-500/10 text-green-600",
|
||||
},
|
||||
degraded: {
|
||||
dotClass: "bg-yellow-500",
|
||||
label: "Degraded",
|
||||
badgeClass:
|
||||
"border-yellow-200/30 bg-yellow-500/10 text-yellow-600",
|
||||
},
|
||||
offline: {
|
||||
dotClass: "bg-red-500",
|
||||
label: "Offline",
|
||||
badgeClass: "border-red-200/30 bg-red-500/10 text-red-500",
|
||||
},
|
||||
};
|
||||
|
||||
function getProviderStatus(
|
||||
provider: ProviderStatus
|
||||
): "online" | "degraded" | "offline" {
|
||||
if (provider.available) return "online";
|
||||
if (provider.error) return "offline";
|
||||
return "degraded";
|
||||
}
|
||||
|
||||
export function ProviderStatus({ data, isLoading }: ProviderStatusProps) {
|
||||
const providers = Object.entries(data?.providers || {});
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-3 w-32 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-6 w-28 animate-pulse rounded bg-muted"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (providers.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card px-4 py-3">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Translation API Providers
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No provider data available
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Translation API Providers
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{providers.length} provider{providers.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{providers.map(([key, provider]) => {
|
||||
const status = getProviderStatus(provider);
|
||||
const config = STATUS_CONFIG[status];
|
||||
const label = PROVIDER_LABELS[key] || provider.name || key;
|
||||
|
||||
return (
|
||||
<Tooltip key={key}>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`cursor-default gap-1.5 px-2.5 py-1 text-xs font-medium ${config.badgeClass}`}
|
||||
>
|
||||
<span
|
||||
className={`size-1.5 rounded-full ${config.dotClass}`}
|
||||
/>
|
||||
{label}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
<span className="font-medium">{config.label}</span>
|
||||
{provider.latency_ms !== undefined && (
|
||||
<span className="text-muted-foreground">
|
||||
Latency: {provider.latency_ms}ms
|
||||
</span>
|
||||
)}
|
||||
{provider.last_check && (
|
||||
<span className="text-muted-foreground">
|
||||
Last check:{" "}
|
||||
{new Date(provider.last_check).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
{provider.error && (
|
||||
<span className="text-red-500">{provider.error}</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
frontend/src/app/admin/StatsOverview.tsx
Normal file
116
frontend/src/app/admin/StatsOverview.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { TrendingUp, TrendingDown, FileText, AlertCircle } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { TranslationStatsData } from "./types";
|
||||
|
||||
interface StatsOverviewProps {
|
||||
data: TranslationStatsData | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function StatsOverview({ data, isLoading }: StatsOverviewProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-4 animate-pulse rounded bg-muted" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-8 w-16 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-2 h-3 w-20 animate-pulse rounded bg-muted" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const diff = data.total_translations - data.total_translations_last_period;
|
||||
const trendUp = diff >= 0;
|
||||
const trendPercent = data.total_translations_last_period > 0
|
||||
? Math.abs((diff / data.total_translations_last_period) * 100).toFixed(1)
|
||||
: "0";
|
||||
|
||||
const periodLabels: Record<string, string> = {
|
||||
today: "Aujourd'hui",
|
||||
week: "Cette Semaine",
|
||||
month: "Ce Mois",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Traductions {periodLabels[data.period]}
|
||||
</CardTitle>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{data.total_translations}</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{trendUp ? (
|
||||
<TrendingUp className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
<span className={trendUp ? "text-green-500" : "text-red-500"}>
|
||||
{trendUp ? "+" : ""}{diff}
|
||||
</span>
|
||||
<span>({trendPercent}%)</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Réussies</CardTitle>
|
||||
<Badge variant="default" className="bg-green-500/10 text-green-500 hover:bg-green-500/20">
|
||||
OK
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{data.success_count}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(100 - data.error_rate).toFixed(1)}% de réussite
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Erreurs</CardTitle>
|
||||
<AlertCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{data.error_count}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Taux d'erreur: {data.error_rate.toFixed(1)}%
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Période Précédente</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{data.total_translations_last_period}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Comparaison
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
frontend/src/app/admin/SystemHealthCards.tsx
Normal file
163
frontend/src/app/admin/SystemHealthCards.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { HeartPulse, HardDrive, FileWarning, Trash2, Loader2 } from "lucide-react";
|
||||
import type { AdminDashboardData } from "./types";
|
||||
|
||||
interface SystemHealthCardsProps {
|
||||
data: AdminDashboardData | null;
|
||||
isLoading: boolean;
|
||||
isPurging: boolean;
|
||||
onPurge: () => void;
|
||||
purgeResult: { files_cleaned: number } | null;
|
||||
}
|
||||
|
||||
export function SystemHealthCards({
|
||||
data,
|
||||
isLoading,
|
||||
isPurging,
|
||||
onPurge,
|
||||
purgeResult,
|
||||
}: SystemHealthCardsProps) {
|
||||
const diskUsed = data?.system?.disk?.used_percent ?? 0;
|
||||
const trackedFilesCount = data?.cleanup?.tracked_files_count ?? 0;
|
||||
const systemStatus = data?.status ?? "unhealthy";
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className="py-0">
|
||||
<CardContent className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<Card className="py-0">
|
||||
<CardContent className="flex items-center gap-3 px-4 py-3">
|
||||
<div
|
||||
className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${
|
||||
systemStatus === "healthy"
|
||||
? "bg-green-500/10"
|
||||
: "bg-red-500/10"
|
||||
}`}
|
||||
>
|
||||
<HeartPulse
|
||||
className={`size-4 ${
|
||||
systemStatus === "healthy"
|
||||
? "text-green-500"
|
||||
: "text-red-500"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Server Health
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="relative flex size-2">
|
||||
{systemStatus === "healthy" && (
|
||||
<>
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-green-500 opacity-75" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-green-500" />
|
||||
</>
|
||||
)}
|
||||
{systemStatus !== "healthy" && (
|
||||
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{systemStatus === "healthy"
|
||||
? "All Systems Operational"
|
||||
: "System Issues Detected"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{data?.timestamp
|
||||
? `Last update: ${new Date(data.timestamp).toLocaleTimeString()}`
|
||||
: "Waiting for data..."}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="py-0">
|
||||
<CardContent className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-blue-500/10">
|
||||
<HardDrive className="size-4 text-blue-500" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Disk Space
|
||||
</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold tabular-nums text-foreground">
|
||||
{diskUsed}% used
|
||||
</span>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">
|
||||
{data?.system?.disk?.total_gb ?? "--"} GB total
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={diskUsed}
|
||||
className="h-1.5 bg-muted [&>[data-slot=progress-indicator]]:bg-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="py-0">
|
||||
<CardContent className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-red-500/10">
|
||||
<FileWarning className="size-4 text-red-500" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Temporary Files
|
||||
</span>
|
||||
<span className="text-sm font-semibold tabular-nums text-foreground">
|
||||
{trackedFilesCount} orphaned files
|
||||
</span>
|
||||
{purgeResult && (
|
||||
<span className="text-[10px] text-green-500">
|
||||
{purgeResult.files_cleaned} files deleted
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 gap-1.5 border-red-200/30 text-red-500 hover:bg-red-500/10 hover:text-red-500 text-xs"
|
||||
onClick={onPurge}
|
||||
disabled={isPurging || trackedFilesCount === 0}
|
||||
>
|
||||
{isPurging ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3" />
|
||||
)}
|
||||
{isPurging
|
||||
? "Purging..."
|
||||
: trackedFilesCount === 0
|
||||
? "Clean"
|
||||
: "Purge"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
frontend/src/app/admin/TopUsersTable.tsx
Normal file
133
frontend/src/app/admin/TopUsersTable.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { Users, Trophy } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { TopUser } from "./types";
|
||||
|
||||
interface TopUsersTableProps {
|
||||
topUsers: TopUser[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function TopUsersTable({ topUsers, isLoading }: TopUsersTableProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5" />
|
||||
Top Utilisateurs
|
||||
</CardTitle>
|
||||
<CardDescription>Chargement...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-10 animate-pulse rounded bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!topUsers || topUsers.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5" />
|
||||
Top Utilisateurs
|
||||
</CardTitle>
|
||||
<CardDescription>Aucune donnée disponible</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Users className="h-12 w-12 mb-2 opacity-50" />
|
||||
<p className="text-sm">Aucune traduction enregistrée</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const getRankBadge = (rank: number) => {
|
||||
if (rank === 1) {
|
||||
return (
|
||||
<Badge className="bg-yellow-500/10 text-yellow-600 hover:bg-yellow-500/20">
|
||||
1er
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (rank === 2) {
|
||||
return (
|
||||
<Badge className="bg-gray-400/10 text-gray-500 hover:bg-gray-400/20">
|
||||
2e
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (rank === 3) {
|
||||
return (
|
||||
<Badge className="bg-orange-500/10 text-orange-600 hover:bg-orange-500/20">
|
||||
3e
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
{rank}e
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5" />
|
||||
Top Utilisateurs
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Les 10 utilisateurs les plus actifs par volume de traduction
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">Rang</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead className="text-right">Traductions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topUsers.slice(0, 10).map((user, index) => (
|
||||
<TableRow key={user.user_id}>
|
||||
<TableCell>{getRankBadge(index + 1)}</TableCell>
|
||||
<TableCell className="font-medium">{user.email}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge variant="secondary">{user.translation_count}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
15
frontend/src/app/admin/constants.ts
Normal file
15
frontend/src/app/admin/constants.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { LayoutDashboard, Users, Settings, FileText, Key, type LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface AdminNavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const adminNavItems: AdminNavItem[] = [
|
||||
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||
{ label: 'Users', href: '/admin/users', icon: Users },
|
||||
{ label: 'Providers', href: '/admin/settings', icon: Key },
|
||||
{ label: 'System', href: '/admin/system', icon: Settings },
|
||||
{ label: 'Logs', href: '/admin/logs', icon: FileText },
|
||||
];
|
||||
86
frontend/src/app/admin/layout.tsx
Normal file
86
frontend/src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import { AdminSidebar } from "./AdminSidebar";
|
||||
import { AdminHeader } from "./AdminHeader";
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { settings, setAdminToken } = useTranslationStore();
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
const verifyToken = useCallback(async (token: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/verify`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname === "/admin/login") {
|
||||
setIsChecking(false);
|
||||
setIsValid(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const adminToken = settings.adminToken;
|
||||
if (!adminToken) {
|
||||
router.push(`/admin/login?redirect=${encodeURIComponent(pathname)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
verifyToken(adminToken).then((valid) => {
|
||||
if (!valid) {
|
||||
setAdminToken(undefined);
|
||||
router.push(`/admin/login?redirect=${encodeURIComponent(pathname)}`);
|
||||
return;
|
||||
}
|
||||
setIsValid(true);
|
||||
setIsChecking(false);
|
||||
});
|
||||
}, [settings.adminToken, pathname, router, verifyToken, setAdminToken]);
|
||||
|
||||
if (isChecking && pathname !== "/admin/login") {
|
||||
return (
|
||||
<div className="min-h-screen bg-card flex items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">Vérification de l'authentification...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValid && pathname !== "/admin/login") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pathname === "/admin/login") {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<AdminSidebar />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<AdminHeader />
|
||||
<main className="flex-1 p-4 lg:p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,55 +2,25 @@
|
||||
|
||||
import { useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { useAdminLogin } from "./useAdminLogin";
|
||||
import { Shield, Lock, Eye, EyeOff, AlertCircle } from "lucide-react";
|
||||
|
||||
function AdminLoginContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { setAdminToken } = useTranslationStore();
|
||||
|
||||
const { login, isLoading, error } = useAdminLogin();
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/admin/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || "Mot de passe incorrect");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setAdminToken(data.access_token);
|
||||
|
||||
const redirect = searchParams.get("redirect") || "/admin";
|
||||
router.push(redirect);
|
||||
} catch (err: any) {
|
||||
const errorMessage = typeof err.message === 'string' ? err.message : "Erreur de connexion";
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
await login(password);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-purple-600/20 rounded-2xl mb-4">
|
||||
<Shield className="w-8 h-8 text-purple-400" />
|
||||
@@ -59,7 +29,6 @@ function AdminLoginContent() {
|
||||
<p className="text-gray-400 mt-2">Connexion requise</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="bg-black/30 backdrop-blur-xl rounded-2xl border border-white/10 p-8">
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 mb-6 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400">
|
||||
@@ -80,6 +49,7 @@ function AdminLoginContent() {
|
||||
placeholder="••••••••"
|
||||
className="w-full pl-12 pr-12 py-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500 transition-all"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -93,10 +63,10 @@ function AdminLoginContent() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !password}
|
||||
disabled={isLoading || !password}
|
||||
className="w-full py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-600/50 disabled:cursor-not-allowed text-white font-medium rounded-xl transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Connexion...
|
||||
|
||||
18
frontend/src/app/admin/login/types.ts
Normal file
18
frontend/src/app/admin/login/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface AdminLoginRequest {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AdminLoginResponse {
|
||||
status: string;
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AdminLoginState {
|
||||
login: (password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
89
frontend/src/app/admin/login/useAdminLogin.ts
Normal file
89
frontend/src/app/admin/login/useAdminLogin.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import type { AdminLoginResponse } from "./types";
|
||||
|
||||
const TIMEOUT_MS = 15000;
|
||||
|
||||
export function useAdminLogin() {
|
||||
const router = useRouter();
|
||||
const { setAdminToken } = useTranslationStore();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const login = async (password: string): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const contentType = response.headers.get("content-type");
|
||||
const isJson = contentType?.includes("application/json");
|
||||
|
||||
if (!response.ok) {
|
||||
const message = isJson
|
||||
? (await response.json()).detail || "Mot de passe incorrect"
|
||||
: "Erreur de connexion au serveur";
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const data: AdminLoginResponse = isJson ? await response.json() : {};
|
||||
const token = data.access_token;
|
||||
if (!token) {
|
||||
throw new Error("Réponse invalide du serveur");
|
||||
}
|
||||
|
||||
setAdminToken(token);
|
||||
router.push("/admin");
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === "AbortError") {
|
||||
setError("Délai de connexion dépassé. Veuillez réessayer.");
|
||||
} else {
|
||||
setError(err.message);
|
||||
}
|
||||
} else {
|
||||
setError("Erreur de connexion");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = useCallback(async (): Promise<void> => {
|
||||
const token = useTranslationStore.getState().settings.adminToken;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/v1/admin/logout`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Ignore logout errors - proceed with local cleanup
|
||||
}
|
||||
}
|
||||
|
||||
setAdminToken(undefined);
|
||||
router.push("/admin/login");
|
||||
}, [router, setAdminToken]);
|
||||
|
||||
return { login, logout, isLoading, error };
|
||||
}
|
||||
@@ -1,614 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { motion } from "framer-motion";
|
||||
import { Users, Activity, Settings, FileText, TrendingUp, Server, Key, LogOut, RefreshCw, Search, ChevronRight, Shield, Zap, Globe, DollarSign } from "lucide-react";
|
||||
|
||||
interface DashboardData {
|
||||
translations_today: number;
|
||||
translations_total: number;
|
||||
active_users: number;
|
||||
popular_languages: { [key: string]: number };
|
||||
average_processing_time: number;
|
||||
cache_hit_rate: number;
|
||||
openrouter_usage?: {
|
||||
total_cost: number;
|
||||
requests_count: number;
|
||||
models_used: { [key: string]: number };
|
||||
};
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
plan: string;
|
||||
translations_count: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
last_login?: string;
|
||||
}
|
||||
|
||||
interface AdminSettings {
|
||||
default_provider: string;
|
||||
openrouter_enabled: boolean;
|
||||
google_enabled: boolean;
|
||||
max_file_size_mb: number;
|
||||
rate_limit_per_minute: number;
|
||||
cache_enabled: boolean;
|
||||
}
|
||||
|
||||
function AdminContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { adminToken } = useTranslationStore();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "users" | "config" | "settings">("overview");
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [settings, setSettings] = useState<AdminSettings>({
|
||||
default_provider: "google",
|
||||
openrouter_enabled: true,
|
||||
google_enabled: true,
|
||||
max_file_size_mb: 10,
|
||||
rate_limit_per_minute: 60,
|
||||
cache_enabled: true
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get("tab");
|
||||
if (tab && ["overview", "users", "config", "settings"].includes(tab)) {
|
||||
setActiveTab(tab as any);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminToken) {
|
||||
router.push("/admin/login");
|
||||
return;
|
||||
}
|
||||
fetchDashboardData();
|
||||
}, [adminToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "users" && users.length === 0) {
|
||||
fetchUsers();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_BASE}/admin/dashboard`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` }
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to fetch dashboard data");
|
||||
const data = await response.json();
|
||||
setDashboardData(data);
|
||||
} catch (err) {
|
||||
setError("Erreur de chargement des données");
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/admin/users`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` }
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to fetch users");
|
||||
const data = await response.json();
|
||||
setUsers(data.users || []);
|
||||
} catch (err) {
|
||||
console.error("Error fetching users:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshData = async () => {
|
||||
setRefreshing(true);
|
||||
await fetchDashboardData();
|
||||
if (activeTab === "users") {
|
||||
await fetchUsers();
|
||||
}
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
useTranslationStore.getState().setAdminToken(null);
|
||||
router.push("/admin/login");
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(user =>
|
||||
user.email?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
user.username?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center">
|
||||
<div className="text-white text-xl flex items-center gap-3">
|
||||
<RefreshCw className="animate-spin" />
|
||||
Chargement...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
|
||||
{/* Header */}
|
||||
<header className="bg-black/30 backdrop-blur-xl border-b border-white/10">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-8 h-8 text-purple-400" />
|
||||
<h1 className="text-2xl font-bold text-white">Administration</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={refreshData}
|
||||
disabled={refreshing}
|
||||
className="p-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-all"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-red-500/20 hover:bg-red-500/30 text-red-400 transition-all"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-2 mb-8 bg-black/20 p-2 rounded-xl w-fit">
|
||||
{[
|
||||
{ id: "overview", label: "Vue d'ensemble", icon: Activity },
|
||||
{ id: "users", label: "Utilisateurs", icon: Users },
|
||||
{ id: "config", label: "Configuration", icon: Server },
|
||||
{ id: "settings", label: "Paramètres", icon: Settings }
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-all ${
|
||||
activeTab === tab.id
|
||||
? "bg-purple-600 text-white shadow-lg"
|
||||
: "text-gray-400 hover:text-white hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === "overview" && dashboardData && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
title="Traductions Aujourd'hui"
|
||||
value={dashboardData.translations_today ?? 0}
|
||||
icon={FileText}
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Traductions"
|
||||
value={dashboardData.translations_total ?? 0}
|
||||
icon={TrendingUp}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="Utilisateurs Actifs"
|
||||
value={dashboardData.active_users ?? 0}
|
||||
icon={Users}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="Taux Cache"
|
||||
value={`${((dashboardData.cache_hit_rate ?? 0) * 100).toFixed(1)}%`}
|
||||
icon={Zap}
|
||||
color="yellow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* OpenRouter Usage */}
|
||||
{dashboardData.openrouter_usage && (
|
||||
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-purple-400" />
|
||||
Utilisation OpenRouter
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white/5 rounded-xl p-4">
|
||||
<p className="text-gray-400 text-sm">Coût Total</p>
|
||||
<p className="text-2xl font-bold text-green-400">
|
||||
${dashboardData.openrouter_usage.total_cost?.toFixed(4) ?? '0.0000'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-4">
|
||||
<p className="text-gray-400 text-sm">Requêtes</p>
|
||||
<p className="text-2xl font-bold text-blue-400">
|
||||
{dashboardData.openrouter_usage.requests_count ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-4">
|
||||
<p className="text-gray-400 text-sm">Temps Moyen</p>
|
||||
<p className="text-2xl font-bold text-yellow-400">
|
||||
{(dashboardData.average_processing_time ?? 0).toFixed(2)}s
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Popular Languages */}
|
||||
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-purple-400" />
|
||||
Langues Populaires
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Object.entries(dashboardData.popular_languages || {}).slice(0, 8).map(([lang, count]) => (
|
||||
<div key={lang} className="bg-white/5 rounded-xl p-4 text-center">
|
||||
<p className="text-2xl font-bold text-white">{count}</p>
|
||||
<p className="text-gray-400 text-sm uppercase">{lang}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Users Tab */}
|
||||
{activeTab === "users" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un utilisateur..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-gray-400">
|
||||
{filteredUsers.length} utilisateur(s)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="text-left p-4 text-gray-400 font-medium">Utilisateur</th>
|
||||
<th className="text-left p-4 text-gray-400 font-medium">Plan</th>
|
||||
<th className="text-left p-4 text-gray-400 font-medium">Traductions</th>
|
||||
<th className="text-left p-4 text-gray-400 font-medium">Statut</th>
|
||||
<th className="text-left p-4 text-gray-400 font-medium">Inscrit le</th>
|
||||
<th className="text-left p-4 text-gray-400 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map((user) => (
|
||||
<tr key={user.id} className="border-b border-white/5 hover:bg-white/5 transition-colors">
|
||||
<td className="p-4">
|
||||
<div>
|
||||
<p className="text-white font-medium">{user.username || 'N/A'}</p>
|
||||
<p className="text-gray-400 text-sm">{user.email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
user.plan === 'premium'
|
||||
? 'bg-purple-500/20 text-purple-400'
|
||||
: user.plan === 'pro'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-gray-500/20 text-gray-400'
|
||||
}`}>
|
||||
{user.plan || 'free'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-white">{user.translations_count ?? 0}</td>
|
||||
<td className="p-4">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
user.is_active
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-red-500/20 text-red-400'
|
||||
}`}>
|
||||
{user.is_active ? 'Actif' : 'Inactif'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-gray-400 text-sm">
|
||||
{user.created_at ? new Date(user.created_at).toLocaleDateString('fr-FR') : 'N/A'}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<button className="p-2 rounded-lg hover:bg-white/10 text-gray-400 hover:text-white transition-all">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredUsers.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-8 text-center text-gray-400">
|
||||
Aucun utilisateur trouvé
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Config Tab */}
|
||||
{activeTab === "config" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Translation Providers */}
|
||||
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<Server className="w-5 h-5 text-purple-400" />
|
||||
Fournisseurs de Traduction
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Google Translate */}
|
||||
<div className="flex items-center justify-between p-4 bg-white/5 rounded-xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-blue-500/20 rounded-xl flex items-center justify-center">
|
||||
<Globe className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-white font-medium">Google Translate</h4>
|
||||
<p className="text-gray-400 text-sm">API officielle Google Cloud</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.google_enabled}
|
||||
onChange={(e) => setSettings({ ...settings, google_enabled: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-700 peer-focus:ring-4 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* OpenRouter */}
|
||||
<div className="flex items-center justify-between p-4 bg-white/5 rounded-xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-purple-500/20 rounded-xl flex items-center justify-center">
|
||||
<Zap className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-white font-medium">OpenRouter</h4>
|
||||
<p className="text-gray-400 text-sm">Modèles IA avancés (GPT-4, Claude, etc.)</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.openrouter_enabled}
|
||||
onChange={(e) => setSettings({ ...settings, openrouter_enabled: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-700 peer-focus:ring-4 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Keys */}
|
||||
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<Key className="w-5 h-5 text-purple-400" />
|
||||
Clés API
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-white/5 rounded-xl">
|
||||
<label className="block text-gray-400 text-sm mb-2">Google Cloud API Key</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••••••••••"
|
||||
className="flex-1 px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
<button className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-all">
|
||||
Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white/5 rounded-xl">
|
||||
<label className="block text-gray-400 text-sm mb-2">OpenRouter API Key</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••••••••••"
|
||||
className="flex-1 px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
<button className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-all">
|
||||
Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default Provider */}
|
||||
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-purple-400" />
|
||||
Fournisseur par Défaut
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => setSettings({ ...settings, default_provider: 'google' })}
|
||||
className={`p-4 rounded-xl border-2 transition-all ${
|
||||
settings.default_provider === 'google'
|
||||
? 'border-purple-500 bg-purple-500/10'
|
||||
: 'border-white/10 bg-white/5 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<Globe className={`w-8 h-8 mb-2 ${settings.default_provider === 'google' ? 'text-purple-400' : 'text-gray-400'}`} />
|
||||
<h4 className="text-white font-medium">Google Translate</h4>
|
||||
<p className="text-gray-400 text-sm">Rapide et fiable</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSettings({ ...settings, default_provider: 'openrouter' })}
|
||||
className={`p-4 rounded-xl border-2 transition-all ${
|
||||
settings.default_provider === 'openrouter'
|
||||
? 'border-purple-500 bg-purple-500/10'
|
||||
: 'border-white/10 bg-white/5 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<Zap className={`w-8 h-8 mb-2 ${settings.default_provider === 'openrouter' ? 'text-purple-400' : 'text-gray-400'}`} />
|
||||
<h4 className="text-white font-medium">OpenRouter</h4>
|
||||
<p className="text-gray-400 text-sm">IA avancée</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Settings Tab */}
|
||||
{activeTab === "settings" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Limits */}
|
||||
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-purple-400" />
|
||||
Limites
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-gray-400 text-sm mb-2">Taille max fichier (MB)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.max_file_size_mb}
|
||||
onChange={(e) => setSettings({ ...settings, max_file_size_mb: parseInt(e.target.value) || 10 })}
|
||||
className="w-full px-4 py-3 bg-black/30 border border-white/10 rounded-xl text-white focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-400 text-sm mb-2">Requêtes/minute</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.rate_limit_per_minute}
|
||||
onChange={(e) => setSettings({ ...settings, rate_limit_per_minute: parseInt(e.target.value) || 60 })}
|
||||
className="w-full px-4 py-3 bg-black/30 border border-white/10 rounded-xl text-white focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cache */}
|
||||
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-purple-400" />
|
||||
Cache
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-white/5 rounded-xl">
|
||||
<div>
|
||||
<h4 className="text-white font-medium">Cache des traductions</h4>
|
||||
<p className="text-gray-400 text-sm">Améliore les performances et réduit les coûts</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.cache_enabled}
|
||||
onChange={(e) => setSettings({ ...settings, cache_enabled: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-700 peer-focus:ring-4 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<button className="px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white rounded-xl font-medium transition-all flex items-center gap-2">
|
||||
Sauvegarder les paramètres
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon: Icon, color }: { title: string; value: string | number; icon: any; color: string }) {
|
||||
const colorClasses = {
|
||||
purple: 'bg-purple-500/20 text-purple-400',
|
||||
blue: 'bg-blue-500/20 text-blue-400',
|
||||
green: 'bg-green-500/20 text-green-400',
|
||||
yellow: 'bg-yellow-500/20 text-yellow-400'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`p-3 rounded-xl ${colorClasses[color as keyof typeof colorClasses]}`}>
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white mb-1">{value}</p>
|
||||
<p className="text-gray-400 text-sm">{title}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Shield, RefreshCw, Loader2, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SystemHealthCards } from "./SystemHealthCards";
|
||||
import { ProviderStatus } from "./ProviderStatus";
|
||||
import { useAdminDashboard } from "./useAdminDashboard";
|
||||
import { useCleanup } from "./useCleanup";
|
||||
import {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
export default function AdminPage() {
|
||||
const { data, isLoading, error, refetch } = useAdminDashboard();
|
||||
const { isPurging, purgeResult, triggerCleanup } = useCleanup();
|
||||
|
||||
const handlePurge = async () => {
|
||||
await triggerCleanup();
|
||||
refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center">
|
||||
<div className="text-white text-xl">Chargement...</div>
|
||||
<TooltipProvider>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-purple-500/10">
|
||||
<Shield className="size-5 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Dashboard Admin
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Panneau de contrôle administrateur
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Refresh dashboard data</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-red-200/30 bg-red-500/10 px-4 py-3 text-red-500">
|
||||
<AlertCircle className="size-4" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SystemHealthCards
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
isPurging={isPurging}
|
||||
onPurge={handlePurge}
|
||||
purgeResult={purgeResult}
|
||||
/>
|
||||
|
||||
<ProviderStatus data={data} isLoading={isLoading} />
|
||||
|
||||
{data?.config && (
|
||||
<div className="rounded-lg border border-border bg-card px-4 py-3">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
System Configuration
|
||||
</span>
|
||||
<div className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Max file size:{" "}
|
||||
<strong className="text-foreground">
|
||||
{data.config.max_file_size_mb}MB
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
Translation service:{" "}
|
||||
<strong className="text-foreground">
|
||||
{data.config.translation_service}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
Formats:{" "}
|
||||
<strong className="text-foreground">
|
||||
{data.config.supported_extensions.join(", ")}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}>
|
||||
<AdminContent />
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
574
frontend/src/app/admin/settings/page.tsx
Normal file
574
frontend/src/app/admin/settings/page.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Settings, Save, Loader2, CheckCircle, XCircle, RefreshCw, FlaskConical, KeyRound } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useNotification } from "@/components/ui/notification";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
|
||||
interface ProviderConfig {
|
||||
enabled: boolean;
|
||||
api_key?: string;
|
||||
base_url?: string;
|
||||
model?: string;
|
||||
timeout?: number;
|
||||
max_retries?: number;
|
||||
}
|
||||
|
||||
interface SettingsConfig {
|
||||
google: ProviderConfig;
|
||||
deepl: ProviderConfig;
|
||||
openai: ProviderConfig;
|
||||
ollama: ProviderConfig;
|
||||
openrouter: ProviderConfig;
|
||||
openrouter_premium: ProviderConfig;
|
||||
zai: ProviderConfig;
|
||||
fallback_chain: string;
|
||||
fallback_chain_classic: string;
|
||||
fallback_chain_llm: string;
|
||||
}
|
||||
|
||||
interface EnvInfo {
|
||||
deepl: boolean;
|
||||
openai: boolean;
|
||||
openrouter: boolean;
|
||||
openrouter_premium: boolean;
|
||||
zai: boolean;
|
||||
ollama: boolean;
|
||||
}
|
||||
|
||||
interface OllamaModel {
|
||||
name: string;
|
||||
size: number;
|
||||
modified_at: string;
|
||||
}
|
||||
|
||||
const defaultConfig: SettingsConfig = {
|
||||
google: { enabled: true, timeout: 30, max_retries: 3 },
|
||||
deepl: { enabled: false, api_key: "", timeout: 30, max_retries: 3 },
|
||||
openai: { enabled: false, api_key: "", timeout: 60, max_retries: 3 },
|
||||
ollama: { enabled: false, base_url: "http://localhost:11434", model: "llama3" },
|
||||
openrouter: { enabled: false, api_key: "", model: "deepseek/deepseek-chat" },
|
||||
openrouter_premium: { enabled: false, api_key: "", model: "openai/gpt-4o-mini" },
|
||||
zai: { enabled: false, api_key: "", base_url: "https://api.x.ai/v1", model: "grok-2-1212" },
|
||||
fallback_chain: "google,deepl,openai,ollama,openrouter,openrouter_premium,zai",
|
||||
fallback_chain_classic: "google,deepl",
|
||||
fallback_chain_llm: "ollama,openai,openrouter,zai",
|
||||
};
|
||||
|
||||
const defaultEnvInfo: EnvInfo = {
|
||||
deepl: false,
|
||||
openai: false,
|
||||
openrouter: false,
|
||||
openrouter_premium: false,
|
||||
zai: false,
|
||||
ollama: false,
|
||||
};
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const [config, setConfig] = useState<SettingsConfig>(defaultConfig);
|
||||
const [envInfo, setEnvInfo] = useState<EnvInfo>(defaultEnvInfo);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [testResults, setTestResults] = useState<Record<string, "ok" | "error" | "testing" | "idle">>({});
|
||||
const [testMessages, setTestMessages] = useState<Record<string, string>>({});
|
||||
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||
const { success, error, info } = useNotification();
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const getToken = () => useTranslationStore.getState().settings.adminToken ?? "";
|
||||
|
||||
const loadConfig = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/settings`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const envelope = await response.json();
|
||||
// API returns { data: {...settings...}, env_info: {...}, meta: {} }
|
||||
const payload = envelope.data ?? envelope;
|
||||
setConfig({ ...defaultConfig, ...payload });
|
||||
if (envelope.env_info) {
|
||||
setEnvInfo({ ...defaultEnvInfo, ...envelope.env_info });
|
||||
}
|
||||
} else {
|
||||
error({ title: "Erreur de chargement", description: `HTTP ${response.status} — vérifiez votre token admin.` });
|
||||
}
|
||||
} catch (e) {
|
||||
error({ title: "Erreur réseau", description: "Impossible de contacter le backend." });
|
||||
console.error("Failed to load settings:", e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveConfig = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/settings`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
if (response.ok) {
|
||||
success({ title: "✅ Configuration sauvegardée", description: "Les paramètres ont été enregistrés avec succès." });
|
||||
} else {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
error({ title: "Erreur de sauvegarde", description: body.detail || `HTTP ${response.status}` });
|
||||
}
|
||||
} catch (e) {
|
||||
error({ title: "Erreur réseau", description: "Impossible de contacter le backend pour la sauvegarde." });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testProvider = async (provider: string) => {
|
||||
setTestResults((prev) => ({ ...prev, [provider]: "testing" }));
|
||||
setTestMessages((prev) => ({ ...prev, [provider]: "" }));
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/admin/providers/${provider}/test`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.available) {
|
||||
setTestResults((prev) => ({ ...prev, [provider]: "ok" }));
|
||||
const detail = data.test_result || data.usage || data.models_count !== undefined
|
||||
? `Connexion OK${data.models_count !== undefined ? ` — ${data.models_count} modèles` : ""}${data.test_result ? ` — "${data.test_result}"` : ""}`
|
||||
: "Connexion OK";
|
||||
setTestMessages((prev) => ({ ...prev, [provider]: detail }));
|
||||
} else {
|
||||
setTestResults((prev) => ({ ...prev, [provider]: "error" }));
|
||||
setTestMessages((prev) => ({ ...prev, [provider]: data.error || "Échec" }));
|
||||
}
|
||||
} catch (e) {
|
||||
setTestResults((prev) => ({ ...prev, [provider]: "error" }));
|
||||
setTestMessages((prev) => ({ ...prev, [provider]: "Erreur réseau" }));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchOllamaModels = async () => {
|
||||
setIsLoadingModels(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/admin/providers/ollama/models`,
|
||||
{ headers: { Authorization: `Bearer ${getToken()}` } }
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setOllamaModels(data.data || []);
|
||||
if (data.data?.length > 0 && !config.ollama.model) {
|
||||
updateProvider("ollama", { model: data.data[0].name });
|
||||
}
|
||||
info({ title: `${data.data?.length || 0} modèles Ollama trouvés` });
|
||||
} else {
|
||||
error({ title: "Ollama inaccessible", description: "Vérifiez que Ollama tourne sur l'URL configurée." });
|
||||
}
|
||||
} catch (e) {
|
||||
error({ title: "Erreur Ollama", description: "Impossible de contacter Ollama." });
|
||||
} finally {
|
||||
setIsLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
type ProviderKey = keyof Omit<SettingsConfig, "fallback_chain" | "fallback_chain_classic" | "fallback_chain_llm">;
|
||||
const updateProvider = (provider: ProviderKey, updates: Partial<ProviderConfig>) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], ...updates } as ProviderConfig,
|
||||
}));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-600/20 rounded-lg flex items-center justify-center">
|
||||
<Settings className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Paramètres des providers</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configurez les clés API. Les clés peuvent aussi être définies dans le fichier <code>.env</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<ProviderCard
|
||||
title="Google Translate"
|
||||
description="Tier gratuit : 500 000 caractères/mois. Aucune clé requise."
|
||||
enabled={config.google.enabled}
|
||||
onToggle={(enabled) => updateProvider("google", { enabled })}
|
||||
onTest={() => testProvider("google")}
|
||||
testResult={testResults.google ?? "idle"}
|
||||
testMessage={testMessages.google}
|
||||
noApiKey
|
||||
/>
|
||||
|
||||
<ProviderCard
|
||||
title="DeepL"
|
||||
description="Traduction professionnelle. Obtenez une clé sur deepl.com/pro-api"
|
||||
enabled={config.deepl.enabled}
|
||||
onToggle={(enabled) => updateProvider("deepl", { enabled })}
|
||||
onTest={() => testProvider("deepl")}
|
||||
testResult={testResults.deepl ?? "idle"}
|
||||
testMessage={testMessages.deepl}
|
||||
envKeySet={envInfo.deepl}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="deepl-key">Clé API</Label>
|
||||
<Input
|
||||
id="deepl-key"
|
||||
type="password"
|
||||
placeholder={envInfo.deepl ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "Entrez votre clé DeepL"}
|
||||
value={config.deepl.api_key || ""}
|
||||
onChange={(e) => updateProvider("deepl", { api_key: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</ProviderCard>
|
||||
|
||||
<ProviderCard
|
||||
title="OpenAI"
|
||||
description="Traductions GPT-4. Obtenez une clé sur platform.openai.com"
|
||||
enabled={config.openai.enabled}
|
||||
onToggle={(enabled) => updateProvider("openai", { enabled })}
|
||||
onTest={() => testProvider("openai")}
|
||||
testResult={testResults.openai ?? "idle"}
|
||||
testMessage={testMessages.openai}
|
||||
envKeySet={envInfo.openai}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openai-key">Clé API</Label>
|
||||
<Input
|
||||
id="openai-key"
|
||||
type="password"
|
||||
placeholder={envInfo.openai ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "sk-..."}
|
||||
value={config.openai.api_key || ""}
|
||||
onChange={(e) => updateProvider("openai", { api_key: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</ProviderCard>
|
||||
|
||||
<ProviderCard
|
||||
title="Ollama"
|
||||
description="LLM local. Nécessite Ollama en cours d'exécution."
|
||||
enabled={config.ollama.enabled}
|
||||
onToggle={(enabled) => updateProvider("ollama", { enabled })}
|
||||
onTest={() => testProvider("ollama")}
|
||||
testResult={testResults.ollama ?? "idle"}
|
||||
testMessage={testMessages.ollama}
|
||||
envKeySet={envInfo.ollama}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ollama-url">URL de base</Label>
|
||||
<Input
|
||||
id="ollama-url"
|
||||
placeholder={envInfo.ollama ? "URL configurée dans .env" : "http://localhost:11434"}
|
||||
value={config.ollama.base_url || ""}
|
||||
onChange={(e) => updateProvider("ollama", { base_url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="ollama-model">Modèle</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchOllamaModels}
|
||||
disabled={isLoadingModels}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{isLoadingModels ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3" />
|
||||
)}
|
||||
<span className="ml-1">Récupérer les modèles</span>
|
||||
</Button>
|
||||
</div>
|
||||
{ollamaModels.length > 0 ? (
|
||||
<Select
|
||||
value={config.ollama.model || ""}
|
||||
onValueChange={(value) => updateProvider("ollama", { model: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sélectionnez un modèle" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ollamaModels.map((model) => (
|
||||
<SelectItem key={model.name} value={model.name}>
|
||||
{model.name}
|
||||
{model.size > 0 && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({(model.size / 1e9).toFixed(1)} GB)
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id="ollama-model"
|
||||
placeholder="llama3"
|
||||
value={config.ollama.model || ""}
|
||||
onChange={(e) => updateProvider("ollama", { model: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
{ollamaModels.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Cliquez sur "Récupérer les modèles" pour charger la liste depuis Ollama.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ProviderCard>
|
||||
|
||||
<ProviderCard
|
||||
title="Traduction IA Essentielle"
|
||||
description="Affichée aux utilisateurs comme 'Traduction IA Essentielle'. Modèles économiques recommandés : deepseek/deepseek-chat, google/gemini-2.0-flash, meta-llama/llama-3.3-70b-instruct. Clé API : openrouter.ai"
|
||||
enabled={config.openrouter.enabled}
|
||||
onToggle={(enabled) => updateProvider("openrouter", { enabled })}
|
||||
onTest={() => testProvider("openrouter")}
|
||||
testResult={testResults.openrouter ?? "idle"}
|
||||
testMessage={testMessages.openrouter}
|
||||
envKeySet={envInfo.openrouter}
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openrouter-key">Clé API OpenRouter</Label>
|
||||
<Input
|
||||
id="openrouter-key"
|
||||
type="password"
|
||||
placeholder={envInfo.openrouter ? "Clé configurée dans .env (partagée avec Premium)" : "sk-or-..."}
|
||||
value={config.openrouter.api_key || ""}
|
||||
onChange={(e) => updateProvider("openrouter", { api_key: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openrouter-model">Modèle Essentiel</Label>
|
||||
<Input
|
||||
id="openrouter-model"
|
||||
placeholder="deepseek/deepseek-chat"
|
||||
value={config.openrouter.model || ""}
|
||||
onChange={(e) => updateProvider("openrouter", { model: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Recommandé : <code>deepseek/deepseek-chat</code> (~€0.04/doc)</p>
|
||||
</div>
|
||||
</div>
|
||||
</ProviderCard>
|
||||
|
||||
<ProviderCard
|
||||
title="Traduction IA Premium"
|
||||
description="Affichée aux utilisateurs comme 'Traduction IA Premium'. Modèles haute qualité : openai/gpt-4o, anthropic/claude-3.5-sonnet, google/gemini-1.5-pro. Partage la même clé OpenRouter."
|
||||
enabled={config.openrouter_premium.enabled}
|
||||
onToggle={(enabled) => updateProvider("openrouter_premium", { enabled })}
|
||||
onTest={() => testProvider("openrouter_premium")}
|
||||
testResult={testResults.openrouter_premium ?? "idle"}
|
||||
testMessage={testMessages.openrouter_premium}
|
||||
envKeySet={envInfo.openrouter_premium}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openrouter-premium-model">Modèle Premium</Label>
|
||||
<Input
|
||||
id="openrouter-premium-model"
|
||||
placeholder="openai/gpt-4o-mini"
|
||||
value={config.openrouter_premium.model || ""}
|
||||
onChange={(e) => updateProvider("openrouter_premium", { model: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recommandé : <code>openai/gpt-4o-mini</code> (~€0.15/doc) ou <code>anthropic/claude-3.5-haiku</code> (~€0.20/doc)
|
||||
</p>
|
||||
</div>
|
||||
</ProviderCard>
|
||||
|
||||
<ProviderCard
|
||||
title="z.AI / xAI Grok"
|
||||
description="Modèles Grok par xAI. API compatible OpenAI. Obtenez votre clé sur x.ai"
|
||||
enabled={config.zai.enabled}
|
||||
onToggle={(enabled) => updateProvider("zai", { enabled })}
|
||||
onTest={() => testProvider("zai")}
|
||||
testResult={testResults.zai ?? "idle"}
|
||||
testMessage={testMessages.zai}
|
||||
envKeySet={envInfo.zai}
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="zai-key">Clé API</Label>
|
||||
<Input
|
||||
id="zai-key"
|
||||
type="password"
|
||||
placeholder={envInfo.zai ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "xai-..."}
|
||||
value={config.zai.api_key || ""}
|
||||
onChange={(e) => updateProvider("zai", { api_key: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="zai-model">Modèle</Label>
|
||||
<Input
|
||||
id="zai-model"
|
||||
placeholder="grok-2-1212"
|
||||
value={config.zai.model || ""}
|
||||
onChange={(e) => updateProvider("zai", { model: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
<Label htmlFor="zai-url">URL de base</Label>
|
||||
<Input
|
||||
id="zai-url"
|
||||
placeholder="https://api.x.ai/v1"
|
||||
value={config.zai.base_url || ""}
|
||||
onChange={(e) => updateProvider("zai", { base_url: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Par défaut : <code>https://api.x.ai/v1</code> — à changer uniquement si vous utilisez un proxy.
|
||||
</p>
|
||||
</div>
|
||||
</ProviderCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Chaîne de fallback</CardTitle>
|
||||
<CardDescription>Ordre de priorité pour la sélection des providers</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Mode classique (Google/DeepL)</Label>
|
||||
<Input
|
||||
value={config.fallback_chain_classic}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, fallback_chain_classic: e.target.value }))}
|
||||
placeholder="google,deepl"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Mode LLM (Ollama/OpenAI)</Label>
|
||||
<Input
|
||||
value={config.fallback_chain_llm}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, fallback_chain_llm: e.target.value }))}
|
||||
placeholder="ollama,openai"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={saveConfig} disabled={isSaving} size="lg">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Sauvegarde...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 size-4" />
|
||||
Sauvegarder la configuration
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderCard({
|
||||
title,
|
||||
description,
|
||||
enabled,
|
||||
onToggle,
|
||||
onTest,
|
||||
testResult,
|
||||
testMessage,
|
||||
noApiKey = false,
|
||||
envKeySet = false,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onTest: () => void;
|
||||
testResult: "ok" | "error" | "testing" | "idle";
|
||||
testMessage?: string;
|
||||
noApiKey?: boolean;
|
||||
envKeySet?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className={enabled ? "border-primary/30" : ""}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
<Badge variant={enabled ? "default" : "secondary"} className="text-xs">
|
||||
{enabled ? "Activé" : "Désactivé"}
|
||||
</Badge>
|
||||
{envKeySet && !noApiKey && (
|
||||
<Badge variant="outline" className="text-xs gap-1 border-green-500/40 text-green-400">
|
||||
<KeyRound className="size-3" />
|
||||
Clé dans .env
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onTest}
|
||||
disabled={testResult === "testing"}
|
||||
className="h-8"
|
||||
>
|
||||
{testResult === "testing" ? (
|
||||
<><Loader2 className="size-3 animate-spin mr-1" />Test...</>
|
||||
) : testResult === "ok" ? (
|
||||
<><CheckCircle className="size-3 text-green-500 mr-1" />OK</>
|
||||
) : testResult === "error" ? (
|
||||
<><XCircle className="size-3 text-red-500 mr-1" />Erreur</>
|
||||
) : (
|
||||
<><FlaskConical className="size-3 mr-1" />Tester</>
|
||||
)}
|
||||
</Button>
|
||||
<Switch checked={enabled} onCheckedChange={onToggle} />
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
{testMessage && (
|
||||
<p className={`text-xs mt-1 ${testResult === "ok" ? "text-green-400" : "text-red-400"}`}>
|
||||
{testMessage}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
{!noApiKey && children && <CardContent className="pt-0">{children}</CardContent>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
126
frontend/src/app/admin/stats/page.tsx
Normal file
126
frontend/src/app/admin/stats/page.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { BarChart3, RefreshCw, Loader2, AlertCircle, Info } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatsOverview } from "../StatsOverview";
|
||||
import { TopUsersTable } from "../TopUsersTable";
|
||||
import { ProviderBreakdownChart } from "../ProviderBreakdownChart";
|
||||
import { FormatBreakdownChart } from "../FormatBreakdownChart";
|
||||
import { DateRangeFilter } from "../DateRangeFilter";
|
||||
import { useTranslationStats } from "../useTranslationStats";
|
||||
import type { StatsPeriod } from "../types";
|
||||
import {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
const ERROR_RATE_WARNING_THRESHOLD = 5;
|
||||
|
||||
export default function StatsPage() {
|
||||
const [period, setPeriod] = useState<StatsPeriod>("today");
|
||||
const { data, isLoading, error, refetch, isMockData } = useTranslationStats(period);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-blue-500/10">
|
||||
<BarChart3 className="size-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Statistiques de Traduction
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Analyse des traductions et patterns d'utilisation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<DateRangeFilter value={period} onChange={setPeriod} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5" />
|
||||
)}
|
||||
Actualiser
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Actualiser les statistiques</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-red-200/30 bg-red-500/10 px-4 py-3 text-red-500">
|
||||
<AlertCircle className="size-4" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatsOverview data={data} isLoading={isLoading} />
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<ProviderBreakdownChart data={data} isLoading={isLoading} />
|
||||
<FormatBreakdownChart data={data} isLoading={isLoading} />
|
||||
</div>
|
||||
|
||||
<TopUsersTable
|
||||
topUsers={data?.top_users ?? []}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{data && (
|
||||
<div className="rounded-lg border border-border bg-card px-4 py-3">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Informations
|
||||
</span>
|
||||
<div className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground">
|
||||
{isMockData && (
|
||||
<span className="flex items-center gap-1 text-amber-500">
|
||||
<Info className="h-3 w-3" />
|
||||
<strong>Mode Démo</strong> - Données simulées
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
Rafraîchissement auto:{" "}
|
||||
<strong className="text-foreground">toutes les 30 secondes</strong>
|
||||
</span>
|
||||
<span>
|
||||
Période:{" "}
|
||||
<strong className="text-foreground">
|
||||
{period === "today"
|
||||
? "Aujourd'hui"
|
||||
: period === "week"
|
||||
? "7 derniers jours"
|
||||
: "30 derniers jours"}
|
||||
</strong>
|
||||
</span>
|
||||
{data.error_rate > ERROR_RATE_WARNING_THRESHOLD && (
|
||||
<span className="text-orange-500">
|
||||
Taux d'erreur élevé détecté ({data.error_rate.toFixed(1)}%)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
61
frontend/src/app/admin/system/CleanupSection.tsx
Normal file
61
frontend/src/app/admin/system/CleanupSection.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2, Loader2 } from "lucide-react";
|
||||
import type { CleanupResponse } from "../types";
|
||||
|
||||
interface CleanupSectionProps {
|
||||
trackedFilesCount: number;
|
||||
isPurging: boolean;
|
||||
purgeResult: CleanupResponse | null;
|
||||
onCleanup: () => void;
|
||||
}
|
||||
|
||||
export function CleanupSection({
|
||||
trackedFilesCount,
|
||||
isPurging,
|
||||
purgeResult,
|
||||
onCleanup,
|
||||
}: CleanupSectionProps) {
|
||||
return (
|
||||
<Card className="py-0">
|
||||
<CardContent className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-red-500/10">
|
||||
<Trash2 className="size-4 text-red-500" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Fichiers Temporaires
|
||||
</span>
|
||||
<span className="text-sm font-semibold tabular-nums text-foreground">
|
||||
{trackedFilesCount} fichier{trackedFilesCount !== 1 ? "s" : ""} orphelin{trackedFilesCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{purgeResult && (
|
||||
<span className="text-[10px] text-green-500">
|
||||
{purgeResult.files_cleaned} fichier{purgeResult.files_cleaned !== 1 ? "s" : ""} supprimé{purgeResult.files_cleaned !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 gap-1.5 border-red-200/30 text-red-500 hover:bg-red-500/10 hover:text-red-500 text-xs"
|
||||
onClick={onCleanup}
|
||||
disabled={isPurging || trackedFilesCount === 0}
|
||||
>
|
||||
{isPurging ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3" />
|
||||
)}
|
||||
{isPurging
|
||||
? "Nettoyage..."
|
||||
: trackedFilesCount === 0
|
||||
? "Propre"
|
||||
: "Nettoyer"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
51
frontend/src/app/admin/system/DiskSpaceCard.tsx
Normal file
51
frontend/src/app/admin/system/DiskSpaceCard.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { HardDrive } from "lucide-react";
|
||||
|
||||
interface DiskSpaceCardProps {
|
||||
usedPercent?: number;
|
||||
totalGb?: number;
|
||||
freeGb?: number;
|
||||
}
|
||||
|
||||
export function DiskSpaceCard({
|
||||
usedPercent = 0,
|
||||
totalGb,
|
||||
freeGb,
|
||||
}: DiskSpaceCardProps) {
|
||||
return (
|
||||
<Card className="py-0">
|
||||
<CardContent className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-blue-500/10">
|
||||
<HardDrive className="size-4 text-blue-500" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Espace Disque
|
||||
</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold tabular-nums text-foreground">
|
||||
{usedPercent.toFixed(1)}% utilisé
|
||||
</span>
|
||||
{totalGb !== undefined && (
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">
|
||||
{totalGb} GB total
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Progress
|
||||
value={usedPercent}
|
||||
className="h-1.5 bg-muted [&>[data-slot=progress-indicator]]:bg-blue-500"
|
||||
/>
|
||||
{freeGb !== undefined && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{freeGb.toFixed(1)} GB libres
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
61
frontend/src/app/admin/system/page.tsx
Normal file
61
frontend/src/app/admin/system/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { Settings, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { useSystemPage } from "./useSystemPage";
|
||||
import { CleanupSection } from "./CleanupSection";
|
||||
import { DiskSpaceCard } from "./DiskSpaceCard";
|
||||
import { ProviderStatus } from "../ProviderStatus";
|
||||
|
||||
export default function AdminSystemPage() {
|
||||
const { data, isLoading, error, isPurging, purgeResult, handleCleanup } = useSystemPage();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-600/20 rounded-lg flex items-center justify-center">
|
||||
<Settings className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Système</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Surveiller l'état du système et gérer les ressources
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-red-200/30 bg-red-500/10 px-4 py-3 text-red-500">
|
||||
<AlertCircle className="size-4" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && !data ? (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{[1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[88px] animate-pulse rounded-lg border border-border bg-card"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<DiskSpaceCard
|
||||
usedPercent={data?.system?.disk?.used_percent}
|
||||
totalGb={data?.system?.disk?.total_gb}
|
||||
freeGb={data?.system?.disk?.free_gb}
|
||||
/>
|
||||
<CleanupSection
|
||||
trackedFilesCount={data?.cleanup?.tracked_files_count ?? 0}
|
||||
isPurging={isPurging}
|
||||
purgeResult={purgeResult}
|
||||
onCleanup={handleCleanup}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProviderStatus data={data} isLoading={isLoading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
frontend/src/app/admin/system/useSystemPage.ts
Normal file
20
frontend/src/app/admin/system/useSystemPage.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useAdminDashboard } from "../useAdminDashboard";
|
||||
import { useCleanup } from "../useCleanup";
|
||||
|
||||
export function useSystemPage() {
|
||||
const { data, isLoading, error } = useAdminDashboard();
|
||||
const { isPurging, purgeResult, error: cleanupError, triggerCleanup } = useCleanup();
|
||||
|
||||
const handleCleanup = () => triggerCleanup();
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
error: error || cleanupError,
|
||||
isPurging,
|
||||
purgeResult,
|
||||
handleCleanup,
|
||||
};
|
||||
}
|
||||
76
frontend/src/app/admin/types.ts
Normal file
76
frontend/src/app/admin/types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export interface AdminDashboardData {
|
||||
timestamp: string;
|
||||
status: "healthy" | "unhealthy";
|
||||
system: {
|
||||
memory: Record<string, unknown>;
|
||||
disk: {
|
||||
used_percent?: number;
|
||||
total_gb?: number;
|
||||
free_gb?: number;
|
||||
};
|
||||
};
|
||||
providers: Record<string, ProviderStatus>;
|
||||
cleanup: {
|
||||
files_cleaned: number;
|
||||
tracked_files_count: number;
|
||||
};
|
||||
rate_limits: {
|
||||
active_clients: number;
|
||||
};
|
||||
config: {
|
||||
max_file_size_mb: number;
|
||||
supported_extensions: string[];
|
||||
translation_service: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProviderStatus {
|
||||
name: string;
|
||||
available: boolean;
|
||||
last_check: string | null;
|
||||
latency_ms?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CleanupResponse {
|
||||
status: "success" | "error";
|
||||
files_cleaned: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type StatsPeriod = "today" | "week" | "month";
|
||||
|
||||
export interface ProviderBreakdownItem {
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface FormatBreakdownItem {
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface TopUser {
|
||||
user_id: string;
|
||||
email: string;
|
||||
translation_count: number;
|
||||
}
|
||||
|
||||
export interface TranslationStatsData {
|
||||
period: StatsPeriod;
|
||||
total_translations: number;
|
||||
total_translations_last_period: number;
|
||||
error_rate: number;
|
||||
error_count: number;
|
||||
success_count: number;
|
||||
top_users: TopUser[];
|
||||
provider_breakdown: Record<string, ProviderBreakdownItem>;
|
||||
format_breakdown: Record<string, FormatBreakdownItem>;
|
||||
}
|
||||
|
||||
export interface TranslationStatsResponse {
|
||||
data: TranslationStatsData;
|
||||
meta: {
|
||||
generated_at: string;
|
||||
};
|
||||
}
|
||||
97
frontend/src/app/admin/useAdminDashboard.ts
Normal file
97
frontend/src/app/admin/useAdminDashboard.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import type { AdminDashboardData } from "./types";
|
||||
|
||||
const TIMEOUT_MS = 15000;
|
||||
export const REFETCH_INTERVAL_MS = 30000;
|
||||
|
||||
export const QUERY_KEY = ["admin", "dashboard"];
|
||||
|
||||
async function fetchDashboardData(adminToken: string | null | undefined): Promise<AdminDashboardData> {
|
||||
if (!adminToken) {
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/dashboard`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error("UNAUTHORIZED");
|
||||
}
|
||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function useAdminDashboard() {
|
||||
const { settings } = useTranslationStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: () => fetchDashboardData(settings.adminToken),
|
||||
enabled: !!settings.adminToken,
|
||||
refetchInterval: REFETCH_INTERVAL_MS,
|
||||
staleTime: 10000,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const getErrorMessage = (err: Error | null): string | null => {
|
||||
if (!err) return null;
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
AUTH_REQUIRED: "Veuillez vous connecter pour accéder au tableau de bord",
|
||||
UNAUTHORIZED: "Session expirée. Veuillez vous reconnecter.",
|
||||
HTTP_ERROR_403: "Accès refusé. Droits administrateur requis.",
|
||||
HTTP_ERROR_404: "Service indisponible. Veuillez réessayer plus tard.",
|
||||
HTTP_ERROR_500: "Erreur serveur. Veuillez réessayer plus tard.",
|
||||
HTTP_ERROR_502: "Service temporairement indisponible.",
|
||||
HTTP_ERROR_503: "Service en maintenance. Veuillez réessayer plus tard.",
|
||||
};
|
||||
|
||||
const code = err.message;
|
||||
if (errorMap[code]) {
|
||||
return errorMap[code];
|
||||
}
|
||||
|
||||
if (err.name === "AbortError") {
|
||||
return "Le serveur met trop de temps à répondre. Veuillez réessayer.";
|
||||
}
|
||||
|
||||
if (err.message.includes("fetch") || err.message.includes("network")) {
|
||||
return "Impossible de se connecter au serveur. Vérifiez votre connexion.";
|
||||
}
|
||||
|
||||
return "Une erreur inattendue s'est produite. Veuillez réessayer.";
|
||||
};
|
||||
|
||||
const errorMessage = error ? getErrorMessage(error as Error) : null;
|
||||
|
||||
return {
|
||||
data: data ?? null,
|
||||
isLoading,
|
||||
error: errorMessage,
|
||||
refetch,
|
||||
queryClient,
|
||||
queryKey: QUERY_KEY,
|
||||
};
|
||||
}
|
||||
87
frontend/src/app/admin/useCleanup.ts
Normal file
87
frontend/src/app/admin/useCleanup.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import type { CleanupResponse } from "./types";
|
||||
import { QUERY_KEY as DASHBOARD_QUERY_KEY } from "./useAdminDashboard";
|
||||
|
||||
const TIMEOUT_MS = 15000;
|
||||
|
||||
async function triggerCleanupApi(adminToken: string | null | undefined): Promise<CleanupResponse> {
|
||||
if (!adminToken) {
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/cleanup/trigger`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error("UNAUTHORIZED");
|
||||
}
|
||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function useCleanup() {
|
||||
const { settings } = useTranslationStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => triggerCleanupApi(settings.adminToken),
|
||||
onSuccess: () => {
|
||||
// Invalidate dashboard cache after successful cleanup
|
||||
queryClient.invalidateQueries({ queryKey: DASHBOARD_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
// Map error codes to user-friendly messages
|
||||
const getErrorMessage = (err: Error | null): string | null => {
|
||||
if (!err) return null;
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
AUTH_REQUIRED: "Veuillez vous connecter pour effectuer cette action",
|
||||
UNAUTHORIZED: "Session expirée. Veuillez vous reconnecter.",
|
||||
HTTP_ERROR_403: "Accès refusé. Droits administrateur requis.",
|
||||
HTTP_ERROR_500: "Erreur serveur lors du nettoyage. Veuillez réessayer.",
|
||||
};
|
||||
|
||||
const code = err.message;
|
||||
if (errorMap[code]) {
|
||||
return errorMap[code];
|
||||
}
|
||||
|
||||
if (err.name === "AbortError") {
|
||||
return "Le serveur met trop de temps à répondre. Veuillez réessayer.";
|
||||
}
|
||||
|
||||
return "Erreur lors du nettoyage. Veuillez réessayer.";
|
||||
};
|
||||
|
||||
const errorMessage = mutation.error ? getErrorMessage(mutation.error as Error) : null;
|
||||
|
||||
return {
|
||||
isPurging: mutation.isPending,
|
||||
purgeResult: mutation.data ?? null,
|
||||
error: errorMessage,
|
||||
triggerCleanup: mutation.mutateAsync,
|
||||
};
|
||||
}
|
||||
159
frontend/src/app/admin/useTranslationStats.ts
Normal file
159
frontend/src/app/admin/useTranslationStats.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import type { StatsPeriod, TranslationStatsResponse } from "./types";
|
||||
|
||||
const TIMEOUT_MS = 15000;
|
||||
export const REFETCH_INTERVAL_MS = 30000;
|
||||
|
||||
export const QUERY_KEY = (period: StatsPeriod) => ["admin", "stats", "translations", period];
|
||||
|
||||
async function fetchTranslationStats(
|
||||
adminToken: string | null | undefined,
|
||||
period: StatsPeriod
|
||||
): Promise<TranslationStatsResponse> {
|
||||
if (!adminToken) {
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/admin/stats/translations?period=${period}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
}
|
||||
);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error("UNAUTHORIZED");
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new Error("ENDPOINT_NOT_FOUND");
|
||||
}
|
||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function getMockData(period: StatsPeriod): TranslationStatsResponse {
|
||||
const baseCount = period === "today" ? 42 : period === "week" ? 287 : 1156;
|
||||
const lastPeriodCount = period === "today" ? 38 : period === "week" ? 254 : 1023;
|
||||
|
||||
return {
|
||||
data: {
|
||||
period,
|
||||
total_translations: baseCount,
|
||||
total_translations_last_period: lastPeriodCount,
|
||||
error_rate: 2.3,
|
||||
error_count: Math.floor(baseCount * 0.023),
|
||||
success_count: Math.floor(baseCount * 0.977),
|
||||
top_users: [
|
||||
{ user_id: "user_1", email: "sarah.chen@acme.com", translation_count: 15 },
|
||||
{ user_id: "user_2", email: "marc.dubois@example.fr", translation_count: 12 },
|
||||
{ user_id: "user_3", email: "anna.mueller@corp.de", translation_count: 8 },
|
||||
{ user_id: "user_4", email: "john.smith@company.uk", translation_count: 6 },
|
||||
{ user_id: "user_5", email: "lisa.wong@startup.io", translation_count: 5 },
|
||||
{ user_id: "user_6", email: "pierre.leroux@mail.fr", translation_count: 4 },
|
||||
{ user_id: "user_7", email: "emma.johnson@tech.us", translation_count: 3 },
|
||||
{ user_id: "user_8", email: "klaus.weber@firm.de", translation_count: 2 },
|
||||
{ user_id: "user_9", email: "sofia.garcia@empresa.es", translation_count: 2 },
|
||||
{ user_id: "user_10", email: "yuki.tanaka@office.jp", translation_count: 1 },
|
||||
],
|
||||
provider_breakdown: {
|
||||
google: { count: Math.floor(baseCount * 0.476), percentage: 47.6 },
|
||||
deepl: { count: Math.floor(baseCount * 0.357), percentage: 35.7 },
|
||||
ollama: { count: Math.floor(baseCount * 0.119), percentage: 11.9 },
|
||||
openai: { count: Math.floor(baseCount * 0.048), percentage: 4.8 },
|
||||
},
|
||||
format_breakdown: {
|
||||
xlsx: { count: Math.floor(baseCount * 0.595), percentage: 59.5 },
|
||||
docx: { count: Math.floor(baseCount * 0.286), percentage: 28.6 },
|
||||
pptx: { count: Math.floor(baseCount * 0.119), percentage: 11.9 },
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
generated_at: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useTranslationStats(period: StatsPeriod = "today") {
|
||||
const { settings } = useTranslationStore();
|
||||
|
||||
const [isMockData, setIsMockData] = React.useState(false);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: QUERY_KEY(period),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const result = await fetchTranslationStats(settings.adminToken, period);
|
||||
setIsMockData(false);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if ((err as Error).message === "ENDPOINT_NOT_FOUND") {
|
||||
setIsMockData(true);
|
||||
return getMockData(period);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
enabled: !!settings.adminToken,
|
||||
refetchInterval: REFETCH_INTERVAL_MS,
|
||||
staleTime: 10000,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const getErrorMessage = (err: Error | null): string | null => {
|
||||
if (!err) return null;
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
AUTH_REQUIRED: "Veuillez vous connecter pour accéder aux statistiques",
|
||||
UNAUTHORIZED: "Session expirée. Veuillez vous reconnecter.",
|
||||
HTTP_ERROR_403: "Accès refusé. Droits administrateur requis.",
|
||||
HTTP_ERROR_500: "Erreur serveur. Veuillez réessayer plus tard.",
|
||||
};
|
||||
|
||||
const code = err.message;
|
||||
if (errorMap[code]) {
|
||||
return errorMap[code];
|
||||
}
|
||||
|
||||
if (err.name === "AbortError") {
|
||||
return "Le serveur met trop de temps à répondre.";
|
||||
}
|
||||
|
||||
if (err.message.includes("fetch") || err.message.includes("network")) {
|
||||
return "Impossible de se connecter au serveur.";
|
||||
}
|
||||
|
||||
return "Une erreur inattendue s'est produite.";
|
||||
};
|
||||
|
||||
const errorMessage = error ? getErrorMessage(error as Error) : null;
|
||||
|
||||
return {
|
||||
data: data?.data ?? null,
|
||||
isLoading,
|
||||
error: errorMessage,
|
||||
refetch,
|
||||
queryKey: QUERY_KEY(period),
|
||||
isMockData,
|
||||
};
|
||||
}
|
||||
116
frontend/src/app/admin/users/UserStats.tsx
Normal file
116
frontend/src/app/admin/users/UserStats.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Users, UserCheck, Crown, Zap } from "lucide-react";
|
||||
import type { AdminUser } from "./types";
|
||||
import { PLAN_LABELS } from "./types";
|
||||
|
||||
interface UserStatsProps {
|
||||
users: AdminUser[];
|
||||
total: number;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function UserStats({ users, total, isLoading }: UserStatsProps) {
|
||||
const activeUsers = users.filter((u) => u.subscription_status === "active").length;
|
||||
const proUsers = users.filter((u) => u.plan === "pro" || u.plan === "business" || u.plan === "enterprise").length;
|
||||
const freeUsers = users.filter((u) => u.plan === "free" || u.plan === "starter").length;
|
||||
|
||||
const planDistribution = users.reduce(
|
||||
(acc, user) => {
|
||||
acc[user.plan] = (acc[user.plan] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="size-9 rounded-lg bg-muted" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="h-3 w-16 rounded bg-muted" />
|
||||
<div className="h-5 w-10 rounded bg-muted" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<Card className="py-0">
|
||||
<CardContent className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-secondary">
|
||||
<Users className="size-4 text-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Total Users
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-foreground">{total}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="py-0">
|
||||
<CardContent className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-[oklch(0.59_0.16_145/0.1)]">
|
||||
<UserCheck className="size-4 text-[oklch(0.59_0.16_145)]" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Active This Month
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-foreground">{activeUsers}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="py-0">
|
||||
<CardContent className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-[oklch(0.70_0.14_255/0.1)]">
|
||||
<Crown className="size-4 text-[oklch(0.70_0.14_255)]" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Pro Users
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-foreground">{proUsers}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="py-0">
|
||||
<CardContent className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<Zap className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Free Users
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-foreground">{freeUsers}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{Object.entries(planDistribution).length > 0 && (
|
||||
<div className="col-span-2 flex flex-wrap items-center gap-2 md:col-span-4">
|
||||
<span className="text-xs text-muted-foreground">Distribution:</span>
|
||||
{Object.entries(planDistribution).map(([plan, count]) => (
|
||||
<Badge key={plan} variant="outline" className="text-xs">
|
||||
{PLAN_LABELS[plan as keyof typeof PLAN_LABELS] || plan}: {count}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
383
frontend/src/app/admin/users/UserTable.tsx
Normal file
383
frontend/src/app/admin/users/UserTable.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search, KeyRound, Loader2, Filter } from "lucide-react";
|
||||
import type { AdminUser, PlanType } from "./types";
|
||||
import { PLAN_LABELS, PLAN_TIERS } from "./types";
|
||||
|
||||
interface UserTableProps {
|
||||
users: AdminUser[];
|
||||
isLoading: boolean;
|
||||
onTierChange: (userId: string, plan: PlanType) => Promise<void>;
|
||||
onRevokeKeys: (userId: string, keyIds: string[]) => Promise<void>;
|
||||
isUpdating: boolean;
|
||||
isRevoking: boolean;
|
||||
}
|
||||
|
||||
type TierFilter = "all" | "free" | "pro";
|
||||
|
||||
const statusConfig: Record<string, { label: string; dotClass: string; textClass: string }> = {
|
||||
active: {
|
||||
label: "Actif",
|
||||
dotClass: "bg-[oklch(0.59_0.16_145)]",
|
||||
textClass: "text-[oklch(0.45_0.12_145)]",
|
||||
},
|
||||
suspended: {
|
||||
label: "Suspendu",
|
||||
dotClass: "bg-destructive",
|
||||
textClass: "text-destructive",
|
||||
},
|
||||
pending: {
|
||||
label: "En attente",
|
||||
dotClass: "bg-[oklch(0.75_0.18_55)]",
|
||||
textClass: "text-[oklch(0.55_0.16_55)]",
|
||||
},
|
||||
cancelled: {
|
||||
label: "Annulé",
|
||||
dotClass: "bg-muted-foreground",
|
||||
textClass: "text-muted-foreground",
|
||||
},
|
||||
};
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
export function UserTable({
|
||||
users,
|
||||
isLoading,
|
||||
onTierChange,
|
||||
onRevokeKeys,
|
||||
isUpdating,
|
||||
isRevoking,
|
||||
}: UserTableProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [tierFilter, setTierFilter] = useState<TierFilter>("all");
|
||||
const [revokedUsers, setRevokedUsers] = useState<Set<string>>(new Set());
|
||||
const [errorUserId, setErrorUserId] = useState<string | null>(null);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
let result = users;
|
||||
|
||||
if (tierFilter !== "all") {
|
||||
result = result.filter((user) => PLAN_TIERS[user.plan] === tierFilter);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter((user) => user.email.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [users, searchQuery, tierFilter]);
|
||||
|
||||
const handleTierChange = async (userId: string, plan: PlanType) => {
|
||||
setErrorUserId(null);
|
||||
try {
|
||||
await onTierChange(userId, plan);
|
||||
} catch {
|
||||
setErrorUserId(userId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeKeys = async (userId: string, keyIds: string[]) => {
|
||||
setErrorUserId(null);
|
||||
try {
|
||||
await onRevokeKeys(userId, keyIds);
|
||||
setRevokedUsers((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(userId);
|
||||
return next;
|
||||
});
|
||||
setTimeout(() => {
|
||||
setRevokedUsers((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(userId);
|
||||
return next;
|
||||
});
|
||||
}, 2000);
|
||||
} catch {
|
||||
setErrorUserId(userId);
|
||||
}
|
||||
};
|
||||
|
||||
const activeCount = users.filter((u) => u.subscription_status === "active").length;
|
||||
const proCount = users.filter((u) => PLAN_TIERS[u.plan] === "pro").length;
|
||||
const freeCount = users.filter((u) => PLAN_TIERS[u.plan] === "free").length;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Chargement des utilisateurs...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Gestion des Utilisateurs</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">
|
||||
{users.length} total
|
||||
<span className="mx-1.5 text-border">|</span>
|
||||
{activeCount} actifs
|
||||
<span className="mx-1.5 text-border">|</span>
|
||||
{proCount} pro
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="size-3.5 text-muted-foreground" />
|
||||
<Select value={tierFilter} onValueChange={(val: TierFilter) => setTierFilter(val)}>
|
||||
<SelectTrigger className="h-8 w-[100px] text-xs">
|
||||
<SelectValue placeholder="Tier" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all" className="text-xs">Tous</SelectItem>
|
||||
<SelectItem value="free" className="text-xs">Free</SelectItem>
|
||||
<SelectItem value="pro" className="text-xs">Pro</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="relative w-full md:w-64">
|
||||
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Rechercher par email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-0 pb-0">
|
||||
<div className="border-t border-border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-8 pl-6 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Email
|
||||
</TableHead>
|
||||
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Statut
|
||||
</TableHead>
|
||||
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Plan
|
||||
</TableHead>
|
||||
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Usage
|
||||
</TableHead>
|
||||
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Clés
|
||||
</TableHead>
|
||||
<TableHead className="h-8 pr-6 text-right text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.map((user) => {
|
||||
const sConfig = statusConfig[user.subscription_status] || statusConfig.pending;
|
||||
const maxDocs = user.plan_limits?.docs_per_month || 100;
|
||||
const usagePercent = Math.min((user.docs_translated_this_month / maxDocs) * 100, 100);
|
||||
const isOverQuota = user.docs_translated_this_month > maxDocs;
|
||||
const justRevoked = revokedUsers.has(user.id);
|
||||
const hasError = errorUserId === user.id;
|
||||
const apiKeyIds = user.api_key_ids || [];
|
||||
|
||||
return (
|
||||
<TableRow key={user.id} className={`group ${hasError ? "bg-destructive/5" : ""}`}>
|
||||
<TableCell className="pl-6 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Créé le {formatDate(user.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`size-1.5 rounded-full ${sConfig.dotClass}`} />
|
||||
<span className={`text-xs font-medium ${sConfig.textClass}`}>
|
||||
{sConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2">
|
||||
<Select
|
||||
value={user.plan}
|
||||
onValueChange={(val: PlanType) => handleTierChange(user.id, val)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className={`h-7 w-[90px] text-xs font-semibold uppercase tracking-wider ${
|
||||
PLAN_TIERS[user.plan] === "pro"
|
||||
? "border-[oklch(0.59_0.16_145)/30] bg-[oklch(0.59_0.16_145)/10] text-[oklch(0.45_0.12_145)]"
|
||||
: "border-border text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="free" className="text-xs">Free</SelectItem>
|
||||
<SelectItem value="starter" className="text-xs">Starter</SelectItem>
|
||||
<SelectItem value="pro" className="text-xs">Pro</SelectItem>
|
||||
<SelectItem value="business" className="text-xs">Business</SelectItem>
|
||||
<SelectItem value="enterprise" className="text-xs">Enterprise</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2">
|
||||
<div className="flex w-28 flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`text-[10px] font-medium tabular-nums ${
|
||||
isOverQuota ? "text-destructive" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{user.docs_translated_this_month} / {maxDocs}
|
||||
</span>
|
||||
{isOverQuota && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-4 border-destructive/30 bg-destructive/5 px-1 text-[9px] text-destructive"
|
||||
>
|
||||
Dépassement
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Progress
|
||||
value={usagePercent}
|
||||
className={`h-1 bg-muted ${
|
||||
isOverQuota
|
||||
? "[&>[data-slot=progress-indicator]]:bg-destructive"
|
||||
: usagePercent > 80
|
||||
? "[&>[data-slot=progress-indicator]]:bg-[oklch(0.75_0.18_55)]"
|
||||
: "[&>[data-slot=progress-indicator]]:bg-[oklch(0.59_0.16_145)]"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2">
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{user.api_keys_count ?? 0}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="pr-6 py-2 text-right">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`h-7 gap-1 px-2 text-[10px] ${
|
||||
justRevoked
|
||||
? "border-[oklch(0.59_0.16_145/0.3)] text-[oklch(0.45_0.12_145)]"
|
||||
: hasError
|
||||
? "border-destructive text-destructive"
|
||||
: "border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
}`}
|
||||
onClick={() => handleRevokeKeys(user.id, apiKeyIds)}
|
||||
disabled={apiKeyIds.length === 0 || isRevoking || justRevoked}
|
||||
>
|
||||
<KeyRound className="size-3" />
|
||||
{justRevoked ? "Révoquées" : "Révoquer"}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">
|
||||
{apiKeyIds.length === 0
|
||||
? "Aucune clé active"
|
||||
: `Révoquer ${apiKeyIds.length} clé${apiKeyIds.length > 1 ? "s" : ""} active${apiKeyIds.length > 1 ? "s" : ""}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredUsers.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="py-8 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
{searchQuery || tierFilter !== "all"
|
||||
? "Aucun utilisateur ne correspond à vos filtres."
|
||||
: "Aucun utilisateur trouvé."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-border px-6 py-2">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Affichage de {filteredUsers.length} sur {users.length} utilisateurs
|
||||
</span>
|
||||
{tierFilter !== "all" && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Filtre: {tierFilter === "pro" ? "Pro" : "Free"} ({tierFilter === "pro" ? proCount : freeCount})
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
114
frontend/src/app/admin/users/page.tsx
Normal file
114
frontend/src/app/admin/users/page.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { Users } from "lucide-react";
|
||||
import { useAdminUsers } from "./useAdminUsers";
|
||||
import { useUpdateUserTier } from "./useUpdateUserTier";
|
||||
import { useRevokeApiKey } from "./useRevokeApiKey";
|
||||
import { UserStats } from "./UserStats";
|
||||
import { UserTable } from "./UserTable";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import type { PlanType } from "./types";
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { users, total, isLoading, error, refetch } = useAdminUsers();
|
||||
const { updateTier, isUpdating } = useUpdateUserTier();
|
||||
const { revokeKey, isRevoking } = useRevokeApiKey();
|
||||
const toast = useToast();
|
||||
|
||||
const handleTierChange = async (userId: string, plan: PlanType) => {
|
||||
try {
|
||||
await updateTier({ userId, plan });
|
||||
toast.success({
|
||||
title: "Plan mis à jour",
|
||||
description: `Le plan a été changé vers "${plan}" avec succès.`,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
toast.error({
|
||||
title: "Erreur",
|
||||
description: `Impossible de mettre à jour le plan: ${message}`,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeKeys = async (userId: string, keyIds: string[]) => {
|
||||
if (!keyIds || keyIds.length === 0) {
|
||||
toast.warning({
|
||||
title: "Aucune clé",
|
||||
description: "Cet utilisateur n'a pas de clés API actives.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
keyIds.map((keyId) =>
|
||||
revokeKey({ keyId, reason: "Admin revocation from user management" })
|
||||
)
|
||||
);
|
||||
toast.success({
|
||||
title: "Clés révoquées",
|
||||
description: `${keyIds.length} clé${keyIds.length > 1 ? "s" : ""} API ${keyIds.length > 1 ? "ont été révoquées" : "a été révoquée"} avec succès.`,
|
||||
});
|
||||
refetch();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
toast.error({
|
||||
title: "Erreur",
|
||||
description: `Impossible de révoquer les clés: ${message}`,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-600/20 rounded-lg flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Gestion des Utilisateurs</h1>
|
||||
<p className="text-sm text-muted-foreground">Visualiser et gérer les comptes utilisateurs</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-2 text-xs text-destructive hover:underline"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-600/20 rounded-lg flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Gestion des Utilisateurs</h1>
|
||||
<p className="text-sm text-muted-foreground">Visualiser et gérer les comptes utilisateurs</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UserStats users={users} isLoading={isLoading} total={total} />
|
||||
|
||||
<UserTable
|
||||
users={users}
|
||||
isLoading={isLoading}
|
||||
onTierChange={handleTierChange}
|
||||
onRevokeKeys={handleRevokeKeys}
|
||||
isUpdating={isUpdating}
|
||||
isRevoking={isRevoking}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
frontend/src/app/admin/users/types.ts
Normal file
68
frontend/src/app/admin/users/types.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export interface PlanLimits {
|
||||
docs_per_month: number;
|
||||
max_pages_per_doc: number;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
plan: "free" | "starter" | "pro" | "business" | "enterprise";
|
||||
subscription_status: "active" | "suspended" | "pending" | "cancelled";
|
||||
docs_translated_this_month: number;
|
||||
pages_translated_this_month: number;
|
||||
extra_credits: number;
|
||||
created_at: string;
|
||||
plan_limits: PlanLimits;
|
||||
api_keys_count?: number;
|
||||
api_key_ids?: string[];
|
||||
}
|
||||
|
||||
export interface AdminUsersResponse {
|
||||
total: number;
|
||||
users: AdminUser[];
|
||||
}
|
||||
|
||||
export interface UpdateTierRequest {
|
||||
plan: "free" | "starter" | "pro" | "business" | "enterprise";
|
||||
}
|
||||
|
||||
export interface UpdateTierResponse {
|
||||
data: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
plan: string;
|
||||
tier: "free" | "pro";
|
||||
};
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RevokeApiKeyResponse {
|
||||
data: {
|
||||
id: string;
|
||||
revoked: boolean;
|
||||
revoked_at: string;
|
||||
owner_user_id: string;
|
||||
reason?: string;
|
||||
};
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type PlanType = "free" | "starter" | "pro" | "business" | "enterprise";
|
||||
|
||||
export const PLAN_LABELS: Record<PlanType, string> = {
|
||||
free: "Free",
|
||||
starter: "Starter",
|
||||
pro: "Pro",
|
||||
business: "Business",
|
||||
enterprise: "Enterprise",
|
||||
};
|
||||
|
||||
export const PLAN_TIERS: Record<PlanType, "free" | "pro"> = {
|
||||
free: "free",
|
||||
starter: "free",
|
||||
pro: "pro",
|
||||
business: "pro",
|
||||
enterprise: "pro",
|
||||
};
|
||||
92
frontend/src/app/admin/users/useAdminUsers.ts
Normal file
92
frontend/src/app/admin/users/useAdminUsers.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import type { AdminUsersResponse } from "./types";
|
||||
|
||||
export const ADMIN_TIMEOUT_MS = 15000;
|
||||
export const QUERY_KEY = ["admin", "users"];
|
||||
|
||||
async function fetchUsers(adminToken: string | null | undefined): Promise<AdminUsersResponse> {
|
||||
if (!adminToken) {
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), ADMIN_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/users`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error("UNAUTHORIZED");
|
||||
}
|
||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function useAdminUsers() {
|
||||
const { settings } = useTranslationStore();
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: () => fetchUsers(settings.adminToken),
|
||||
enabled: !!settings.adminToken,
|
||||
staleTime: 30000,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const getErrorMessage = (err: Error | null): string | null => {
|
||||
if (!err) return null;
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
AUTH_REQUIRED: "Veuillez vous connecter pour accéder aux utilisateurs",
|
||||
UNAUTHORIZED: "Session expirée. Veuillez vous reconnecter.",
|
||||
HTTP_ERROR_403: "Accès refusé. Droits administrateur requis.",
|
||||
HTTP_ERROR_404: "Service indisponible. Veuillez réessayer plus tard.",
|
||||
HTTP_ERROR_500: "Erreur serveur. Veuillez réessayer plus tard.",
|
||||
};
|
||||
|
||||
const code = err.message;
|
||||
if (errorMap[code]) {
|
||||
return errorMap[code];
|
||||
}
|
||||
|
||||
if (err.name === "AbortError") {
|
||||
return "Le serveur met trop de temps à répondre. Veuillez réessayer.";
|
||||
}
|
||||
|
||||
if (err.message.includes("fetch") || err.message.includes("network")) {
|
||||
return "Impossible de se connecter au serveur. Vérifiez votre connexion.";
|
||||
}
|
||||
|
||||
return "Une erreur inattendue s'est produite. Veuillez réessayer.";
|
||||
};
|
||||
|
||||
const errorMessage = error ? getErrorMessage(error as Error) : null;
|
||||
|
||||
return {
|
||||
data: data ?? null,
|
||||
users: data?.users ?? [],
|
||||
total: data?.total ?? 0,
|
||||
isLoading,
|
||||
error: errorMessage,
|
||||
refetch,
|
||||
queryKey: QUERY_KEY,
|
||||
};
|
||||
}
|
||||
100
frontend/src/app/admin/users/useRevokeApiKey.ts
Normal file
100
frontend/src/app/admin/users/useRevokeApiKey.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import type { RevokeApiKeyResponse } from "./types";
|
||||
import { QUERY_KEY, ADMIN_TIMEOUT_MS } from "./useAdminUsers";
|
||||
|
||||
async function revokeApiKey(
|
||||
keyId: string,
|
||||
reason: string | undefined,
|
||||
adminToken: string | null | undefined
|
||||
): Promise<RevokeApiKeyResponse> {
|
||||
if (!adminToken) {
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), ADMIN_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/api-keys/${keyId}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(reason ? { reason } : {}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error("UNAUTHORIZED");
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new Error("API_KEY_NOT_FOUND");
|
||||
}
|
||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function useRevokeApiKey() {
|
||||
const { settings } = useTranslationStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({
|
||||
keyId,
|
||||
reason,
|
||||
}: {
|
||||
keyId: string;
|
||||
reason?: string;
|
||||
}) => revokeApiKey(keyId, reason, settings.adminToken),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const getErrorMessage = (err: Error | null): string | null => {
|
||||
if (!err) return null;
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
AUTH_REQUIRED: "Veuillez vous connecter pour effectuer cette action",
|
||||
UNAUTHORIZED: "Session expirée. Veuillez vous reconnecter.",
|
||||
API_KEY_NOT_FOUND: "Clé API non trouvée ou déjà révoquée.",
|
||||
HTTP_ERROR_403: "Accès refusé. Droits administrateur requis.",
|
||||
HTTP_ERROR_500: "Erreur serveur. Veuillez réessayer plus tard.",
|
||||
};
|
||||
|
||||
const code = err.message;
|
||||
if (errorMap[code]) {
|
||||
return errorMap[code];
|
||||
}
|
||||
|
||||
if (err.name === "AbortError") {
|
||||
return "Le serveur met trop de temps à répondre. Veuillez réessayer.";
|
||||
}
|
||||
|
||||
return "Erreur lors de la révocation. Veuillez réessayer.";
|
||||
};
|
||||
|
||||
const errorMessage = mutation.error ? getErrorMessage(mutation.error as Error) : null;
|
||||
|
||||
return {
|
||||
isRevoking: mutation.isPending,
|
||||
result: mutation.data ?? null,
|
||||
error: errorMessage,
|
||||
revokeKey: mutation.mutateAsync,
|
||||
reset: mutation.reset,
|
||||
};
|
||||
}
|
||||
96
frontend/src/app/admin/users/useUpdateUserTier.ts
Normal file
96
frontend/src/app/admin/users/useUpdateUserTier.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import type { UpdateTierRequest, UpdateTierResponse, PlanType } from "./types";
|
||||
import { QUERY_KEY, ADMIN_TIMEOUT_MS } from "./useAdminUsers";
|
||||
|
||||
async function updateUserTier(
|
||||
userId: string,
|
||||
plan: PlanType,
|
||||
adminToken: string | null | undefined
|
||||
): Promise<UpdateTierResponse> {
|
||||
if (!adminToken) {
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), ADMIN_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/users/${userId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ plan } as UpdateTierRequest),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error("UNAUTHORIZED");
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new Error("USER_NOT_FOUND");
|
||||
}
|
||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function useUpdateUserTier() {
|
||||
const { settings } = useTranslationStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ userId, plan }: { userId: string; plan: PlanType }) =>
|
||||
updateUserTier(userId, plan, settings.adminToken),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const getErrorMessage = (err: Error | null): string | null => {
|
||||
if (!err) return null;
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
AUTH_REQUIRED: "Veuillez vous connecter pour effectuer cette action",
|
||||
UNAUTHORIZED: "Session expirée. Veuillez vous reconnecter.",
|
||||
USER_NOT_FOUND: "Utilisateur non trouvé.",
|
||||
HTTP_ERROR_403: "Accès refusé. Droits administrateur requis.",
|
||||
HTTP_ERROR_400: "Plan invalide. Veuillez réessayer.",
|
||||
HTTP_ERROR_500: "Erreur serveur. Veuillez réessayer plus tard.",
|
||||
};
|
||||
|
||||
const code = err.message;
|
||||
if (errorMap[code]) {
|
||||
return errorMap[code];
|
||||
}
|
||||
|
||||
if (err.name === "AbortError") {
|
||||
return "Le serveur met trop de temps à répondre. Veuillez réessayer.";
|
||||
}
|
||||
|
||||
return "Erreur lors de la mise à jour. Veuillez réessayer.";
|
||||
};
|
||||
|
||||
const errorMessage = mutation.error ? getErrorMessage(mutation.error as Error) : null;
|
||||
|
||||
return {
|
||||
isUpdating: mutation.isPending,
|
||||
result: mutation.data ?? null,
|
||||
error: errorMessage,
|
||||
updateTier: mutation.mutateAsync,
|
||||
reset: mutation.reset,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user