diff --git a/.gitignore b/.gitignore index 69006c5..bf95a87 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ dist/* node_modules/ node_modules/* -.DS_Store \ No newline at end of file +.DS_Store + +CLAUDE.md \ No newline at end of file diff --git a/.releaserc.json b/.releaserc.json index d54a55f..4a5f156 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -15,13 +15,6 @@ { "assets": [{ "path": "dist.zip", "label": "Frontend Distribution" }] } - ], - [ - "@semantic-release/git", - { - "assets": ["CHANGELOG.md"], - "message": "chore(release): ${nextRelease.version}" - } ] ] } diff --git a/src/components/CreateBackupModal.tsx b/src/components/CreateBackupModal.tsx index 22263a8..b352960 100644 --- a/src/components/CreateBackupModal.tsx +++ b/src/components/CreateBackupModal.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from "react"; import { BarLoader } from "react-spinners"; import { api, type Index } from "../api/client"; import { useAuth } from "../context/AuthContext"; -import { useNotification } from "../context/NotificationContext"; import { useNavigate } from "react-router"; import Notification from "./Notification"; @@ -23,7 +22,6 @@ export default function CreateBackupModal(params: CreateBackupParams) { const [loadingIndexes, setLoadingIndexes] = useState(false) const { token, handleUnauthorized } = useAuth(); - const { showNotification } = useNotification(); const navigate = useNavigate(); useEffect(() => { @@ -75,8 +73,7 @@ export default function CreateBackupModal(params: CreateBackupParams) { } params.closeBackupModal() - showNotification('info', `Backup "${backupName.trim()}" getting created for index "${backupIndexName}"`) - navigate("/backups#jobs") + navigate("/backups") } catch (err) { setBackupError(err instanceof Error ? err.message : 'Failed to create backup') } finally { diff --git a/src/config.ts b/src/config.ts index 8859a22..01a35b7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1 +1 @@ -export const APP_VERSION = "0.1.0" +export const APP_VERSION = "beta" diff --git a/src/pages/BackupsPage.tsx b/src/pages/BackupsPage.tsx index 3fa558c..f83b44b 100644 --- a/src/pages/BackupsPage.tsx +++ b/src/pages/BackupsPage.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState, useCallback } from 'react' -import { GoPlus, GoTrash, GoSync, GoDownload, GoUpload, GoCheck, GoAlert, GoHourglass } from 'react-icons/go' +import { useEffect, useState, useCallback, useRef } from 'react' +import { GoPlus, GoTrash, GoSync, GoDownload, GoUpload, GoX } from 'react-icons/go' import { useAuth } from '../context/AuthContext' import { useNotification } from '../context/NotificationContext' import CreateBackupModal from '../components/CreateBackupModal' @@ -10,44 +10,38 @@ interface Backup { name: string } -interface BackupJob { - job_id: string - index_id: string - backup_name: string - status: 'in_progress' | 'completed' | 'failed' - error?: string - started_at: number - completed_at?: number +interface ActiveBackup { + active: boolean + backup_name?: string + index_id?: string } -type Tab = 'backups' | 'jobs' - -const STATUS_CONFIG = { - completed: { - label: 'Completed', - icon: GoCheck, - dot: 'bg-green-500', - text: 'text-green-700 dark:text-green-400', - }, - in_progress: { - label: 'In Progress', - icon: GoHourglass, - dot: 'bg-amber-500 animate-pulse', - text: 'text-amber-700 dark:text-amber-400', - }, - failed: { - label: 'Failed', - icon: GoAlert, - dot: 'bg-red-500', - text: 'text-red-700 dark:text-red-400', - }, -} as const +interface BackupInfo { + original_index: string + params: { + M: number + checksum: number + dim: number + ef_construction: number + quant_level: number + space_type: string + sparse_dim: number + total_elements: number + } + size_mb: number + timestamp: number +} export default function BackupsPage() { const [backups, setBackups] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [activeTab, setActiveTab] = useState('backups') + + // Active backup state + const [activeBackup, setActiveBackup] = useState(null) + const [isPolling, setIsPolling] = useState(false) + const prevActiveRef = useRef(null) + const activeBackupNameRef = useRef(null) // Create backup modal state const [showCreateModal, setShowCreateModal] = useState(false) @@ -67,59 +61,17 @@ export default function BackupsPage() { const [deleteBackupName, setDeleteBackupName] = useState('') const [deleting, setDeleting] = useState(false) - // Backup jobs state - const [jobs, setJobs] = useState([]) + // Info modal state + const [showInfoModal, setShowInfoModal] = useState(false) + const [infoBackupName, setInfoBackupName] = useState('') + const [backupInfo, setBackupInfo] = useState(null) + const [loadingInfo, setLoadingInfo] = useState(false) + const [infoError, setInfoError] = useState(null) - // Authentication operations const { token, handleUnauthorized } = useAuth() const { notification, showNotification, clearNotification } = useNotification() - const loadJobs = useCallback(async () => { - try { - const response = await fetch('/api/v1/backups/jobs', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - ...(token && { Authorization: token }) - } - }) - if (!response.ok) { - if (response.status === 401) { - handleUnauthorized() - } - return - } - const data = await response.json() - const allJobs: BackupJob[] = Array.isArray(data.jobs) ? data.jobs : [] - // Sort by started_at descending (latest first) - allJobs.sort((a, b) => b.started_at - a.started_at) - setJobs(allJobs) - } catch { - // Silently fail - jobs are supplementary info - } - }, [token, handleUnauthorized]) - - useEffect(() => { - if (window.location.hash === '#jobs') { - setActiveTab('jobs') - } - loadBackups() - loadJobs() - }, []) - - // Poll for job updates when there are in-progress jobs - useEffect(() => { - const hasInProgress = jobs.some(j => j.status === 'in_progress') - if (!hasInProgress) return - - const interval = setInterval(() => { - loadJobs() - loadBackups() - }, 5000) - return () => clearInterval(interval) - }, [jobs, loadJobs]) - - const loadBackups = async () => { + const loadBackups = useCallback(async () => { setLoading(true) try { const response = await fetch('/api/v1/backups', { @@ -132,12 +84,11 @@ export default function BackupsPage() { if (!response.ok) { if (response.status === 401) { handleUnauthorized() - throw new Error("Authentication Token Required.") + throw new Error('Authentication Token Required.') } throw new Error('Failed to fetch backups.') } const data = await response.json() - // API returns array of backup names as strings const backupList = Array.isArray(data) ? data.map((name: string) => ({ name })) : [] setBackups(backupList) setError(null) @@ -146,22 +97,60 @@ export default function BackupsPage() { } finally { setLoading(false) } - } + }, [token, handleUnauthorized]) - const openCreateModal = () => { - setShowCreateModal(true) - } + const loadActiveBackup = useCallback(async () => { + try { + const response = await fetch('/api/v1/backups/active', { + headers: { + 'Content-Type': 'application/json', + ...(token && { Authorization: token }) + } + }) + if (!response.ok) return + const data: ActiveBackup = await response.json() + if (data.active && data.backup_name) { + activeBackupNameRef.current = data.backup_name + } + setActiveBackup(data) + setIsPolling(data.active) + } catch { + // silently fail + } + }, [token]) + + useEffect(() => { + loadBackups() + loadActiveBackup() + }, []) + + // Reload backups and notify when active backup completes + useEffect(() => { + if (prevActiveRef.current === true && activeBackup?.active === false) { + loadBackups() + const name = activeBackupNameRef.current + showNotification('success', name ? `Backup "${name}" created successfully` : 'Backup created successfully') + activeBackupNameRef.current = null + } + prevActiveRef.current = activeBackup?.active ?? null + }, [activeBackup, loadBackups, showNotification]) + + // Poll for active backup updates every 3s + useEffect(() => { + if (!isPolling) return + const interval = setInterval(loadActiveBackup, 3000) + return () => clearInterval(interval) + }, [isPolling, loadActiveBackup]) + + const openCreateModal = () => setShowCreateModal(true) const closeCreateModal = () => { setShowCreateModal(false) - loadBackups() - loadJobs() - setActiveTab('jobs') + setIsPolling(true) + loadActiveBackup() } - const openUploadModal = () => { - setShowUploadModal(true) - } + const openUploadModal = () => setShowUploadModal(true) const closeUploadModal = () => { setShowUploadModal(false) @@ -200,7 +189,7 @@ export default function BackupsPage() { if (!response.ok) { if (response.status === 401) { handleUnauthorized() - throw new Error("Authentication Token Required.") + throw new Error('Authentication Token Required.') } const errorData = await response.json().catch(() => ({})) throw new Error(errorData.error || 'Failed to restore backup') @@ -238,7 +227,7 @@ export default function BackupsPage() { if (!response.ok) { if (response.status === 401) { handleUnauthorized() - throw new Error("Authentication Token Required.") + throw new Error('Authentication Token Required.') } const errorData = await response.json().catch(() => ({})) throw new Error(errorData.error || 'Failed to delete backup') @@ -255,21 +244,53 @@ export default function BackupsPage() { } } + const openInfoModal = async (backupName: string) => { + setInfoBackupName(backupName) + setBackupInfo(null) + setInfoError(null) + setLoadingInfo(true) + setShowInfoModal(true) + try { + const response = await fetch(`/api/v1/backups/${encodeURIComponent(backupName)}/info`, { + headers: { + 'Content-Type': 'application/json', + ...(token && { Authorization: token }) + } + }) + if (!response.ok) { + if (response.status === 401) { + handleUnauthorized() + throw new Error('Authentication Token Required.') + } + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || 'Failed to fetch backup info') + } + const data: BackupInfo = await response.json() + setBackupInfo(data) + } catch (err) { + setInfoError(err instanceof Error ? err.message : 'Failed to load backup info') + } finally { + setLoadingInfo(false) + } + } + + const closeInfoModal = () => { + setShowInfoModal(false) + setInfoBackupName('') + setBackupInfo(null) + setInfoError(null) + } + const handleDownloadBackup = (backupName: string) => { let downloadUrl = `/api/v1/backups/${encodeURIComponent(backupName)}/download` if (token) { downloadUrl += `?token=${encodeURIComponent(token)}` } - const iframe = document.createElement('iframe') iframe.style.display = 'none' iframe.src = downloadUrl document.body.appendChild(iframe) - - setTimeout(() => { - document.body.removeChild(iframe) - }, 60000) - + setTimeout(() => { document.body.removeChild(iframe) }, 60000) showNotification('success', `Downloading backup "${backupName}"`) } @@ -277,6 +298,7 @@ export default function BackupsPage() { return new Date(timestamp * 1000).toLocaleString('en-US', { month: 'short', day: 'numeric', + year: 'numeric', hour: '2-digit', minute: '2-digit', }) @@ -300,7 +322,9 @@ export default function BackupsPage() { + {/* Active Backup Banner */} + {activeBackup?.active && ( +
+ + + Creating backup{activeBackup.backup_name ? <> "{activeBackup.backup_name}" : ''}... + + In progress +
+ )} + + {/* Loading State */} + {loading && ( +
+
Loading backups...
+
+ )} + + {/* Empty State */} + {!loading && !error && backups.length === 0 && ( +
+
No backups found
- + )} - {/* ===== Backups Tab ===== */} - {activeTab === 'backups' && ( - <> - {/* Loading State */} - {loading && ( -
-
Loading backups...
-
- )} + {/* Backups List */} + {!loading && backups.length > 0 && ( +
+ + + + + + + + + {backups.map((backup) => ( + + + + + ))} + +
+ Backup Name + + Actions +
+ openInfoModal(backup.name)} className="text-sm font-medium text-slate-800 dark:text-slate-200 hover:text-blue-600 hover:underline cursor-pointer"> + {backup.name} + + +
+ {/* */} + + + +
+
+
+ )} - {/* Empty State */} - {!loading && !error && backups.length === 0 && ( -
-
No backups found
+ {/* Create Backup Modal */} + {showCreateModal && ( + + )} + + {/* Backup Info Modal */} + {showInfoModal && ( +
+
+
+

Backup Info

- )} - - {/* Backups List */} - {!loading && backups.length > 0 && ( -
- - - - - - - - - {backups.map((backup) => ( - - - - - ))} - -
- Backup Name - - Actions -
- - {backup.name} - - -
- - - -
-
-
- )} - - )} +

{infoBackupName}

- {/* ===== Jobs Tab ===== */} - {activeTab === 'jobs' && ( -
- {jobs.length === 0 ? ( -
-
No backup jobs yet
-
- ) : ( -
- {jobs.map((job) => { - const cfg = STATUS_CONFIG[job.status] - return ( -
- {/* Timestamp */} - - {formatDateTime(job.completed_at || job.started_at)} - -
-
- {/* Status dot + label */} - - - {cfg.label} - - - {/* Name */} - - {job.backup_name} - -
+ {loadingInfo && ( +
Loading...
+ )} + + {infoError && ( + + )} - {/* Error */} - {job.error && ( - - {job.error} - - )} + {backupInfo && ( +
+
+
+
Original Index
+
{backupInfo.original_index}
+
+
+
Created
+
{formatDateTime(backupInfo.timestamp)}
+
+
+
Size
+
{backupInfo.size_mb} MB
+
+
+
Space Type
+
{backupInfo.params.space_type}
+
+
+ +
+
Index Parameters
+
+
+ Dimensions + {backupInfo.params.dim}
+
+ Vectors + {backupInfo.params.total_elements} +
+
+ M + {backupInfo.params.M} +
+
+ ef_construction + {backupInfo.params.ef_construction} +
+
+ Quant Level + {backupInfo.params.quant_level} +
+ {backupInfo.params.sparse_dim > 0 && ( +
+ Sparse Dim + {backupInfo.params.sparse_dim} +
+ )}
- ) - })} +
+
+ )} + +
+
- )} +
)} - {/* Create Backup Modal */} - {showCreateModal && ( - - )} - {/* Restore Backup Modal */} {showRestoreModal && (
diff --git a/src/pages/TutorialsPage.tsx b/src/pages/TutorialsPage.tsx index bb4c770..6c8502c 100644 --- a/src/pages/TutorialsPage.tsx +++ b/src/pages/TutorialsPage.tsx @@ -287,7 +287,7 @@ export default function TutorialsPage() { { id: 'create-backup', title: 'Create Backup', - description: 'Create a backup of an index. Backups can be restored later to recover data.', + description: 'Asynchronously create a backup of an index. The backup runs in the background — check /api/v1/backups/active to monitor progress.', endpoint: 'POST /api/v1/index/:indexName/backup', method: 'POST', requiresIndex: true, @@ -400,8 +400,8 @@ export default function TutorialsPage() { { id: 'download-backup', title: 'Download Backup', - description: 'Download a backup as a .tar file. A SHA-1 key is generated from the backup name for authorization.', - endpoint: 'GET /api/v1/backups/:backupName/download?key=:key', + description: 'Download a backup as a .tar file.', + endpoint: 'GET /api/v1/backups/:backupName/download', method: 'GET', requiresPayload: true, defaultPayload: JSON.stringify({ backup_name: "my_backup" }, null, 2), @@ -409,12 +409,10 @@ export default function TutorialsPage() { if (!payload) return { success: false, result: 'Payload required' } try { const { backup_name } = JSON.parse(payload) - const input = backup_name - const data = new TextEncoder().encode(input) - const hashBuffer = await crypto.subtle.digest('SHA-1', data) - const hashArray = Array.from(new Uint8Array(hashBuffer)) - const key = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') - const downloadUrl = `/api/v1/backups/${encodeURIComponent(backup_name)}/download?key=${key}` + let downloadUrl = `/api/v1/backups/${encodeURIComponent(backup_name)}/download` + if (token) { + downloadUrl += `?token=${encodeURIComponent(token)}` + } const iframe = document.createElement('iframe') iframe.style.display = 'none' iframe.src = downloadUrl @@ -463,14 +461,14 @@ export default function TutorialsPage() { } }, { - id: 'list-jobs', - title: 'List Backup Jobs', - description: 'Retrieve a list of all backup jobs, including in-progress, completed, and failed jobs.', - endpoint: 'GET /api/v1/backups/jobs', + id: 'check-active-backup', + title: 'Check Active Backup', + description: 'Check if a backup is currently being created. Returns active status, backup name, and index ID when a backup is in progress.', + endpoint: 'GET /api/v1/backups/active', method: 'GET', run: async () => { try { - const response = await fetch('/api/v1/backups/jobs', { + const response = await fetch('/api/v1/backups/active', { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -478,7 +476,37 @@ export default function TutorialsPage() { } }) if (!response.ok) { - throw new Error('Failed to fetch backup jobs') + throw new Error('Failed to check active backup') + } + const data = await response.json() + return { success: true, result: formatResult(data) } + } catch (e) { + return { success: false, result: `${e}` } + } + } + }, + { + id: 'backup-info', + title: 'Get Backup Info', + description: 'Retrieve metadata about a backup including original index name, parameters, size, and creation timestamp.', + endpoint: 'GET /api/v1/backups/:backupName/info', + method: 'GET', + requiresPayload: true, + defaultPayload: JSON.stringify({ backup_name: "my_backup" }, null, 2), + run: async (payload) => { + if (!payload) return { success: false, result: 'Payload required' } + try { + const { backup_name } = JSON.parse(payload) + const response = await fetch(`/api/v1/backups/${encodeURIComponent(backup_name)}/info`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...(token && { Authorization: token }) + } + }) + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || 'Failed to fetch backup info') } const data = await response.json() return { success: true, result: formatResult(data) }