|
|
@@ -11,6 +11,7 @@ import { getNodeApi, isDesktopApp } from '@/lib/node-api'
|
|
|
|
import { type getMessages } from '@/i18n/messages'
|
|
|
|
import { type getMessages } from '@/i18n/messages'
|
|
|
|
import { getSelectedJobId, includeLinkedJob } from '@/lib/job-navigation'
|
|
|
|
import { getSelectedJobId, includeLinkedJob } from '@/lib/job-navigation'
|
|
|
|
import { formatJobResult, validTaskTimeout } from '@/lib/job-result'
|
|
|
|
import { formatJobResult, validTaskTimeout } from '@/lib/job-result'
|
|
|
|
|
|
|
|
import { appendJobEvent, type JobEvent } from '@/lib/job-events'
|
|
|
|
|
|
|
|
|
|
|
|
type Messages = ReturnType<typeof getMessages>
|
|
|
|
type Messages = ReturnType<typeof getMessages>
|
|
|
|
type Job = { jobId: string; workload: string; mode: string | number; state: string | number; progress: number; error: string; private?: boolean; outputBlobs?: string[]; tasks: { index: number; state: string | number; workerName: string; error: string }[]; result: Uint8Array; createdAtMs: string | number }
|
|
|
|
type Job = { jobId: string; workload: string; mode: string | number; state: string | number; progress: number; error: string; private?: boolean; outputBlobs?: string[]; tasks: { index: number; state: string | number; workerName: string; error: string }[]; result: Uint8Array; createdAtMs: string | number }
|
|
|
@@ -18,6 +19,34 @@ type StoredFile = { cid: string; name: string; sizeBytes: string | number; priva
|
|
|
|
type Member = { peerId: string; role: string | number; joinedAtMs: string | number }
|
|
|
|
type Member = { peerId: string; role: string | number; joinedAtMs: string | number }
|
|
|
|
const prettyBytes = (value: string | number) => { const bytes = Number(value); if (!bytes) return '0 B'; const unit = Math.min(3, Math.floor(Math.log(bytes) / Math.log(1024))); return `${(bytes / 1024 ** unit).toFixed(unit ? 1 : 0)} ${['B', 'KB', 'MB', 'GB'][unit]}` }
|
|
|
|
const prettyBytes = (value: string | number) => { const bytes = Number(value); if (!bytes) return '0 B'; const unit = Math.min(3, Math.floor(Math.log(bytes) / Math.log(1024))); return `${(bytes / 1024 ** unit).toFixed(unit ? 1 : 0)} ${['B', 'KB', 'MB', 'GB'][unit]}` }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function JobTimeline({ jobId, eventLabels, messages }: { jobId: string; eventLabels: Record<string, string>; messages: Messages }) {
|
|
|
|
|
|
|
|
const [events, setEvents] = useState<JobEvent[]>([])
|
|
|
|
|
|
|
|
const [error, setError] = useState('')
|
|
|
|
|
|
|
|
const [ended, setEnded] = useState(false)
|
|
|
|
|
|
|
|
const [retry, setRetry] = useState(0)
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
|
|
let active = true
|
|
|
|
|
|
|
|
setEvents([])
|
|
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
|
|
setEnded(false)
|
|
|
|
|
|
|
|
const stop = getNodeApi().stream<JobEvent>('watchJobEvents', { jobId, afterSeq: '0' }, (event) => {
|
|
|
|
|
|
|
|
if (active) setEvents((current) => appendJobEvent(current, event))
|
|
|
|
|
|
|
|
}, (message) => { if (active) setError(message) }, () => { if (active) setEnded(true) })
|
|
|
|
|
|
|
|
return () => { active = false; stop() }
|
|
|
|
|
|
|
|
}, [jobId, retry])
|
|
|
|
|
|
|
|
return <div role="log" className="mt-3 max-h-64 space-y-2 overflow-auto rounded-lg bg-[var(--app-wash)] p-3">
|
|
|
|
|
|
|
|
{error && <div><p role="alert" className="text-xs text-destructive">{error}</p><Button type="button" size="sm" variant="outline" onClick={() => setRetry((current) => current + 1)}>{messages.refresh}</Button></div>}
|
|
|
|
|
|
|
|
{!error && events.length === 0 && <p role="status" className="text-xs text-muted-foreground">{ended ? messages.operationText.noJobEvents : messages.operationText.waitingJobEvents}</p>}
|
|
|
|
|
|
|
|
{events.map((event, index) => <div key={String(event.seq ?? index)} className="text-xs">
|
|
|
|
|
|
|
|
<span className="font-medium">{eventLabels[event.kind] ?? event.kind}</span>
|
|
|
|
|
|
|
|
{event.atMs && <time className="ms-2 text-muted-foreground" dateTime={new Date(Number(event.atMs)).toISOString()}>{new Date(Number(event.atMs)).toLocaleString(document.documentElement.lang)}</time>}
|
|
|
|
|
|
|
|
{event.taskIndex !== undefined && event.taskIndex >= 0 && <span className="ms-2 text-muted-foreground">#{event.taskIndex + 1}</span>}
|
|
|
|
|
|
|
|
{event.workerName && <span className="ms-2 text-muted-foreground">{event.workerName}</span>}
|
|
|
|
|
|
|
|
{event.text && <p dir="auto" className="mt-1 whitespace-pre-wrap break-words text-muted-foreground">{event.text}</p>}
|
|
|
|
|
|
|
|
</div>)}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function JobResult({ job, messages }: { job: Job; messages: Messages }) {
|
|
|
|
function JobResult({ job, messages }: { job: Job; messages: Messages }) {
|
|
|
|
if (!job.result?.byteLength) return null
|
|
|
|
if (!job.result?.byteLength) return null
|
|
|
|
const text = formatJobResult(job.result)
|
|
|
|
const text = formatJobResult(job.result)
|
|
|
@@ -55,8 +84,7 @@ export function OperationsPage({ messages }: { messages: Messages }) {
|
|
|
|
const scrolledJobId = useRef('')
|
|
|
|
const scrolledJobId = useRef('')
|
|
|
|
const [tab, setTab] = useState<'jobs' | 'files' | 'pool'>('jobs')
|
|
|
|
const [tab, setTab] = useState<'jobs' | 'files' | 'pool'>('jobs')
|
|
|
|
const [jobs, setJobs] = useState<Job[]>([])
|
|
|
|
const [jobs, setJobs] = useState<Job[]>([])
|
|
|
|
const [jobEvents, setJobEvents] = useState<Record<string, { kind: string; text: string; workerName: string }[]>>({})
|
|
|
|
const [timelineJobId, setTimelineJobId] = useState('')
|
|
|
|
const lastEventSeq = useRef<Record<string, number>>({})
|
|
|
|
|
|
|
|
const [files, setFiles] = useState<StoredFile[]>([])
|
|
|
|
const [files, setFiles] = useState<StoredFile[]>([])
|
|
|
|
const [members, setMembers] = useState<Member[]>([])
|
|
|
|
const [members, setMembers] = useState<Member[]>([])
|
|
|
|
const [workload, setWorkload] = useState('primes')
|
|
|
|
const [workload, setWorkload] = useState('primes')
|
|
|
@@ -82,6 +110,7 @@ export function OperationsPage({ messages }: { messages: Messages }) {
|
|
|
|
if (!selectedJobId || !desktop) return
|
|
|
|
if (!selectedJobId || !desktop) return
|
|
|
|
let cancelled = false
|
|
|
|
let cancelled = false
|
|
|
|
setTab('jobs')
|
|
|
|
setTab('jobs')
|
|
|
|
|
|
|
|
setTimelineJobId(selectedJobId)
|
|
|
|
setLinkedJob({ id: selectedJobId })
|
|
|
|
setLinkedJob({ id: selectedJobId })
|
|
|
|
void getNodeApi().call<{ job?: Job }>('getJob', { jobId: selectedJobId }).then((result) => {
|
|
|
|
void getNodeApi().call<{ job?: Job }>('getJob', { jobId: selectedJobId }).then((result) => {
|
|
|
|
if (cancelled) return
|
|
|
|
if (cancelled) return
|
|
|
@@ -141,19 +170,6 @@ export function OperationsPage({ messages }: { messages: Messages }) {
|
|
|
|
async function removeStoredFile(cid: string) {
|
|
|
|
async function removeStoredFile(cid: string) {
|
|
|
|
try { await getNodeApi().call('removeFile', { cid }); await refresh() } catch (error) { toast.error(labels.removeFileFailed, { description: error instanceof Error ? error.message : String(error) }) }
|
|
|
|
try { await getNodeApi().call('removeFile', { cid }); await refresh() } catch (error) { toast.error(labels.removeFileFailed, { description: error instanceof Error ? error.message : String(error) }) }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const liveJobIds = displayedJobs.filter((job) => ['JOB_STATE_PENDING', 'JOB_STATE_RUNNING', 1, 2].includes(job.state)).map((job) => job.jobId).join(',')
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
|
|
const ids = liveJobIds ? liveJobIds.split(',') : []
|
|
|
|
|
|
|
|
// Each stream picks up after the last event already shown, so that
|
|
|
|
|
|
|
|
// reopening them when the set of live jobs changes repeats nothing.
|
|
|
|
|
|
|
|
const stops = ids.map((jobId) => getNodeApi().stream<{ seq?: string | number; kind: string; text: string; workerName: string }>('watchJobEvents', { jobId, afterSeq: lastEventSeq.current[jobId] ?? 0 }, (event) => {
|
|
|
|
|
|
|
|
const seq = Number(event.seq ?? 0)
|
|
|
|
|
|
|
|
if (seq && seq <= (lastEventSeq.current[jobId] ?? 0)) return
|
|
|
|
|
|
|
|
if (seq) lastEventSeq.current[jobId] = seq
|
|
|
|
|
|
|
|
setJobEvents((current) => ({ ...current, [jobId]: [...(current[jobId] ?? []).slice(-19), event] }))
|
|
|
|
|
|
|
|
}, () => {}))
|
|
|
|
|
|
|
|
return () => stops.forEach((stop) => stop())
|
|
|
|
|
|
|
|
}, [liveJobIds])
|
|
|
|
|
|
|
|
async function uploadFile(file?: File) {
|
|
|
|
async function uploadFile(file?: File) {
|
|
|
|
if (!file) return
|
|
|
|
if (!file) return
|
|
|
|
if (file.size > 256 * 1024 * 1024) { toast.error(labels.fileTooLarge, { description: labels.fileSizeLimit }); return }
|
|
|
|
if (file.size > 256 * 1024 * 1024) { toast.error(labels.fileTooLarge, { description: labels.fileSizeLimit }); return }
|
|
|
@@ -196,7 +212,7 @@ export function OperationsPage({ messages }: { messages: Messages }) {
|
|
|
|
{!desktop && <p role="status" className="rounded-xl border border-[var(--app-line)] bg-[var(--app-wash)] px-4 py-3 text-sm text-muted-foreground">{messages.browserOperationsReadOnly}</p>}
|
|
|
|
{!desktop && <p role="status" className="rounded-xl border border-[var(--app-line)] bg-[var(--app-wash)] px-4 py-3 text-sm text-muted-foreground">{messages.browserOperationsReadOnly}</p>}
|
|
|
|
{desktop && tab === 'jobs' && selectedJobId && !hasSelectedJob && <EmptyState icon={Boxes} loading={!selectedError} title={selectedError ? labels.loadJobFailed : labels.openingJob} description={selectedError} action={selectedError ? <Button variant="outline" onClick={() => setLinkRetry((value) => value + 1)}>{messages.refresh}</Button> : undefined} />}
|
|
|
|
{desktop && tab === 'jobs' && selectedJobId && !hasSelectedJob && <EmptyState icon={Boxes} loading={!selectedError} title={selectedError ? labels.loadJobFailed : labels.openingJob} description={selectedError} action={selectedError ? <Button variant="outline" onClick={() => setLinkRetry((value) => value + 1)}>{messages.refresh}</Button> : undefined} />}
|
|
|
|
<div role="tablist" className="flex gap-2 border-b border-[var(--app-line)]">{(['jobs', 'files', 'pool'] as const).map((item) => <button key={item} role="tab" aria-selected={tab === item} onClick={() => setTab(item)} className={`border-b-2 px-3 py-2 text-sm ${tab === item ? 'border-[var(--app-accent)] font-medium' : 'border-transparent text-muted-foreground'}`}>{messages[item]}</button>)}</div>
|
|
|
|
<div role="tablist" className="flex gap-2 border-b border-[var(--app-line)]">{(['jobs', 'files', 'pool'] as const).map((item) => <button key={item} role="tab" aria-selected={tab === item} onClick={() => setTab(item)} className={`border-b-2 px-3 py-2 text-sm ${tab === item ? 'border-[var(--app-accent)] font-medium' : 'border-transparent text-muted-foreground'}`}>{messages[item]}</button>)}</div>
|
|
|
|
{tab === 'jobs' && <div className="space-y-4"><Card><CardHeader className="border-b border-[var(--app-line)]"><CardTitle className="flex items-center gap-2 text-sm"><Boxes className="size-4" />{messages.submitJob}</CardTitle></CardHeader><CardContent><form onSubmit={(event) => void submitJob(event)} className="grid gap-3 pt-4 sm:grid-cols-2"><label className="space-y-1 text-xs">{messages.workload}<Input value={workload} onChange={(event) => setWorkload(event.target.value)} placeholder={labels.workloadExample} /></label><label className="space-y-1 text-xs">{messages.schedulingMode}<select value={mode} onChange={(event) => setMode(event.target.value)} className="h-9 w-full rounded-lg border border-[var(--app-line)] bg-background px-3"><option value="JOB_MODE_DISTRIBUTED">{messages.distributed}</option><option value="JOB_MODE_FULL_WORKER">{messages.fullWorker}</option></select></label><label className="space-y-1 text-xs sm:col-span-2">{messages.parametersJson}<textarea value={params} onChange={(event) => setParams(event.target.value)} className="min-h-20 w-full rounded-lg border border-[var(--app-line)] bg-background p-3 font-mono text-xs" /></label><label className="space-y-1 text-xs">{messages.taskCount}<Input type="number" min="0" value={maxTasks} onChange={(event) => setMaxTasks(Number(event.target.value))} /></label><label className="space-y-1 text-xs">{labels.taskTimeoutSeconds}<Input type="number" min="0" max="4294967295" step="1" value={taskTimeout} onChange={(event) => setTaskTimeout(Number(event.target.value))} /><span className="block text-[10px] text-muted-foreground">{labels.taskTimeoutHint}</span></label><label className="space-y-1 text-xs">{messages.minimumMemoryBytes}<Input type="number" min="0" value={minMemory} onChange={(event) => setMinMemory(Number(event.target.value))} /></label><label className="space-y-1 text-xs">{messages.minimumGpus}<Input type="number" min="0" value={minGpus} onChange={(event) => setMinGpus(Number(event.target.value))} /></label><label className="flex items-center gap-2 pt-5 text-xs"><input type="checkbox" checked={jobPrivate} onChange={(event) => setJobPrivate(event.target.checked)} />{messages.privateJob}</label><div className="sm:col-span-2"><Button type="submit" disabled={busy || !workload.trim()}>{messages.submit}</Button><p className="mt-2 text-[10px] text-muted-foreground">{messages.privateJobWarning}</p></div></form></CardContent></Card><div className="space-y-3">{displayedJobs.map((job) => <Card key={job.jobId} id={`job-${job.jobId}`} tabIndex={-1} aria-label={`${messages.jobId}: ${job.jobId}`} className={job.jobId === selectedJobId ? 'scroll-mt-4 ring-1 ring-[var(--app-accent)] focus-visible:outline-2 focus-visible:outline-[var(--app-accent)]' : undefined}><CardContent className="flex flex-wrap items-start gap-3 py-4"><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-2"><span className="font-medium">{job.workload}</span><Badge variant="outline" className="capitalize">{stateName(job.state)}</Badge><Badge variant="secondary">{modeName(job.mode)}</Badge>{job.private && <Badge variant="outline">{messages.private}</Badge>}</div><code className="mt-1 block truncate text-[10px] text-muted-foreground">{job.jobId}</code><div className="mt-3 h-1.5 overflow-hidden rounded-full bg-[var(--app-wash)]"><div className="h-full bg-[var(--app-accent)]" style={{ width: `${Math.max(0, Math.min(100, job.progress * 100))}%` }} /></div><div className="mt-2 text-[10px] text-muted-foreground">{job.tasks?.length ?? 0} · {labels.tasks}{job.error ? ` · ${job.error}` : ''}</div>{jobEvents[job.jobId]?.length ? <div className="mt-3 max-h-28 space-y-1 overflow-auto rounded-lg bg-[var(--app-wash)] p-2">{jobEvents[job.jobId].map((item, index) => <p key={index} className="text-[10px] text-muted-foreground"><span className="font-medium text-foreground">{eventLabels[item.kind] ?? labels.unknown}</span>{item.workerName ? ` · ${item.workerName}` : ''}{item.text ? ` · ${item.text}` : ''}</p>)}</div> : null}<JobResult job={job} messages={messages} />{job.outputBlobs?.length ? <div className="mt-2 flex flex-wrap gap-2">{job.outputBlobs.map((cid, index) => <Button key={cid} type="button" size="sm" variant="outline" onClick={() => void downloadOutput(cid, job.jobId, index)}><Download className="me-1 size-3" />{messages.output} {index + 1}</Button>)}</div> : null}</div>{['JOB_STATE_PENDING', 'JOB_STATE_RUNNING', 1, 2].includes(job.state) && <Button size="sm" variant="outline" onClick={() => void cancelJob(job.jobId)}>{messages.cancel}</Button>}</CardContent></Card>)}{displayedJobs.length === 0 && !selectedJobId && <EmptyState icon={Boxes} title={messages.noJobs} />}</div></div>}
|
|
|
|
{tab === 'jobs' && <div className="space-y-4"><Card><CardHeader className="border-b border-[var(--app-line)]"><CardTitle className="flex items-center gap-2 text-sm"><Boxes className="size-4" />{messages.submitJob}</CardTitle></CardHeader><CardContent><form onSubmit={(event) => void submitJob(event)} className="grid gap-3 pt-4 sm:grid-cols-2"><label className="space-y-1 text-xs">{messages.workload}<Input value={workload} onChange={(event) => setWorkload(event.target.value)} placeholder={labels.workloadExample} /></label><label className="space-y-1 text-xs">{messages.schedulingMode}<select value={mode} onChange={(event) => setMode(event.target.value)} className="h-9 w-full rounded-lg border border-[var(--app-line)] bg-background px-3"><option value="JOB_MODE_DISTRIBUTED">{messages.distributed}</option><option value="JOB_MODE_FULL_WORKER">{messages.fullWorker}</option></select></label><label className="space-y-1 text-xs sm:col-span-2">{messages.parametersJson}<textarea value={params} onChange={(event) => setParams(event.target.value)} className="min-h-20 w-full rounded-lg border border-[var(--app-line)] bg-background p-3 font-mono text-xs" /></label><label className="space-y-1 text-xs">{messages.taskCount}<Input type="number" min="0" value={maxTasks} onChange={(event) => setMaxTasks(Number(event.target.value))} /></label><label className="space-y-1 text-xs">{labels.taskTimeoutSeconds}<Input type="number" min="0" max="4294967295" step="1" value={taskTimeout} onChange={(event) => setTaskTimeout(Number(event.target.value))} /><span className="block text-[10px] text-muted-foreground">{labels.taskTimeoutHint}</span></label><label className="space-y-1 text-xs">{messages.minimumMemoryBytes}<Input type="number" min="0" value={minMemory} onChange={(event) => setMinMemory(Number(event.target.value))} /></label><label className="space-y-1 text-xs">{messages.minimumGpus}<Input type="number" min="0" value={minGpus} onChange={(event) => setMinGpus(Number(event.target.value))} /></label><label className="flex items-center gap-2 pt-5 text-xs"><input type="checkbox" checked={jobPrivate} onChange={(event) => setJobPrivate(event.target.checked)} />{messages.privateJob}</label><div className="sm:col-span-2"><Button type="submit" disabled={busy || !workload.trim()}>{messages.submit}</Button><p className="mt-2 text-[10px] text-muted-foreground">{messages.privateJobWarning}</p></div></form></CardContent></Card><div className="space-y-3">{displayedJobs.map((job) => <Card key={job.jobId} id={`job-${job.jobId}`} tabIndex={-1} aria-label={`${messages.jobId}: ${job.jobId}`} className={job.jobId === selectedJobId ? 'scroll-mt-4 ring-1 ring-[var(--app-accent)] focus-visible:outline-2 focus-visible:outline-[var(--app-accent)]' : undefined}><CardContent className="flex flex-wrap items-start gap-3 py-4"><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-2"><span className="font-medium">{job.workload}</span><Badge variant="outline" className="capitalize">{stateName(job.state)}</Badge><Badge variant="secondary">{modeName(job.mode)}</Badge>{job.private && <Badge variant="outline">{messages.private}</Badge>}</div><code className="mt-1 block truncate text-[10px] text-muted-foreground">{job.jobId}</code><div className="mt-3 h-1.5 overflow-hidden rounded-full bg-[var(--app-wash)]"><div className="h-full bg-[var(--app-accent)]" style={{ width: `${Math.max(0, Math.min(100, job.progress * 100))}%` }} /></div><div className="mt-2 text-[10px] text-muted-foreground">{job.tasks?.length ?? 0} · {labels.tasks}{job.error ? ` · ${job.error}` : ''}</div>{desktop && <Button type="button" size="sm" variant="ghost" className="mt-2" aria-expanded={timelineJobId === job.jobId} onClick={() => setTimelineJobId((current) => current === job.jobId ? '' : job.jobId)}>{labels.log}</Button>}{desktop && timelineJobId === job.jobId && <JobTimeline key={job.jobId} jobId={job.jobId} eventLabels={eventLabels} messages={messages} />}<JobResult job={job} messages={messages} />{job.outputBlobs?.length ? <div className="mt-2 flex flex-wrap gap-2">{job.outputBlobs.map((cid, index) => <Button key={cid} type="button" size="sm" variant="outline" onClick={() => void downloadOutput(cid, job.jobId, index)}><Download className="me-1 size-3" />{messages.output} {index + 1}</Button>)}</div> : null}</div>{['JOB_STATE_PENDING', 'JOB_STATE_RUNNING', 1, 2].includes(job.state) && <Button size="sm" variant="outline" onClick={() => void cancelJob(job.jobId)}>{messages.cancel}</Button>}</CardContent></Card>)}{displayedJobs.length === 0 && !selectedJobId && <EmptyState icon={Boxes} title={messages.noJobs} />}</div></div>}
|
|
|
|
{tab === 'files' && <div className="space-y-4"><Card><CardHeader className="border-b border-[var(--app-line)]"><CardTitle className="flex items-center gap-2 text-sm"><FileUp className="size-4" />{messages.storeFile}</CardTitle></CardHeader><CardContent><div className="flex flex-wrap items-center gap-3 pt-4"><label className="flex items-center gap-2 text-xs"><input type="checkbox" checked={privateFile} onChange={(event) => setPrivateFile(event.target.checked)} />{messages.privateFile}</label><label className="cursor-pointer"><input className="sr-only" type="file" disabled={busy} onChange={(event) => { void uploadFile(event.target.files?.[0]); event.currentTarget.value = '' }} /><span className="inline-flex h-8 items-center gap-2 rounded-lg bg-[var(--app-accent)] px-3 text-xs text-[var(--app-accent-ink)]"><FileUp className="size-4" />{messages.chooseFile}</span></label></div></CardContent></Card><Card><CardContent className="divide-y divide-[var(--app-line)]">{files.map((file) => <div key={file.cid} className="flex flex-wrap items-center gap-3 py-3"><div className="min-w-0 flex-1"><div className="truncate text-sm font-medium">{file.name || file.cid}</div><code className="block truncate text-[10px] text-muted-foreground">{file.cid}</code></div><span className="text-xs text-muted-foreground">{prettyBytes(file.sizeBytes)}</span>{file.private && <Badge variant="secondary">{messages.private}</Badge>}<Button size="icon" variant="ghost" aria-label={`${labels.download} ${file.name}`} onClick={() => void downloadFile(file)}><Download className="size-4" /></Button><Button size="icon" variant="ghost" aria-label={`${messages.remove} ${file.name}`} onClick={() => void removeStoredFile(file.cid)}><Trash2 className="size-4" /></Button></div>)}{files.length === 0 && <EmptyState icon={FileUp} title={messages.noFiles} />}</CardContent></Card></div>}
|
|
|
|
{tab === 'files' && <div className="space-y-4"><Card><CardHeader className="border-b border-[var(--app-line)]"><CardTitle className="flex items-center gap-2 text-sm"><FileUp className="size-4" />{messages.storeFile}</CardTitle></CardHeader><CardContent><div className="flex flex-wrap items-center gap-3 pt-4"><label className="flex items-center gap-2 text-xs"><input type="checkbox" checked={privateFile} onChange={(event) => setPrivateFile(event.target.checked)} />{messages.privateFile}</label><label className="cursor-pointer"><input className="sr-only" type="file" disabled={busy} onChange={(event) => { void uploadFile(event.target.files?.[0]); event.currentTarget.value = '' }} /><span className="inline-flex h-8 items-center gap-2 rounded-lg bg-[var(--app-accent)] px-3 text-xs text-[var(--app-accent-ink)]"><FileUp className="size-4" />{messages.chooseFile}</span></label></div></CardContent></Card><Card><CardContent className="divide-y divide-[var(--app-line)]">{files.map((file) => <div key={file.cid} className="flex flex-wrap items-center gap-3 py-3"><div className="min-w-0 flex-1"><div className="truncate text-sm font-medium">{file.name || file.cid}</div><code className="block truncate text-[10px] text-muted-foreground">{file.cid}</code></div><span className="text-xs text-muted-foreground">{prettyBytes(file.sizeBytes)}</span>{file.private && <Badge variant="secondary">{messages.private}</Badge>}<Button size="icon" variant="ghost" aria-label={`${labels.download} ${file.name}`} onClick={() => void downloadFile(file)}><Download className="size-4" /></Button><Button size="icon" variant="ghost" aria-label={`${messages.remove} ${file.name}`} onClick={() => void removeStoredFile(file.cid)}><Trash2 className="size-4" /></Button></div>)}{files.length === 0 && <EmptyState icon={FileUp} title={messages.noFiles} />}</CardContent></Card></div>}
|
|
|
|
{tab === 'pool' && <div className="space-y-4"><Card><CardHeader className="border-b border-[var(--app-line)]"><CardTitle className="flex items-center gap-2 text-sm"><Users className="size-4" />{messages.inviteNode}</CardTitle></CardHeader><CardContent><div className="flex flex-wrap gap-2 pt-4"><Button variant="outline" onClick={() => void createInvitation('POOL_ROLE_WORKER')}>{messages.inviteWorker}</Button><Button variant="outline" onClick={() => void createInvitation('POOL_ROLE_CLIENT')}>{messages.inviteClient}</Button></div>{invitation && <div className="mt-4 rounded-xl border border-[var(--app-line)] bg-[var(--app-wash)] p-3"><div className="mb-2 flex items-center justify-between text-xs font-medium">{messages.singleUseInvitation}<Button size="icon" variant="ghost" className="size-7" aria-label={labels.dismissInvitation} onClick={() => { setInvitation(''); setInvitationExpiry('') }}><X className="size-3.5" /></Button></div><code dir="ltr" className="block break-all text-xs">{invitation}</code><p className="mt-2 text-[10px] text-muted-foreground">{invitationExpiry ? `${invitationExpiry}. ` : ''}{messages.shareInvitationHint}</p></div>}<form onSubmit={(event) => void joinPool(event)} className="mt-4 grid gap-2 border-t border-[var(--app-line)] pt-4 sm:grid-cols-2"><Input dir="ltr" value={address} onChange={(event) => setAddress(event.target.value)} placeholder={messages.coordinatorAddress} /><Input dir="ltr" value={joinToken} onChange={(event) => setJoinToken(event.target.value)} placeholder={messages.invitationToken} /><Button className="sm:col-span-2" disabled={!address.trim() || !joinToken.trim()}>{messages.joinPool}</Button></form></CardContent></Card><Card><CardHeader className="border-b border-[var(--app-line)]"><CardTitle className="text-sm">{messages.poolMembers} · {members.length}</CardTitle></CardHeader><CardContent className="divide-y divide-[var(--app-line)]">{members.map((member) => <div key={member.peerId} className="flex flex-wrap items-center gap-3 py-3"><code dir="ltr" className="min-w-0 flex-1 truncate text-xs">{member.peerId}</code><Badge variant="outline" className="capitalize">{roleName(member.role)}</Badge><Button size="sm" variant="outline" onClick={async () => { try { await getNodeApi().call('removeMember', { peerId: member.peerId }); await refresh() } catch (error) { toast.error(labels.removeMemberFailed, { description: error instanceof Error ? error.message : String(error) }) } }}>{messages.remove}</Button></div>)}{members.length === 0 && <EmptyState icon={Users} title={messages.noPoolMembers} />}</CardContent></Card></div>}
|
|
|
|
{tab === 'pool' && <div className="space-y-4"><Card><CardHeader className="border-b border-[var(--app-line)]"><CardTitle className="flex items-center gap-2 text-sm"><Users className="size-4" />{messages.inviteNode}</CardTitle></CardHeader><CardContent><div className="flex flex-wrap gap-2 pt-4"><Button variant="outline" onClick={() => void createInvitation('POOL_ROLE_WORKER')}>{messages.inviteWorker}</Button><Button variant="outline" onClick={() => void createInvitation('POOL_ROLE_CLIENT')}>{messages.inviteClient}</Button></div>{invitation && <div className="mt-4 rounded-xl border border-[var(--app-line)] bg-[var(--app-wash)] p-3"><div className="mb-2 flex items-center justify-between text-xs font-medium">{messages.singleUseInvitation}<Button size="icon" variant="ghost" className="size-7" aria-label={labels.dismissInvitation} onClick={() => { setInvitation(''); setInvitationExpiry('') }}><X className="size-3.5" /></Button></div><code dir="ltr" className="block break-all text-xs">{invitation}</code><p className="mt-2 text-[10px] text-muted-foreground">{invitationExpiry ? `${invitationExpiry}. ` : ''}{messages.shareInvitationHint}</p></div>}<form onSubmit={(event) => void joinPool(event)} className="mt-4 grid gap-2 border-t border-[var(--app-line)] pt-4 sm:grid-cols-2"><Input dir="ltr" value={address} onChange={(event) => setAddress(event.target.value)} placeholder={messages.coordinatorAddress} /><Input dir="ltr" value={joinToken} onChange={(event) => setJoinToken(event.target.value)} placeholder={messages.invitationToken} /><Button className="sm:col-span-2" disabled={!address.trim() || !joinToken.trim()}>{messages.joinPool}</Button></form></CardContent></Card><Card><CardHeader className="border-b border-[var(--app-line)]"><CardTitle className="text-sm">{messages.poolMembers} · {members.length}</CardTitle></CardHeader><CardContent className="divide-y divide-[var(--app-line)]">{members.map((member) => <div key={member.peerId} className="flex flex-wrap items-center gap-3 py-3"><code dir="ltr" className="min-w-0 flex-1 truncate text-xs">{member.peerId}</code><Badge variant="outline" className="capitalize">{roleName(member.role)}</Badge><Button size="sm" variant="outline" onClick={async () => { try { await getNodeApi().call('removeMember', { peerId: member.peerId }); await refresh() } catch (error) { toast.error(labels.removeMemberFailed, { description: error instanceof Error ? error.message : String(error) }) } }}>{messages.remove}</Button></div>)}{members.length === 0 && <EmptyState icon={Users} title={messages.noPoolMembers} />}</CardContent></Card></div>}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|