SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
2 h agolast push
JavaScript 56.7% TypeScript 42.6%
10.5 KB · 275 lines tsx
Raw Blame History
1"use client";23import { useState, useEffect } from "react";4import { motion } from "framer-motion";5import {6  Clock,7  Camera,8  RotateCcw,9  Trash2,10  CheckCircle,11  XCircle,12  Loader2,13  AlertTriangle,14  CalendarDays,15} from "lucide-react";16import Button from "../ui/Button";17import Modal from "../ui/Modal";18import { EmptyState } from "../ui/PageHeader";19import { formatFileSize } from "@/lib/utils";20import toast from "react-hot-toast";2122interface SnapshotData {23  id: string;24  label: string;25  snapshotDate: string;26  path: string;27  size: number;28  status: string;29  error: string | null;30  createdAt: string;31}3233function StatusIcon({ status }: { status: string }) {34  if (status === "completed") return <CheckCircle className="w-4 h-4 text-emerald-500 flex-shrink-0" />;35  if (status === "failed") return <XCircle className="w-4 h-4 text-rose-500 flex-shrink-0" />;36  return <Loader2 className="w-4 h-4 text-blue-400 animate-spin flex-shrink-0" />;37}3839function isToday(dateStr: string): boolean {40  return new Date().toISOString().split("T")[0] === dateStr;41}4243export default function SnapshotPanel() {44  const [snapshots, setSnapshots] = useState<SnapshotData[]>([]);45  const [loading, setLoading] = useState(true);46  const [creating, setCreating] = useState(false);47  const [restoringId, setRestoringId] = useState<string | null>(null);48  const [deletingId, setDeletingId] = useState<string | null>(null);49  const [confirmRestore, setConfirmRestore] = useState<SnapshotData | null>(null);5051  const fetchSnapshots = async () => {52    const res = await fetch("/api/snapshots");53    if (res.ok) setSnapshots(await res.json());54    setLoading(false);55  };5657  useEffect(() => {58    fetchSnapshots();59  }, []);6061  const handleCreate = async () => {62    setCreating(true);63    try {64      const res = await fetch("/api/snapshots", { method: "POST" });65      if (res.ok) {66        toast.success("Snapshot créé");67        await fetchSnapshots();68      } else {69        const data = await res.json();70        toast.error(data.error || "Échec du snapshot");71      }72    } catch {73      toast.error("Erreur réseau");74    }75    setCreating(false);76  };7778  const handleRestore = async (snapshot: SnapshotData) => {79    setConfirmRestore(null);80    setRestoringId(snapshot.id);81    try {82      const res = await fetch(`/api/snapshots/${snapshot.id}/restore`, { method: "POST" });83      if (res.ok) {84        toast.success(`Restauré au ${snapshot.label}`);85        await fetchSnapshots();86      } else {87        const data = await res.json();88        toast.error(data.error || "Échec de la restauration");89      }90    } catch {91      toast.error("Erreur réseau");92    }93    setRestoringId(null);94  };9596  const handleDelete = async (id: string) => {97    setDeletingId(id);98    try {99      const res = await fetch(`/api/snapshots/${id}`, { method: "DELETE" });100      if (res.ok) {101        toast.success("Snapshot supprimé");102        setSnapshots((prev) => prev.filter((s) => s.id !== id));103      } else {104        toast.error("Échec de la suppression");105      }106    } catch {107      toast.error("Erreur réseau");108    }109    setDeletingId(null);110  };111112  const todayHasSnapshot = snapshots.some(113    (s) => isToday(s.snapshotDate) && s.status === "completed"114  );115116  return (117    <div className="space-y-5">118      {/* Header */}119      <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">120        <div>121          <h2 className="text-[15px] font-semibold text-gray-900 dark:text-white flex items-center gap-2">122            <Clock className="w-[18px] h-[18px] text-blue-500" />123            Snapshots quotidiens124          </h2>125          <p className="text-[13px] text-gray-500 dark:text-gray-400 mt-0.5">126            {snapshots.length} instantané{snapshots.length !== 1 ? "s" : ""} · 7 jours conservés · restauration en un clic127          </p>128        </div>129        <Button130          size="sm"131          variant="accent"132          onClick={handleCreate}133          loading={creating}134        >135          <Camera className="w-4 h-4" />136          {todayHasSnapshot ? "Re-snapshot maintenant" : "Snapshot maintenant"}137        </Button>138      </div>139140      {/* Auto info banner */}141      <div className="flex items-start gap-2.5 p-3 rounded-2xl bg-blue-500/[0.07] text-[13px] text-blue-800 dark:text-blue-200">142        <CalendarDays className="w-4 h-4 mt-0.5 flex-shrink-0" />143        <span>144          Un snapshot automatique est créé chaque jour au premier démarrage du serveur.145          Les plus anciens sont supprimés automatiquement.146        </span>147      </div>148149      {/* Snapshot list */}150      {loading ? (151        <div className="space-y-2">152          {[0, 1, 2].map((i) => (153            <div key={i} className="rounded-2xl border border-line dark:border-white/[0.07] p-3 pl-12 flex items-center gap-3 animate-pulse">154              <div className="flex-1 space-y-2"><div className="h-3 w-1/3 rounded bg-gray-200 dark:bg-white/10" /><div className="h-2 w-1/4 rounded bg-gray-200 dark:bg-white/10" /></div>155            </div>156          ))}157        </div>158      ) : snapshots.length === 0 ? (159        <EmptyState icon={Camera} title="Aucun snapshot" description="Créez votre premier snapshot manuellement ou attendez le démarrage automatique quotidien." />160      ) : (161        <div className="relative space-y-2">162          {/* Vertical timeline line */}163          <div className="absolute left-[22px] top-4 bottom-4 w-px bg-gray-200 dark:bg-white/10 pointer-events-none" />164165          {snapshots.map((snapshot, i) => {166            const today = isToday(snapshot.snapshotDate);167            return (168              <motion.div169                key={snapshot.id}170                initial={{ opacity: 0, x: -8 }}171                animate={{ opacity: 1, x: 0 }}172                transition={{ delay: i * 0.04 }}173                className={`relative flex flex-col sm:flex-row sm:items-center gap-3 p-3 pl-12 rounded-2xl border transition-colors ${174                  today175                    ? "bg-blue-500/[0.07] border-blue-500/25"176                    : "bg-surface dark:bg-white/[0.03] border-line dark:border-white/[0.07] hover:bg-gray-50 dark:hover:bg-white/[0.05]"177                }`}178              >179                {/* Timeline dot */}180                <div181                  className={`absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 flex items-center justify-center ${182                    snapshot.status === "completed"183                      ? today184                        ? "bg-blue-500 border-blue-300 shadow-glow"185                        : "bg-emerald-500 border-emerald-300"186                      : snapshot.status === "failed"187                      ? "bg-rose-500 border-rose-300"188                      : "bg-gray-400 border-gray-300"189                  }`}190                />191192                {/* Info */}193                <div className="flex-1 min-w-0">194                  <div className="flex items-center gap-2 flex-wrap">195                    <p className="text-sm font-medium text-gray-900 dark:text-white capitalize truncate">196                      {snapshot.label}197                    </p>198                    {today && (199                      <span className="text-[10px] font-semibold px-1.5 py-0.5 bg-blue-500/15 text-blue-700 dark:text-blue-300 rounded-full uppercase tracking-wide">200                        Aujourd&apos;hui201                      </span>202                    )}203                    <StatusIcon status={snapshot.status} />204                  </div>205                  <p className="text-xs text-gray-500 mt-0.5">206                    {snapshot.status === "completed"207                      ? formatFileSize(snapshot.size)208                      : snapshot.status === "failed"209                      ? snapshot.error || "Erreur inconnue"210                      : "En cours…"}211                    {snapshot.status === "completed" && (212                      <> · {new Date(snapshot.createdAt).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}</>213                    )}214                  </p>215                </div>216217                {/* Actions */}218                {snapshot.status === "completed" && (219                  <div className="flex gap-1 pl-0 sm:pl-0">220                    <button221                      onClick={() => setConfirmRestore(snapshot)}222                      disabled={restoringId === snapshot.id}223                      title="Restaurer"224                      className="w-10 h-10 flex items-center justify-center text-gray-400 hover:text-blue-600 dark:hover:text-blue-300 rounded-xl hover:bg-blue-500/10 transition-colors disabled:opacity-50"225                    >226                      {restoringId === snapshot.id ? (227                        <Loader2 className="w-4 h-4 animate-spin" />228                      ) : (229                        <RotateCcw className="w-4 h-4" />230                      )}231                    </button>232                    <button233                      onClick={() => handleDelete(snapshot.id)}234                      disabled={deletingId === snapshot.id}235                      title="Supprimer"236                      className="w-10 h-10 flex items-center justify-center text-gray-400 hover:text-rose-500 rounded-xl hover:bg-rose-500/10 transition-colors disabled:opacity-50"237                    >238                      {deletingId === snapshot.id ? (239                        <Loader2 className="w-4 h-4 animate-spin" />240                      ) : (241                        <Trash2 className="w-4 h-4" />242                      )}243                    </button>244                  </div>245                )}246              </motion.div>247            );248          })}249        </div>250      )}251252      {/* Restore confirmation modal */}253      <Modal254        isOpen={!!confirmRestore}255        onClose={() => setConfirmRestore(null)}256        title="Restaurer ce snapshot ?"257        description={confirmRestore?.label}258        footer={259          <>260            <Button variant="ghost" onClick={() => setConfirmRestore(null)}>Annuler</Button>261            <Button onClick={() => confirmRestore && handleRestore(confirmRestore)} className="!bg-amber-500 hover:!bg-amber-600 !text-white dark:!bg-amber-500 dark:!text-white">262              <RotateCcw className="w-4 h-4" /> Oui, restaurer263            </Button>264          </>265        }266      >267        <div className="flex items-start gap-3 p-3 rounded-2xl bg-amber-500/[0.08] text-[13.5px] text-amber-800 dark:text-amber-300">268          <AlertTriangle className="w-5 h-5 shrink-0 mt-0.5" />269          <p>Tous vos fichiers et la base de données seront remplacés par ceux du snapshot sélectionné. <strong>Cette action est irréversible.</strong></p>270        </div>271      </Modal>272    </div>273  );274}275