diff --git a/ui_src/src/App.tsx b/ui_src/src/App.tsx index 2989516..300fdb0 100644 --- a/ui_src/src/App.tsx +++ b/ui_src/src/App.tsx @@ -7,7 +7,7 @@ function App() { return (
-
+
diff --git a/ui_src/src/components/mirror-suggestions.tsx b/ui_src/src/components/mirror-suggestions.tsx index 441de1c..2121356 100644 --- a/ui_src/src/components/mirror-suggestions.tsx +++ b/ui_src/src/components/mirror-suggestions.tsx @@ -18,11 +18,19 @@ interface MirrorSuggestionsProps { servers: string[]; } +interface MirrorProgress { + total: number; + completed: number; + failed: number; + errors: string[]; +} + export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) { const [suggestions, setSuggestions] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [mirroring, setMirroring] = useState>(new Set()); + const [mirrorAllProgress, setMirrorAllProgress] = useState(null); const pub = usePublisher(); const login = useLogin(); @@ -35,16 +43,23 @@ export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) { async function fetchSuggestions() { if (!pub || !login?.pubkey) return; - if (loading) return; try { setLoading(true); setError(undefined); + // Capture the servers list at the start to avoid race conditions + const serverList = [...servers]; + + if (serverList.length <= 1) { + setLoading(false); + return; + } + const fileMap: Map = new Map(); // Fetch files from each server - for (const serverUrl of servers) { + for (const serverUrl of serverList) { try { const blossom = new Blossom(serverUrl, pub); const files = await blossom.list(login.pubkey); @@ -70,9 +85,9 @@ export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) { } } - // Determine missing servers for each file + // Determine missing servers for each file using the captured server list for (const suggestion of fileMap.values()) { - for (const serverUrl of servers) { + for (const serverUrl of serverList) { if (!suggestion.available_on.includes(serverUrl)) { suggestion.missing_from.push(serverUrl); } @@ -96,43 +111,96 @@ export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) { } } - async function mirrorFile(suggestion: FileMirrorSuggestion, targetServer: string) { - if (!pub) return; + async function mirrorAll() { + if (!pub || suggestions.length === 0) return; - const mirrorKey = `${suggestion.sha256}-${targetServer}`; - setMirroring(prev => new Set(prev.add(mirrorKey))); + // Calculate total operations needed + const totalOperations = suggestions.reduce((total, suggestion) => + total + suggestion.missing_from.length, 0 + ); - try { - const blossom = new Blossom(targetServer, pub); - await blossom.mirror(suggestion.url); + setMirrorAllProgress({ + total: totalOperations, + completed: 0, + failed: 0, + errors: [] + }); - // Update suggestions by removing this server from missing_from - setSuggestions(prev => - prev.map(s => - s.sha256 === suggestion.sha256 - ? { - ...s, - available_on: [...s.available_on, targetServer], - missing_from: s.missing_from.filter(server => server !== targetServer) - } - : s - ).filter(s => s.missing_from.length > 0) // Remove suggestions with no missing servers - ); - } catch (e) { - if (e instanceof Error) { - setError(`Failed to mirror file: ${e.message}`); - } else { - setError("Failed to mirror file"); + let completed = 0; + let failed = 0; + const errors: string[] = []; + + // Mirror all files to all missing servers + for (const suggestion of suggestions) { + for (const targetServer of suggestion.missing_from) { + try { + const blossom = new Blossom(targetServer, pub); + await blossom.mirror(suggestion.url); + completed++; + + setMirrorAllProgress(prev => prev ? { + ...prev, + completed: completed, + failed: failed + } : null); + + // Update suggestions in real-time + setSuggestions(prev => + prev.map(s => + s.sha256 === suggestion.sha256 + ? { + ...s, + available_on: [...s.available_on, targetServer], + missing_from: s.missing_from.filter(server => server !== targetServer) + } + : s + ).filter(s => s.missing_from.length > 0) + ); + } catch (e) { + failed++; + const errorMessage = e instanceof Error ? e.message : "Unknown error"; + const serverHost = new URL(targetServer).hostname; + errors.push(`${serverHost}: ${errorMessage}`); + + setMirrorAllProgress(prev => prev ? { + ...prev, + completed: completed, + failed: failed, + errors: [...errors] + } : null); + } } - } finally { - setMirroring(prev => { - const newSet = new Set(prev); - newSet.delete(mirrorKey); - return newSet; - }); } + + // Keep progress visible for a moment before clearing + setTimeout(() => { + setMirrorAllProgress(null); + }, 3000); } + // Calculate coverage statistics + const totalFiles = suggestions.length; + const totalMirrorOperations = suggestions.reduce((total, suggestion) => + total + suggestion.missing_from.length, 0 + ); + const totalSize = suggestions.reduce((total, suggestion) => total + suggestion.size, 0); + + // Calculate coverage per server + const serverCoverage = servers.map(serverUrl => { + const filesOnServer = suggestions.filter(s => s.available_on.includes(serverUrl)).length; + const totalFilesAcrossAllServers = new Set(suggestions.map(s => s.sha256)).size; + const coveragePercentage = totalFilesAcrossAllServers > 0 ? + Math.round((filesOnServer / totalFilesAcrossAllServers) * 100) : 100; + + return { + url: serverUrl, + hostname: new URL(serverUrl).hostname, + filesCount: filesOnServer, + totalFiles: totalFilesAcrossAllServers, + coveragePercentage + }; + }); + if (servers.length <= 1) { return null; // No suggestions needed for single server } @@ -171,66 +239,133 @@ export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) { return (
-

Mirror Suggestions

-

- The following files are missing from some of your servers and can be mirrored: -

+

Mirror Coverage

-
- {suggestions.map((suggestion) => ( -
-
-
-

- File: {suggestion.sha256} -

-

- Size: {FormatBytes(suggestion.size)} - {suggestion.mime_type && ` • Type: ${suggestion.mime_type}`} -

+ {/* Coverage Summary */} +
+
+
+
{totalFiles}
+
Files to Mirror
+
+
+
{totalMirrorOperations}
+
Operations Needed
+
+
+
{FormatBytes(totalSize)}
+
Total Size
+
+
+
+ + {/* Server Coverage */} +
+

Coverage by Server

+
+ {serverCoverage.map((server) => ( +
+
+ + {server.hostname} + + = 80 + ? "text-yellow-400" + : "text-red-400" + }`} + > + {server.coveragePercentage}% + +
+
+
= 80 + ? "bg-yellow-500" + : "bg-red-500" + }`} + style={{ + width: `${server.coveragePercentage}%`, + }} + >
+
+
+ {server.filesCount} / {server.totalFiles} files
+ ))} +
+
-
-
-

Available on:

-
- {suggestion.available_on.map((server) => ( - - {new URL(server).hostname} - - ))} -
-
- -
-

Missing from:

-
- {suggestion.missing_from.map((server) => { - const mirrorKey = `${suggestion.sha256}-${server}`; - const isMirroring = mirroring.has(mirrorKey); - - return ( -
- - {new URL(server).hostname} - - -
- ); - })} -
-
+ {/* Mirror All Section */} + {!mirrorAllProgress ? ( +
+

+ {totalFiles} files need to be synchronized across your servers +

+ +
+ ) : ( +
+ {/* Progress Bar */} +
+
+ Progress + + {mirrorAllProgress.completed + mirrorAllProgress.failed} / {mirrorAllProgress.total} + +
+
+
- ))} -
+ + {/* Status Summary */} +
+
+
{mirrorAllProgress.completed}
+
Completed
+
+
+
{mirrorAllProgress.failed}
+
Failed
+
+
+
+ {mirrorAllProgress.total - mirrorAllProgress.completed - mirrorAllProgress.failed} +
+
Remaining
+
+
+ + {/* Errors */} + {mirrorAllProgress.errors.length > 0 && ( +
+

Errors ({mirrorAllProgress.errors.length})

+
+ {mirrorAllProgress.errors.map((error, index) => ( +
{error}
+ ))} +
+
+ )} +
+ )}
); } \ No newline at end of file diff --git a/ui_src/src/views/header.tsx b/ui_src/src/views/header.tsx index 87591fb..e51c41a 100644 --- a/ui_src/src/views/header.tsx +++ b/ui_src/src/views/header.tsx @@ -43,7 +43,7 @@ export default function Header() { return (
-
+
diff --git a/ui_src/src/views/upload.tsx b/ui_src/src/views/upload.tsx index c92cbb4..9bb5c5c 100644 --- a/ui_src/src/views/upload.tsx +++ b/ui_src/src/views/upload.tsx @@ -15,7 +15,7 @@ import { FormatBytes, ServerUrl } from "../const"; import { UploadProgress } from "../upload/progress"; export default function Upload() { - const [noCompress, setNoCompress] = useState(false); + const [stripMetadata, setStripMetadata] = useState(true); const [self, setSelf] = useState(); const [error, setError] = useState(); const [results, setResults] = useState>([]); @@ -50,8 +50,8 @@ export default function Upload() { }; const uploader = new Blossom(ServerUrl, pub); - // Use compression by default for video and image files, unless explicitly disabled - const useCompression = shouldCompress(file) && !noCompress; + // Use compression for video and image files when metadata stripping is enabled + const useCompression = shouldCompress(file) && stripMetadata; const result = useCompression ? await uploader.media(file, onProgress) : await uploader.upload(file, onProgress); @@ -159,294 +159,303 @@ export default function Upload() { } return ( -
+
{error && ( -
+
{error}
)} -
-

Upload Files

+
+ {/* Upload Widget */} +
+

Upload Files

-
-
-
-
- {self && ( -
-

Storage Usage

-
- {/* File Count */} -
- Files: - - {self.file_count.toLocaleString()} - -
+ {/* Storage Usage Widget */} + {self && ( +
+

Storage Usage

+
+ {/* File Count */} +
+ Files: + + {self.file_count.toLocaleString()} + +
- {/* Total Usage */} -
- Total Size: - - {FormatBytes(self.total_size)} - -
+ {/* Total Usage */} +
+ Total Size: + + {FormatBytes(self.total_size)} + +
- {/* Only show quota information if available */} - {self.total_available_quota && self.total_available_quota > 0 && ( - <> - {/* Progress Bar */} -
-
- Quota Used: - - {FormatBytes(self.total_size)} of{" "} - {FormatBytes(self.total_available_quota)} - -
-
-
0.8 - ? "bg-red-500" - : self.total_size / self.total_available_quota > 0.6 - ? "bg-yellow-500" - : "bg-green-500" - }`} - style={{ - width: `${Math.min(100, (self.total_size / self.total_available_quota) * 100)}%`, - }} - >
-
-
- - {( - (self.total_size / self.total_available_quota) * - 100 - ).toFixed(1)} - % used - - 0.8 - ? "text-red-400" - : self.total_size / self.total_available_quota > 0.6 - ? "text-yellow-400" - : "text-green-400" - }`} - > - {FormatBytes( - Math.max( - 0, - self.total_available_quota - self.total_size, - ), - )}{" "} - remaining - -
-
- - {/* Quota Breakdown - excluding free quota */} -
- {(self.quota ?? 0) > 0 && ( + {/* Only show quota information if available */} + {self.total_available_quota && self.total_available_quota > 0 && ( + <> + {/* Progress Bar */} +
- Paid Quota: + Quota Used: - {FormatBytes(self.quota!)} + {FormatBytes(self.total_size)} of{" "} + {FormatBytes(self.total_available_quota)}
- )} - {(self.paid_until ?? 0) > 0 && ( -
- Expires: -
-
- {new Date( - self.paid_until! * 1000, - ).toLocaleDateString()} -
-
- {(() => { - const now = Date.now() / 1000; - const daysLeft = Math.max( - 0, - Math.ceil( - (self.paid_until! - now) / (24 * 60 * 60), - ), - ); - return daysLeft > 0 - ? `${daysLeft} days left` - : "Expired"; - })()} +
+
0.8 + ? "bg-red-500" + : self.total_size / self.total_available_quota > 0.6 + ? "bg-yellow-500" + : "bg-green-500" + }`} + style={{ + width: `${Math.min(100, (self.total_size / self.total_available_quota) * 100)}%`, + }} + >
+
+
+ + {( + (self.total_size / self.total_available_quota) * + 100 + ).toFixed(1)} + % used + + 0.8 + ? "text-red-400" + : self.total_size / self.total_available_quota > 0.6 + ? "text-yellow-400" + : "text-green-400" + }`} + > + {FormatBytes( + Math.max( + 0, + self.total_available_quota - self.total_size, + ), + )}{" "} + remaining + +
+
+ + {/* Quota Breakdown - excluding free quota */} +
+ {(self.quota ?? 0) > 0 && ( +
+ Paid Quota: + + {FormatBytes(self.quota!)} + +
+ )} + {(self.paid_until ?? 0) > 0 && ( +
+ Expires: +
+
+ {new Date( + self.paid_until! * 1000, + ).toLocaleDateString()} +
+
+ {(() => { + const now = Date.now() / 1000; + const daysLeft = Math.max( + 0, + Math.ceil( + (self.paid_until! - now) / (24 * 60 * 60), + ), + ); + return daysLeft > 0 + ? `${daysLeft} days left` + : "Expired"; + })()} +
-
- )} -
- + )} +
+ + )} +
+ +
+ )} + + {/* Payment Flow Widget */} + {showPaymentFlow && pub && ( +
+ { + console.log("Payment requested:", pr); + }} + userInfo={self} + /> +
+ )} + + {/* Mirror Suggestions Widget */} + {blossomServers && blossomServers.length > 1 && ( +
+ +
+ )} + + {/* Files Widget */} +
+
+

Your Files

+ {!listedFiles && ( + )}
- -
- )} - {showPaymentFlow && pub && ( -
- { - console.log("Payment requested:", pr); - }} - userInfo={self} - /> -
- )} - - {/* Mirror Suggestions */} - {blossomServers && blossomServers.length > 1 && ( - - )} - -
-
-

Your Files

- {!listedFiles && ( - + {listedFiles && ( + setListedPage(x)} + onDelete={async (x) => { + await deleteFile(x); + await listUploads(listedPage); + }} + /> )}
- {listedFiles && ( - setListedPage(x)} - onDelete={async (x) => { - await deleteFile(x); - await listUploads(listedPage); - }} - /> + {/* Upload Results Widget */} + {results.length > 0 && ( +
+

Upload Results

+
+ {results.map((result, index) => ( +
+
+
+

+ ✅ Upload Successful +

+

+ {new Date( + (result.uploaded || Date.now() / 1000) * 1000, + ).toLocaleString()} +

+
+
+ + {result.type || "Unknown type"} + +
+
+ +
+
+

File Size

+

+ {FormatBytes(result.size || 0)} +

+
+
+ +
+ {result.url && ( +
+

File URL

+
+ + {result.url} + + +
+
+ )} + +
+

+ File Hash (SHA256) +

+ + {result.sha256} + +
+
+ +
+ + Show raw JSON data + +
+                      {JSON.stringify(result, undefined, 2)}
+                    
+
+
+ ))} +
+
)}
- - {results.length > 0 && ( -
-

Upload Results

-
- {results.map((result, index) => ( -
-
-
-

- ✅ Upload Successful -

-

- {new Date( - (result.uploaded || Date.now() / 1000) * 1000, - ).toLocaleString()} -

-
-
- - {result.type || "Unknown type"} - -
-
- -
-
-

File Size

-

- {FormatBytes(result.size || 0)} -

-
-
- -
- {result.url && ( -
-

File URL

-
- - {result.url} - - -
-
- )} - -
-

- File Hash (SHA256) -

- - {result.sha256} - -
-
- -
- - Show raw JSON data - -
-                    {JSON.stringify(result, undefined, 2)}
-                  
-
-
- ))} -
-
- )}
); }