fix(deps): deduplicate protobufjs with protocol compatibility coverage #138

Merged
admin merged 12 commits from fix/desktop-protobuf-deduplication into dev 2026-10-09 15:40:07 +00:00
10 changed files with 89 additions and 16 deletions
Showing only changes of commit 264e912afe - Show all commits

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { appendJobEvent, eventSequence } from '../src/renderer/src/lib/job-events.ts'
const event = (seq) => ({ seq, kind: 'log', text: 'message', workerName: '' })
test('timeline ignores duplicate and older replayed events', () => {
const events = [event('3')]
assert.equal(appendJobEvent(events, event('3')), events)
assert.equal(appendJobEvent(events, event('2')), events)
assert.equal(appendJobEvent(events, event('4')).length, 2)
})
test('uint64 event sequence remains exact beyond safe integer range', () => {
const events = [event('9007199254740992')]
assert.equal(appendJobEvent(events, event('9007199254740993')).length, 2)
assert.equal(eventSequence(event('18446744073709551615')), 18446744073709551615n)
})
test('timeline retains the newest bounded events', () => {
let events = []
for (let seq = 1; seq <= 600; seq++) events = appendJobEvent(events, event(String(seq)))
assert.equal(events.length, 500)
assert.equal(events[0].seq, '101')
assert.equal(events.at(-1).seq, '600')
})
@@ -190,6 +190,8 @@
"jobResult": "نتيجة المهمة",
"binaryResult": "نتيجة ثنائية — نزّلها للفحص.",
"resultTruncated": "المعاينة مختصرة. نزّل النتيجة الكاملة.",
"noJobEvents": "لا توجد أحداث مسجلة.",
"waitingJobEvents": "في انتظار أحداث المهمة…",
"openingJob": "جارٍ فتح المهمة…",
"loadJobFailed": "تعذر فتح المهمة",
"jobNotFound": "لم يتم العثور على المهمة.",
@@ -190,6 +190,8 @@
"jobResult": "Job result",
"binaryResult": "Binary result — download to inspect.",
"resultTruncated": "Preview shortened. Download for the full result.",
"noJobEvents": "No recorded events.",
"waitingJobEvents": "Waiting for job events…",
"openingJob": "Opening job…",
"loadJobFailed": "Could not open job",
"jobNotFound": "The job was not found.",
@@ -190,6 +190,8 @@
"jobResult": "Resultado de la tarea",
"binaryResult": "Resultado binario — descárgalo para verlo.",
"resultTruncated": "Vista previa abreviada. Descarga el resultado completo.",
"noJobEvents": "No hay eventos registrados.",
"waitingJobEvents": "Esperando eventos del trabajo…",
"openingJob": "Abriendo tarea…",
"loadJobFailed": "No se pudo abrir la tarea",
"jobNotFound": "No se encontró la tarea.",
@@ -190,6 +190,8 @@
"jobResult": "Résultat de la tâche",
"binaryResult": "Résultat binaire — téléchargez-le pour l’examiner.",
"resultTruncated": "Aperçu abrégé. Téléchargez le résultat complet.",
"noJobEvents": "Aucun événement enregistré.",
"waitingJobEvents": "En attente des événements de la tâche…",
"openingJob": "Ouverture de la tâche…",
"loadJobFailed": "Impossible d’ouvrir la tâche",
"jobNotFound": "La tâche est introuvable.",
@@ -190,6 +190,8 @@
"jobResult": "תוצאת העבודה",
"binaryResult": "תוצאה בינארית — יש להוריד כדי לבדוק.",
"resultTruncated": "התצוגה קוצרה. ניתן להוריד את התוצאה המלאה.",
"noJobEvents": "אין אירועים שמורים.",
"waitingJobEvents": "ממתין לאירועי העבודה…",
"openingJob": "פותח עבודה…",
"loadJobFailed": "לא ניתן לפתוח את העבודה",
"jobNotFound": "העבודה לא נמצאה.",
@@ -190,6 +190,8 @@
"jobResult": "Wynik zadania",
"binaryResult": "Wynik binarny — pobierz, aby sprawdzić.",
"resultTruncated": "Podgląd skrócony. Pobierz pełny wynik.",
"noJobEvents": "Brak zapisanych zdarzeń.",
"waitingJobEvents": "Oczekiwanie na zdarzenia zadania…",
"openingJob": "Otwieranie zadania…",
"loadJobFailed": "Nie udało się otworzyć zadania",
"jobNotFound": "Nie znaleziono zadania.",
@@ -190,6 +190,8 @@
"jobResult": "Результат задачи",
"binaryResult": "Двоичный результат — скачайте для просмотра.",
"resultTruncated": "Предпросмотр сокращён. Скачайте полный результат.",
"noJobEvents": "Нет записанных событий.",
"waitingJobEvents": "Ожидание событий задания…",
"openingJob": "Открываем задачу…",
"loadJobFailed": "Не удалось открыть задачу",
"jobNotFound": "Задача не найдена.",
@@ -0,0 +1,20 @@
export type JobEvent = {
seq?: string | number
atMs?: string | number
kind: string
text: string
workerName: string
taskIndex?: number
}
// Sequence numbers are uint64, so do not round them through Number.
export function eventSequence(event: JobEvent): bigint {
try { return BigInt(event.seq ?? 0) } catch { return 0n }
}
export function appendJobEvent(events: JobEvent[], event: JobEvent): JobEvent[] {
const seq = eventSequence(event)
const previous = events.length ? eventSequence(events[events.length - 1]) : 0n
if (seq > 0n && seq <= previous) return events
return [...events.slice(-499), event]
}
@@ -11,6 +11,7 @@ import { getNodeApi, isDesktopApp } from '@/lib/node-api'
import { type getMessages } from '@/i18n/messages'
import { getSelectedJobId, includeLinkedJob } from '@/lib/job-navigation'
import { formatJobResult, validTaskTimeout } from '@/lib/job-result'
import { appendJobEvent, type JobEvent } from '@/lib/job-events'
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 }
@@ -18,6 +19,34 @@ type StoredFile = { cid: string; name: string; sizeBytes: string | number; priva
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]}` }
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 }) {
if (!job.result?.byteLength) return null
const text = formatJobResult(job.result)
@@ -55,8 +84,7 @@ export function OperationsPage({ messages }: { messages: Messages }) {
const scrolledJobId = useRef('')
const [tab, setTab] = useState<'jobs' | 'files' | 'pool'>('jobs')
const [jobs, setJobs] = useState<Job[]>([])
const [jobEvents, setJobEvents] = useState<Record<string, { kind: string; text: string; workerName: string }[]>>({})
const lastEventSeq = useRef<Record<string, number>>({})
const [timelineJobId, setTimelineJobId] = useState('')
const [files, setFiles] = useState<StoredFile[]>([])
const [members, setMembers] = useState<Member[]>([])
const [workload, setWorkload] = useState('primes')
@@ -82,6 +110,7 @@ export function OperationsPage({ messages }: { messages: Messages }) {
if (!selectedJobId || !desktop) return
let cancelled = false
setTab('jobs')
setTimelineJobId(selectedJobId)
setLinkedJob({ id: selectedJobId })
void getNodeApi().call<{ job?: Job }>('getJob', { jobId: selectedJobId }).then((result) => {
if (cancelled) return
@@ -141,19 +170,6 @@ export function OperationsPage({ messages }: { messages: Messages }) {
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) }) }
}
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) {
if (!file) 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 && 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>
{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 === '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>