| "use client";
|
|
|
| import { useState, useEffect, useCallback } from "react";
|
| import { Card } from "@/shared/components";
|
| import { useTranslations } from "next-intl";
|
|
|
| interface ModelsDevStatus {
|
| enabled: boolean;
|
| lastSync: string | null;
|
| lastSyncModelCount: number;
|
| lastSyncCapabilityCount: number;
|
| nextSync: string | null;
|
| intervalMs: number;
|
| providerCount: number;
|
| modelCount: number;
|
| capabilityCount: number;
|
| }
|
|
|
| interface SyncResult {
|
| success: boolean;
|
| modelCount: number;
|
| providerCount: number;
|
| capabilityCount: number;
|
| error?: string;
|
| }
|
|
|
| export default function ModelsDevSyncTab() {
|
| const t = useTranslations("settings");
|
| const [status, setStatus] = useState<ModelsDevStatus | null>(null);
|
| const [loading, setLoading] = useState(true);
|
| const [syncing, setSyncing] = useState(false);
|
| const [saving, setSaving] = useState(false);
|
| const [enabled, setEnabled] = useState(false);
|
| const [intervalHours, setIntervalHours] = useState(24);
|
| const [draftIntervalHours, setDraftIntervalHours] = useState(24);
|
| const [feedback, setFeedback] = useState<{ type: "success" | "error"; message: string } | null>(
|
| null
|
| );
|
|
|
| const fetchStatus = useCallback(async () => {
|
| try {
|
| const res = await fetch("/api/settings/models-dev?action=status");
|
| if (res.ok) {
|
| const data = await res.json();
|
| setStatus(data);
|
| }
|
| } catch {
|
|
|
| }
|
| setLoading(false);
|
| }, []);
|
|
|
| useEffect(() => {
|
| Promise.all([fetchStatus(), fetch("/api/settings").then((r) => (r.ok ? r.json() : null))])
|
| .then(([, settingsData]) => {
|
| if (settingsData) {
|
| setEnabled(settingsData.modelsDevSyncEnabled === true);
|
| const intervalMs = settingsData.modelsDevSyncInterval || 86400000;
|
| const hours = Math.round(intervalMs / 3600000);
|
| setIntervalHours(hours);
|
| setDraftIntervalHours(hours);
|
| }
|
| })
|
| .catch((err) => {
|
| console.error("Failed to fetch models.dev settings:", err);
|
| setFeedback({ type: "error", message: "Failed to load settings" });
|
| });
|
| }, [fetchStatus]);
|
|
|
| const triggerSync = async () => {
|
| setSyncing(true);
|
| setFeedback(null);
|
| try {
|
| const res = await fetch("/api/settings/models-dev", {
|
| method: "POST",
|
| headers: { "Content-Type": "application/json" },
|
| body: JSON.stringify({ action: "sync" }),
|
| });
|
| if (res.ok) {
|
| const result: SyncResult = await res.json();
|
| if (result.success) {
|
| setFeedback({
|
| type: "success",
|
| message: `Synced ${result.modelCount.toLocaleString()} pricing entries, ${result.capabilityCount.toLocaleString()} capabilities`,
|
| });
|
| fetchStatus();
|
| } else {
|
| setFeedback({ type: "error", message: result.error || "Sync failed" });
|
| }
|
| } else {
|
| setFeedback({ type: "error", message: "Sync request failed" });
|
| }
|
| } catch {
|
| setFeedback({ type: "error", message: "Network error" });
|
| } finally {
|
| setSyncing(false);
|
| setTimeout(() => setFeedback(null), 5000);
|
| }
|
| };
|
|
|
| const toggleEnabled = async () => {
|
| const newVal = !enabled;
|
| setEnabled(newVal);
|
| setSaving(true);
|
| try {
|
| const res = await fetch("/api/settings", {
|
| method: "PATCH",
|
| headers: { "Content-Type": "application/json" },
|
| body: JSON.stringify({ modelsDevSyncEnabled: newVal }),
|
| });
|
| if (!res.ok) {
|
| setEnabled(!newVal);
|
| setFeedback({ type: "error", message: t("enableSyncError") || "Failed to update" });
|
| } else {
|
| setFeedback({ type: "success", message: "Settings saved" });
|
| }
|
| } catch {
|
| setEnabled(!newVal);
|
| setFeedback({ type: "error", message: "Network error" });
|
| } finally {
|
| setSaving(false);
|
| setTimeout(() => setFeedback(null), 3000);
|
| }
|
| };
|
|
|
| const updateInterval = async (hours: number) => {
|
| const oldInterval = intervalHours;
|
| setIntervalHours(hours);
|
| setDraftIntervalHours(hours);
|
| try {
|
| const res = await fetch("/api/settings", {
|
| method: "PATCH",
|
| headers: { "Content-Type": "application/json" },
|
| body: JSON.stringify({ modelsDevSyncInterval: hours * 3600000 }),
|
| });
|
| if (!res.ok) {
|
| setIntervalHours(oldInterval);
|
| setDraftIntervalHours(oldInterval);
|
| setFeedback({ type: "error", message: t("enableSyncError") || "Failed to update" });
|
| } else {
|
| setFeedback({ type: "success", message: "Interval updated" });
|
| }
|
| } catch {
|
| setIntervalHours(oldInterval);
|
| setDraftIntervalHours(oldInterval);
|
| setFeedback({ type: "error", message: "Network error" });
|
| } finally {
|
| setTimeout(() => setFeedback(null), 3000);
|
| }
|
| };
|
|
|
| if (loading) {
|
| return (
|
| <Card>
|
| <div className="flex items-center gap-3">
|
| <div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
|
| <span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
| sync
|
| </span>
|
| </div>
|
| <div>
|
| <h3 className="text-lg font-semibold">{t("modelsDevTitle")}</h3>
|
| <p className="text-sm text-text-muted">{t("modelsDevDesc")}</p>
|
| </div>
|
| </div>
|
| <div className="mt-4 text-sm text-text-muted">{t("loading")}...</div>
|
| </Card>
|
| );
|
| }
|
|
|
| const formatLastSync = (iso: string | null) => {
|
| if (!iso) return t("never");
|
| const d = new Date(iso);
|
| const now = new Date();
|
| const diffMs = now.getTime() - d.getTime();
|
| const diffMin = Math.floor(diffMs / 60000);
|
| if (diffMin < 1) return t("justNow");
|
| if (diffMin < 60) return `${diffMin}m ago`;
|
| const diffHr = Math.floor(diffMin / 60);
|
| if (diffHr < 24) return `${diffHr}h ago`;
|
| return d.toLocaleDateString();
|
| };
|
|
|
| return (
|
| <div className="flex flex-col gap-6">
|
| {/* Main sync card */}
|
| <Card>
|
| <div className="flex items-center gap-3 mb-5">
|
| <div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
|
| <span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
| database
|
| </span>
|
| </div>
|
| <div>
|
| <h3 className="text-lg font-semibold">{t("modelsDevTitle")}</h3>
|
| <p className="text-sm text-text-muted">{t("modelsDevDesc")}</p>
|
| </div>
|
| {feedback && (
|
| <span
|
| className={`ml-auto text-xs font-medium flex items-center gap-1 ${
|
| feedback.type === "success" ? "text-emerald-500" : "text-red-500"
|
| }`}
|
| >
|
| <span className="material-symbols-outlined text-[14px]">
|
| {feedback.type === "success" ? "check_circle" : "error"}
|
| </span>{" "}
|
| {feedback.message}
|
| </span>
|
| )}
|
| </div>
|
|
|
| {/* Enable toggle */}
|
| <div className="flex items-center justify-between p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
|
| <div>
|
| <p className="text-sm font-medium">{t("modelsDevEnabled")}</p>
|
| <p className="text-xs text-text-muted mt-0.5">{t("modelsDevEnabledDesc")}</p>
|
| </div>
|
| <button
|
| onClick={toggleEnabled}
|
| disabled={saving}
|
| className={`relative w-11 h-6 rounded-full transition-colors ${
|
| enabled ? "bg-blue-500" : "bg-border"
|
| }`}
|
| role="switch"
|
| aria-checked={enabled}
|
| >
|
| <span
|
| className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
| enabled ? "translate-x-5" : "translate-x-0"
|
| }`}
|
| />
|
| </button>
|
| </div>
|
|
|
| {/* Sync interval */}
|
| {enabled && (
|
| <div className="p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
|
| <div className="flex items-center justify-between mb-3">
|
| <p className="text-sm font-medium">{t("modelsDevInterval")}</p>
|
| <span className="text-sm font-mono tabular-nums text-blue-400">
|
| {draftIntervalHours}h
|
| </span>
|
| </div>
|
| <input
|
| type="range"
|
| min="1"
|
| max="168"
|
| step="1"
|
| value={draftIntervalHours}
|
| onChange={(e) => setDraftIntervalHours(parseInt(e.target.value))}
|
| onMouseUp={(e) => updateInterval(parseInt((e.target as HTMLInputElement).value))}
|
| onBlur={(e) => updateInterval(parseInt(e.target.value))}
|
| className="w-full accent-blue-500"
|
| />
|
| <div className="flex justify-between text-xs text-text-muted mt-1">
|
| <span>1h</span>
|
| <span>6h</span>
|
| <span>24h</span>
|
| <span>7d</span>
|
| </div>
|
| </div>
|
| )}
|
|
|
| {/* Manual sync button */}
|
| <div className="flex items-center gap-3">
|
| <button
|
| onClick={triggerSync}
|
| disabled={syncing}
|
| className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 transition-colors flex items-center gap-2"
|
| >
|
| <span
|
| className={`material-symbols-outlined text-[16px] ${syncing ? "animate-spin" : ""}`}
|
| >
|
| sync
|
| </span>
|
| {syncing ? t("syncing") : t("syncNow")}
|
| </button>
|
| {status?.lastSync && (
|
| <span className="text-xs text-text-muted">
|
| {t("lastSync")}: {formatLastSync(status.lastSync)}
|
| </span>
|
| )}
|
| </div>
|
| </Card>
|
|
|
| {/* Stats card */}
|
| {status && (status.providerCount > 0 || status.modelCount > 0) && (
|
| <Card>
|
| <div className="flex items-center gap-3 mb-5">
|
| <div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
|
| <span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
| bar_chart
|
| </span>
|
| </div>
|
| <div>
|
| <h3 className="text-lg font-semibold">{t("modelsDevStats")}</h3>
|
| <p className="text-sm text-text-muted">{t("modelsDevStatsDesc")}</p>
|
| </div>
|
| </div>
|
|
|
| <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
| <div className="p-4 rounded-lg bg-surface/30 border border-border/30 text-center">
|
| <p className="text-2xl font-bold text-blue-400">
|
| {status.providerCount.toLocaleString()}
|
| </p>
|
| <p className="text-xs text-text-muted mt-1">{t("providers")}</p>
|
| </div>
|
| <div className="p-4 rounded-lg bg-surface/30 border border-border/30 text-center">
|
| <p className="text-2xl font-bold text-emerald-400">
|
| {status.modelCount.toLocaleString()}
|
| </p>
|
| <p className="text-xs text-text-muted mt-1">{t("modelsWithPricing")}</p>
|
| </div>
|
| <div className="p-4 rounded-lg bg-surface/30 border border-border/30 text-center">
|
| <p className="text-2xl font-bold text-violet-400">
|
| {status.capabilityCount.toLocaleString()}
|
| </p>
|
| <p className="text-xs text-text-muted mt-1">{t("capabilities")}</p>
|
| </div>
|
| <div className="p-4 rounded-lg bg-surface/30 border border-border/30 text-center">
|
| <p className="text-2xl font-bold text-amber-400">
|
| {status.lastSyncModelCount > 0 ? status.lastSyncModelCount.toLocaleString() : "—"}
|
| </p>
|
| <p className="text-xs text-text-muted mt-1">{t("lastSyncCount")}</p>
|
| </div>
|
| </div>
|
|
|
| {status.lastSync && (
|
| <div className="mt-4 text-xs text-text-muted text-center">
|
| {t("lastSyncFull")}: {new Date(status.lastSync).toLocaleString()}
|
| </div>
|
| )}
|
| </Card>
|
| )}
|
|
|
| {/* Info card */}
|
| <Card>
|
| <div className="flex items-center gap-3 mb-4">
|
| <div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
|
| <span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
| info
|
| </span>
|
| </div>
|
| <div>
|
| <h3 className="text-lg font-semibold">{t("modelsDevInfo")}</h3>
|
| </div>
|
| </div>
|
| <div className="text-sm text-text-muted space-y-2">
|
| <p>{t("modelsDevInfoDesc")}</p>
|
| <p>{t("modelsDevInfoResolution")}</p>
|
| <p className="text-xs font-mono bg-surface/50 p-2 rounded">{t("modelsDevInfoOrder")}</p>
|
| </div>
|
| </Card>
|
| </div>
|
| );
|
| }
|
|
|