Add all-in-one desktop wallet with extensive feature set: Infrastructure: - Storage: IPFS-based decentralized file storage with upload/download - Hosting: Domain registration and static site hosting - Compute: GPU/CPU job marketplace for distributed computing - Database: Multi-model database services (KV, Document, Vector, etc.) Financial Features: - Privacy: Confidential transactions with Pedersen commitments - Bridge: Cross-chain transfers (Ethereum, Bitcoin, IBC/Cosmos) - Governance: DAO proposals, voting, and delegation - ZK-Rollup: L2 scaling with deposits, withdrawals, and transfers UI/UX Improvements: - Add ErrorBoundary component for graceful error handling - Add LoadingStates components (spinners, skeletons, overlays) - Add Animation components (FadeIn, SlideIn, CountUp, etc.) - Update navigation with new feature sections Testing: - Add Playwright E2E smoke tests - Test route accessibility and page rendering - Verify build process and asset loading Build: - Fix TypeScript compilation errors - Update Tauri plugin dependencies - Successfully build macOS app bundle and DMG
465 lines
18 KiB
TypeScript
465 lines
18 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import {
|
||
Cpu,
|
||
Server,
|
||
Play,
|
||
Pause,
|
||
XCircle,
|
||
RefreshCw,
|
||
AlertCircle,
|
||
Clock,
|
||
CheckCircle,
|
||
Loader,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
} from 'lucide-react';
|
||
import { useComputeStore, formatPrice } from '../../store/compute';
|
||
|
||
export default function ComputeDashboard() {
|
||
const {
|
||
providers,
|
||
jobs,
|
||
isLoading,
|
||
isSubmitting,
|
||
error,
|
||
clearError,
|
||
fetchProviders,
|
||
fetchJobs,
|
||
submitJob,
|
||
cancelJob,
|
||
} = useComputeStore();
|
||
|
||
const [activeTab, setActiveTab] = useState<'providers' | 'jobs'>('providers');
|
||
const [showSubmitForm, setShowSubmitForm] = useState(false);
|
||
const [selectedProvider, setSelectedProvider] = useState('');
|
||
const [dockerImage, setDockerImage] = useState('');
|
||
const [command, setCommand] = useState('');
|
||
const [inputCid, setInputCid] = useState('');
|
||
const [cpuCores, setCpuCores] = useState(4);
|
||
const [memoryGb, setMemoryGb] = useState(8);
|
||
const [maxHours, setMaxHours] = useState(1);
|
||
const [gpuType, setGpuType] = useState('');
|
||
const [expandedJob, setExpandedJob] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
fetchProviders();
|
||
fetchJobs();
|
||
}, [fetchProviders, fetchJobs]);
|
||
|
||
const handleSubmitJob = async () => {
|
||
if (!selectedProvider || !dockerImage || !command) return;
|
||
try {
|
||
await submitJob({
|
||
provider: selectedProvider,
|
||
inputCid,
|
||
dockerImage,
|
||
command: command.split(' '),
|
||
gpuType: gpuType || undefined,
|
||
cpuCores,
|
||
memoryGb,
|
||
maxHours,
|
||
});
|
||
setShowSubmitForm(false);
|
||
setSelectedProvider('');
|
||
setDockerImage('');
|
||
setCommand('');
|
||
setInputCid('');
|
||
fetchJobs();
|
||
} catch {
|
||
// Error handled by store
|
||
}
|
||
};
|
||
|
||
const handleCancelJob = async (jobId: string) => {
|
||
await cancelJob(jobId);
|
||
fetchJobs();
|
||
};
|
||
|
||
const getStatusIcon = (status: string) => {
|
||
switch (status) {
|
||
case 'pending':
|
||
return <Clock size={16} className="text-yellow-400" />;
|
||
case 'running':
|
||
return <Loader size={16} className="text-blue-400 animate-spin" />;
|
||
case 'completed':
|
||
return <CheckCircle size={16} className="text-green-400" />;
|
||
case 'failed':
|
||
return <XCircle size={16} className="text-red-400" />;
|
||
case 'cancelled':
|
||
return <Pause size={16} className="text-gray-400" />;
|
||
default:
|
||
return <Clock size={16} className="text-gray-400" />;
|
||
}
|
||
};
|
||
|
||
const getStatusColor = (status: string) => {
|
||
switch (status) {
|
||
case 'pending':
|
||
return 'text-yellow-400';
|
||
case 'running':
|
||
return 'text-blue-400';
|
||
case 'completed':
|
||
return 'text-green-400';
|
||
case 'failed':
|
||
return 'text-red-400';
|
||
case 'cancelled':
|
||
return 'text-gray-400';
|
||
default:
|
||
return 'text-gray-400';
|
||
}
|
||
};
|
||
|
||
// Get unique GPU types from all providers
|
||
const availableGpuTypes = [...new Set(providers.flatMap((p) => p.gpuTypes))];
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-white">Compute Marketplace</h1>
|
||
<p className="text-gray-400 mt-1">Decentralized GPU and CPU compute resources</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => {
|
||
fetchProviders();
|
||
fetchJobs();
|
||
}}
|
||
disabled={isLoading}
|
||
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||
>
|
||
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||
Refresh
|
||
</button>
|
||
<button
|
||
onClick={() => setShowSubmitForm(true)}
|
||
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||
>
|
||
<Play size={16} />
|
||
Submit Job
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Error Alert */}
|
||
{error && (
|
||
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||
<AlertCircle className="text-red-400" size={20} />
|
||
<p className="text-red-400 flex-1">{error}</p>
|
||
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Submit Job Form Modal */}
|
||
{showSubmitForm && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800 w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||
<h2 className="text-xl font-bold text-white mb-4">Submit Compute Job</h2>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">Provider</label>
|
||
<select
|
||
value={selectedProvider}
|
||
onChange={(e) => setSelectedProvider(e.target.value)}
|
||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
|
||
>
|
||
<option value="">Select a provider</option>
|
||
{providers
|
||
.filter((p) => p.isAvailable)
|
||
.map((provider) => (
|
||
<option key={provider.address} value={provider.address}>
|
||
{provider.name} - {formatPrice(provider.pricePerHour)}/hr ({provider.gpuTypes.join(', ') || `${provider.cpuCores} cores`})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">Docker Image</label>
|
||
<input
|
||
type="text"
|
||
value={dockerImage}
|
||
onChange={(e) => setDockerImage(e.target.value)}
|
||
placeholder="pytorch/pytorch:latest"
|
||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">Command</label>
|
||
<input
|
||
type="text"
|
||
value={command}
|
||
onChange={(e) => setCommand(e.target.value)}
|
||
placeholder="python train.py --epochs 10"
|
||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">Input Data CID (optional)</label>
|
||
<input
|
||
type="text"
|
||
value={inputCid}
|
||
onChange={(e) => setInputCid(e.target.value)}
|
||
placeholder="Qm..."
|
||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">CPU Cores</label>
|
||
<input
|
||
type="number"
|
||
value={cpuCores}
|
||
onChange={(e) => setCpuCores(Number(e.target.value))}
|
||
min={1}
|
||
max={64}
|
||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">Memory (GB)</label>
|
||
<input
|
||
type="number"
|
||
value={memoryGb}
|
||
onChange={(e) => setMemoryGb(Number(e.target.value))}
|
||
min={1}
|
||
max={512}
|
||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">GPU Type (optional)</label>
|
||
<select
|
||
value={gpuType}
|
||
onChange={(e) => setGpuType(e.target.value)}
|
||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
|
||
>
|
||
<option value="">None</option>
|
||
{availableGpuTypes.map((type) => (
|
||
<option key={type} value={type}>
|
||
{type}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">Max Hours</label>
|
||
<input
|
||
type="number"
|
||
value={maxHours}
|
||
onChange={(e) => setMaxHours(Number(e.target.value))}
|
||
min={1}
|
||
max={168}
|
||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setShowSubmitForm(false)}
|
||
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={handleSubmitJob}
|
||
disabled={!selectedProvider || !dockerImage || !command || isSubmitting}
|
||
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||
>
|
||
{isSubmitting ? 'Submitting...' : 'Submit Job'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tabs */}
|
||
<div className="flex gap-2 border-b border-gray-800">
|
||
<button
|
||
onClick={() => setActiveTab('providers')}
|
||
className={`px-4 py-2 font-medium transition-colors ${
|
||
activeTab === 'providers'
|
||
? 'text-synor-400 border-b-2 border-synor-400'
|
||
: 'text-gray-400 hover:text-white'
|
||
}`}
|
||
>
|
||
<Server size={16} className="inline mr-2" />
|
||
Providers ({providers.length})
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('jobs')}
|
||
className={`px-4 py-2 font-medium transition-colors ${
|
||
activeTab === 'jobs'
|
||
? 'text-synor-400 border-b-2 border-synor-400'
|
||
: 'text-gray-400 hover:text-white'
|
||
}`}
|
||
>
|
||
<Cpu size={16} className="inline mr-2" />
|
||
My Jobs ({jobs.length})
|
||
</button>
|
||
</div>
|
||
|
||
{/* Providers Tab */}
|
||
{activeTab === 'providers' && (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{providers.map((provider) => (
|
||
<div
|
||
key={provider.address}
|
||
className={`bg-gray-900 rounded-xl p-6 border ${
|
||
provider.isAvailable ? 'border-gray-800' : 'border-gray-800/50 opacity-60'
|
||
}`}
|
||
>
|
||
<div className="flex items-start justify-between mb-4">
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-white">{provider.name}</h3>
|
||
<p className="text-sm text-gray-400 font-mono">{provider.address.slice(0, 12)}...</p>
|
||
</div>
|
||
<span
|
||
className={`px-2 py-1 text-xs rounded-full ${
|
||
provider.isAvailable
|
||
? 'bg-green-900/50 text-green-400'
|
||
: 'bg-gray-800 text-gray-500'
|
||
}`}
|
||
>
|
||
{provider.isAvailable ? 'Available' : 'Busy'}
|
||
</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
{provider.gpuTypes.length > 0 && (
|
||
<div>
|
||
<p className="text-gray-500">GPUs</p>
|
||
<p className="text-white font-medium">{provider.gpuTypes.join(', ')}</p>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<p className="text-gray-500">CPU Cores</p>
|
||
<p className="text-white font-medium">{provider.cpuCores}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-500">Memory</p>
|
||
<p className="text-white font-medium">{provider.memoryGb} GB</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-500">Price</p>
|
||
<p className="text-synor-400 font-medium">{formatPrice(provider.pricePerHour)}/hr</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-500">Reputation</p>
|
||
<p className="text-white font-medium">{provider.reputation}%</p>
|
||
</div>
|
||
</div>
|
||
{provider.isAvailable && (
|
||
<button
|
||
onClick={() => {
|
||
setSelectedProvider(provider.address);
|
||
setShowSubmitForm(true);
|
||
}}
|
||
className="w-full mt-4 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||
>
|
||
Use This Provider
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
{providers.length === 0 && (
|
||
<div className="col-span-2 text-center py-12 text-gray-500">
|
||
No compute providers available
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Jobs Tab */}
|
||
{activeTab === 'jobs' && (
|
||
<div className="space-y-4">
|
||
{jobs.map((job) => (
|
||
<div
|
||
key={job.jobId}
|
||
className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden"
|
||
>
|
||
<div
|
||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-800/50"
|
||
onClick={() => setExpandedJob(expandedJob === job.jobId ? null : job.jobId)}
|
||
>
|
||
<div className="flex items-center gap-4">
|
||
{getStatusIcon(job.status)}
|
||
<div>
|
||
<h3 className="text-white font-medium">Job {job.jobId.slice(0, 8)}</h3>
|
||
<p className="text-sm text-gray-400">
|
||
{job.gpuType || `${job.cpuCores} cores`} • {job.provider.slice(0, 8)}...
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-4">
|
||
<span className={`text-sm font-medium ${getStatusColor(job.status)}`}>
|
||
{job.status.charAt(0).toUpperCase() + job.status.slice(1)}
|
||
</span>
|
||
{expandedJob === job.jobId ? (
|
||
<ChevronUp size={20} className="text-gray-400" />
|
||
) : (
|
||
<ChevronDown size={20} className="text-gray-400" />
|
||
)}
|
||
</div>
|
||
</div>
|
||
{expandedJob === job.jobId && (
|
||
<div className="p-4 border-t border-gray-800 bg-gray-800/30">
|
||
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
|
||
{job.startedAt && (
|
||
<div>
|
||
<p className="text-gray-500">Started</p>
|
||
<p className="text-white">{new Date(job.startedAt).toLocaleString()}</p>
|
||
</div>
|
||
)}
|
||
{job.endedAt && (
|
||
<div>
|
||
<p className="text-gray-500">Ended</p>
|
||
<p className="text-white">{new Date(job.endedAt).toLocaleString()}</p>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<p className="text-gray-500">Cost</p>
|
||
<p className="text-synor-400">{formatPrice(job.totalCost)}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-500">Memory</p>
|
||
<p className="text-white">{job.memoryGb} GB</p>
|
||
</div>
|
||
</div>
|
||
{job.resultCid && (
|
||
<div className="mb-4">
|
||
<p className="text-gray-500 text-sm mb-1">Result CID</p>
|
||
<code className="block p-3 bg-gray-900 rounded-lg text-sm text-gray-300 overflow-x-auto font-mono">
|
||
{job.resultCid}
|
||
</code>
|
||
</div>
|
||
)}
|
||
{(job.status === 'pending' || job.status === 'running') && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleCancelJob(job.jobId);
|
||
}}
|
||
className="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm font-medium transition-colors"
|
||
>
|
||
Cancel Job
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
{jobs.length === 0 && (
|
||
<div className="text-center py-12 text-gray-500">
|
||
No compute jobs. Submit a job to get started.
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|