feat: mirror tool
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2025-06-18 15:17:33 +01:00
parent 84b475d11a
commit 84be9c16bb
4 changed files with 498 additions and 354 deletions

View File

@ -7,7 +7,7 @@ function App() {
return (
<Router>
<div className="min-h-screen bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-lg:px-6">
<Header />
<main className="py-8">
<Routes>

View File

@ -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<FileMirrorSuggestion[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const [mirroring, setMirroring] = useState<Set<string>>(new Set());
const [mirrorAllProgress, setMirrorAllProgress] = useState<MirrorProgress | null>(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<string, FileMirrorSuggestion> = 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,17 +111,40 @@ 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
);
setMirrorAllProgress({
total: totalOperations,
completed: 0,
failed: 0,
errors: []
});
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++;
// Update suggestions by removing this server from missing_from
setMirrorAllProgress(prev => prev ? {
...prev,
completed: completed,
failed: failed
} : null);
// Update suggestions in real-time
setSuggestions(prev =>
prev.map(s =>
s.sha256 === suggestion.sha256
@ -116,22 +154,52 @@ export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) {
missing_from: s.missing_from.filter(server => server !== targetServer)
}
: s
).filter(s => s.missing_from.length > 0) // Remove suggestions with no missing servers
).filter(s => s.missing_from.length > 0)
);
} catch (e) {
if (e instanceof Error) {
setError(`Failed to mirror file: ${e.message}`);
} else {
setError("Failed to mirror file");
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 (
<div className="card">
<h3 className="text-lg font-semibold mb-4">Mirror Suggestions</h3>
<p className="text-gray-400 mb-6">
The following files are missing from some of your servers and can be mirrored:
</p>
<h3 className="text-lg font-semibold mb-4">Mirror Coverage</h3>
<div className="space-y-4">
{suggestions.map((suggestion) => (
<div key={suggestion.sha256} className="bg-gray-800 border border-gray-700 rounded-lg p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<p className="text-sm font-medium text-gray-300 mb-1">
File: {suggestion.sha256}
</p>
<p className="text-xs text-gray-400">
Size: {FormatBytes(suggestion.size)}
{suggestion.mime_type && ` • Type: ${suggestion.mime_type}`}
</p>
</div>
</div>
<div className="space-y-2">
{/* Coverage Summary */}
<div className="bg-gray-800 border border-gray-700 rounded-lg p-4 mb-6">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<p className="text-xs text-green-400 mb-1">Available on:</p>
<div className="flex flex-wrap gap-1">
{suggestion.available_on.map((server) => (
<span key={server} className="text-xs bg-green-900/30 text-green-300 px-2 py-1 rounded">
{new URL(server).hostname}
<div className="text-2xl font-bold text-blue-400">{totalFiles}</div>
<div className="text-xs text-gray-400">Files to Mirror</div>
</div>
<div>
<div className="text-2xl font-bold text-orange-400">{totalMirrorOperations}</div>
<div className="text-xs text-gray-400">Operations Needed</div>
</div>
<div>
<div className="text-2xl font-bold text-green-400">{FormatBytes(totalSize)}</div>
<div className="text-xs text-gray-400">Total Size</div>
</div>
</div>
</div>
{/* Server Coverage */}
<div className="bg-gray-800 border border-gray-700 rounded-lg p-4 mb-6">
<h4 className="text-sm font-semibold text-gray-300 mb-3">Coverage by Server</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{serverCoverage.map((server) => (
<div key={server.url} className="bg-gray-750 border border-gray-600 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-300 truncate">
{server.hostname}
</span>
<span
className={`text-sm font-semibold ${server.coveragePercentage === 100
? "text-green-400"
: server.coveragePercentage >= 80
? "text-yellow-400"
: "text-red-400"
}`}
>
{server.coveragePercentage}%
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2 mb-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${server.coveragePercentage === 100
? "bg-green-500"
: server.coveragePercentage >= 80
? "bg-yellow-500"
: "bg-red-500"
}`}
style={{
width: `${server.coveragePercentage}%`,
}}
></div>
</div>
<div className="text-xs text-gray-400 text-center">
{server.filesCount} / {server.totalFiles} files
</div>
</div>
))}
</div>
</div>
<div>
<p className="text-xs text-red-400 mb-1">Missing from:</p>
<div className="flex flex-wrap gap-2">
{suggestion.missing_from.map((server) => {
const mirrorKey = `${suggestion.sha256}-${server}`;
const isMirroring = mirroring.has(mirrorKey);
return (
<div key={server} className="flex items-center gap-2">
<span className="text-xs bg-red-900/30 text-red-300 px-2 py-1 rounded">
{new URL(server).hostname}
</span>
{/* Mirror All Section */}
{!mirrorAllProgress ? (
<div className="text-center">
<p className="text-gray-400 mb-4">
{totalFiles} files need to be synchronized across your servers
</p>
<Button
onClick={() => mirrorFile(suggestion, server)}
disabled={isMirroring}
className="btn-primary text-xs py-1 px-2"
onClick={mirrorAll}
className="btn-primary"
disabled={totalMirrorOperations === 0}
>
{isMirroring ? "Mirroring..." : "Mirror"}
Mirror Everything
</Button>
</div>
);
})}
) : (
<div className="space-y-4">
{/* Progress Bar */}
<div>
<div className="flex justify-between text-sm mb-2">
<span className="text-gray-400">Progress</span>
<span className="text-gray-400">
{mirrorAllProgress.completed + mirrorAllProgress.failed} / {mirrorAllProgress.total}
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{
width: `${((mirrorAllProgress.completed + mirrorAllProgress.failed) / mirrorAllProgress.total) * 100}%`
}}
/>
</div>
</div>
{/* Status Summary */}
<div className="grid grid-cols-3 gap-4 text-center text-sm">
<div>
<div className="text-green-400 font-semibold">{mirrorAllProgress.completed}</div>
<div className="text-gray-400">Completed</div>
</div>
<div>
<div className="text-red-400 font-semibold">{mirrorAllProgress.failed}</div>
<div className="text-gray-400">Failed</div>
</div>
<div>
<div className="text-gray-400 font-semibold">
{mirrorAllProgress.total - mirrorAllProgress.completed - mirrorAllProgress.failed}
</div>
<div className="text-gray-400">Remaining</div>
</div>
</div>
{/* Errors */}
{mirrorAllProgress.errors.length > 0 && (
<div className="bg-red-900/20 border border-red-800 rounded-lg p-3">
<h4 className="text-red-400 font-semibold mb-2">Errors ({mirrorAllProgress.errors.length})</h4>
<div className="space-y-1 max-h-32 overflow-y-auto">
{mirrorAllProgress.errors.map((error, index) => (
<div key={index} className="text-red-300 text-xs">{error}</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -43,7 +43,7 @@ export default function Header() {
return (
<header className="border-b border-gray-700 bg-gray-800 w-full">
<div className="px-4 sm:px-6 lg:px-8 flex justify-between items-center py-4">
<div className="px-4 flex justify-between items-center py-4">
<div className="flex items-center space-x-8">
<Link to="/">
<div className="text-2xl font-bold text-gray-100 hover:text-blue-400 transition-colors">

View File

@ -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<AdminSelf>();
const [error, setError] = useState<string>();
const [results, setResults] = useState<Array<BlobDescriptor>>([]);
@ -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,14 +159,16 @@ export default function Upload() {
}
return (
<div className="max-w-4xl mx-auto space-y-8">
<div className="w-full px-4">
{error && (
<div className="bg-red-900/20 border border-red-800 text-red-400 px-4 py-3 rounded-lg">
<div className="bg-red-900/20 border border-red-800 text-red-400 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
<div className="card">
<div className="flex flex-wrap gap-6">
{/* Upload Widget */}
<div className="card flex-1 min-w-80">
<h2 className="text-xl font-semibold mb-6">Upload Files</h2>
<div className="space-y-6">
@ -174,12 +176,12 @@ export default function Upload() {
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={noCompress}
onChange={(e) => setNoCompress(e.target.checked)}
checked={stripMetadata}
onChange={(e) => setStripMetadata(e.target.checked)}
className="mr-2"
/>
<span className="text-sm font-medium text-gray-300">
Disable Compression (for images and videos)
Strip metadata (for images)
</span>
</label>
</div>
@ -203,8 +205,9 @@ export default function Upload() {
</div>
</div>
{/* Storage Usage Widget */}
{self && (
<div className="card max-w-2xl mx-auto">
<div className="card flex-1 min-w-80">
<h3 className="text-lg font-semibold mb-4">Storage Usage</h3>
<div className="space-y-4">
{/* File Count */}
@ -324,8 +327,9 @@ export default function Upload() {
</div>
)}
{/* Payment Flow Widget */}
{showPaymentFlow && pub && (
<div className="card">
<div className="card flex-1 min-w-80">
<PaymentFlow
route96={new Route96(ServerUrl, pub)}
onPaymentRequested={(pr) => {
@ -336,14 +340,17 @@ export default function Upload() {
</div>
)}
{/* Mirror Suggestions */}
{/* Mirror Suggestions Widget */}
{blossomServers && blossomServers.length > 1 && (
<div className="w-full">
<MirrorSuggestions
servers={blossomServers}
/>
</div>
)}
<div className="card">
{/* Files Widget */}
<div className="card w-full">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold">Your Files</h2>
{!listedFiles && (
@ -367,8 +374,9 @@ export default function Upload() {
)}
</div>
{/* Upload Results Widget */}
{results.length > 0 && (
<div className="card">
<div className="card w-full">
<h3 className="text-lg font-semibold mb-4">Upload Results</h3>
<div className="space-y-4">
{results.map((result, index) => (
@ -448,5 +456,6 @@ export default function Upload() {
</div>
)}
</div>
</div>
);
}