feat(jobs): configure task timeout and display results

This commit is contained in:
excho0 committed 2026-10-09 16:51:21 +03:00
1 parent 81a581cd0f
commit 1749930d3d
10 files changed
+98 -2

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { formatJobResult, validTaskTimeout } from '../src/renderer/src/lib/job-result.ts'
test('timeout accepts the protobuf uint32 range, including zero for default', () => {
for (const value of [0, 1, 60, 0xffffffff]) assert.equal(validTaskTimeout(value), true)
for (const value of [-1, 0.5, NaN, Infinity, 0x100000000]) assert.equal(validTaskTimeout(value), false)
})
test('JSON results are readable and plain multilingual text is preserved', () => {
const encode = (value) => new TextEncoder().encode(value)
assert.equal(formatJobResult(encode('{"count":25}')), '{\n "count": 25\n}')
assert.equal(formatJobResult(encode('שלום\nhello')), 'שלום\nhello')
assert.equal(formatJobResult(encode('0')), '0')
})
test('binary and malformed UTF-8 are not rendered as corrupt text', () => {
assert.equal(formatJobResult(new Uint8Array([0, 1, 2])), null)
assert.equal(formatJobResult(new Uint8Array([0xff])), null)
assert.equal(formatJobResult(new Uint8Array()), '')
})
@@ -184,6 +184,12 @@
"plannerRunsOnNode": "يعمل مزود الذكاء الاصطناعي محلياً على هذه العقدة",
"closePreview": "إغلاق معاينة الصورة",
"operationText": {
"taskTimeoutSeconds": "مهلة المهمة (ثوانٍ)",
"taskTimeoutHint": "0 يستخدم الإعداد الافتراضي. يتم إيقاف المحاولات المتأخرة وإعادتها.",
"invalidTaskTimeout": "أدخل عددًا صحيحًا من 0 إلى 4294967295.",
"jobResult": "نتيجة المهمة",
"binaryResult": "نتيجة ثنائية — نزّلها للفحص.",
"resultTruncated": "المعاينة مختصرة. نزّل النتيجة الكاملة.",
"openingJob": "جارٍ فتح المهمة…",
"loadJobFailed": "تعذر فتح المهمة",
"jobNotFound": "لم يتم العثور على المهمة.",
@@ -184,6 +184,12 @@
"plannerRunsOnNode": "Your AI provider runs locally on this node",
"closePreview": "Close image preview",
"operationText": {
"taskTimeoutSeconds": "Task timeout (seconds)",
"taskTimeoutHint": "0 uses the daemon default. Timed-out attempts are stopped and retried.",
"invalidTaskTimeout": "Use a whole number from 0 to 4294967295.",
"jobResult": "Job result",
"binaryResult": "Binary result — download to inspect.",
"resultTruncated": "Preview shortened. Download for the full result.",
"openingJob": "Opening job…",
"loadJobFailed": "Could not open job",
"jobNotFound": "The job was not found.",
@@ -184,6 +184,12 @@
"plannerRunsOnNode": "Tu proveedor de IA se ejecuta localmente en este nodo",
"closePreview": "Cerrar vista previa de imagen",
"operationText": {
"taskTimeoutSeconds": "Tiempo límite por tarea (segundos)",
"taskTimeoutHint": "0 usa el valor del nodo. Los intentos que exceden el límite se detienen y se reintentan.",
"invalidTaskTimeout": "Introduce un entero entre 0 y 4294967295.",
"jobResult": "Resultado de la tarea",
"binaryResult": "Resultado binario — descárgalo para verlo.",
"resultTruncated": "Vista previa abreviada. Descarga el resultado completo.",
"openingJob": "Abriendo tarea…",
"loadJobFailed": "No se pudo abrir la tarea",
"jobNotFound": "No se encontró la tarea.",
@@ -184,6 +184,12 @@
"plannerRunsOnNode": "Votre fournisseur d’IA s’exécute localement sur ce nœud",
"closePreview": "Fermer l’aperçu de l’image",
"operationText": {
"taskTimeoutSeconds": "Délai par tâche (secondes)",
"taskTimeoutHint": "0 utilise le réglage du nœud. Les tentatives expirées sont arrêtées puis relancées.",
"invalidTaskTimeout": "Saisissez un entier de 0 à 4294967295.",
"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.",
"openingJob": "Ouverture de la tâche…",
"loadJobFailed": "Impossible d’ouvrir la tâche",
"jobNotFound": "La tâche est introuvable.",
@@ -184,6 +184,12 @@
"plannerRunsOnNode": "ספק ה־AI שלכם פועל מקומית בצומת הזו",
"closePreview": "סגירת תצוגת התמונה",
"operationText": {
"taskTimeoutSeconds": "זמן מרבי למשימה (שניות)",
"taskTimeoutHint": "0 משתמש בברירת המחדל של הדיימון. ניסיונות שחורגים נעצרים ומנוסים שוב.",
"invalidTaskTimeout": "יש להזין מספר שלם בין 0 ל־4294967295.",
"jobResult": "תוצאת העבודה",
"binaryResult": "תוצאה בינארית — יש להוריד כדי לבדוק.",
"resultTruncated": "התצוגה קוצרה. ניתן להוריד את התוצאה המלאה.",
"openingJob": "פותח עבודה…",
"loadJobFailed": "לא ניתן לפתוח את העבודה",
"jobNotFound": "העבודה לא נמצאה.",
@@ -184,6 +184,12 @@
"plannerRunsOnNode": "Dostawca AI działa lokalnie na tym węźle",
"closePreview": "Zamknij podgląd obrazu",
"operationText": {
"taskTimeoutSeconds": "Limit czasu zadania (sekundy)",
"taskTimeoutHint": "0 używa ustawienia węzła. Próby po przekroczeniu limitu są zatrzymywane i ponawiane.",
"invalidTaskTimeout": "Wpisz liczbę całkowitą od 0 do 4294967295.",
"jobResult": "Wynik zadania",
"binaryResult": "Wynik binarny — pobierz, aby sprawdzić.",
"resultTruncated": "Podgląd skrócony. Pobierz pełny wynik.",
"openingJob": "Otwieranie zadania…",
"loadJobFailed": "Nie udało się otworzyć zadania",
"jobNotFound": "Nie znaleziono zadania.",
@@ -184,6 +184,12 @@
"plannerRunsOnNode": "Провайдер ИИ работает локально на этом узле",
"closePreview": "Закрыть просмотр изображения",
"operationText": {
"taskTimeoutSeconds": "Тайм-аут задачи (секунды)",
"taskTimeoutHint": "0 — настройка узла. Просроченные попытки останавливаются и повторяются.",
"invalidTaskTimeout": "Введите целое число от 0 до 4294967295.",
"jobResult": "Результат задачи",
"binaryResult": "Двоичный результат — скачайте для просмотра.",
"resultTruncated": "Предпросмотр сокращён. Скачайте полный результат.",
"openingJob": "Открываем задачу…",
"loadJobFailed": "Не удалось открыть задачу",
"jobNotFound": "Задача не найдена.",
@@ -0,0 +1,12 @@
export function validTaskTimeout(value: number): boolean {
return Number.isInteger(value) && value >= 0 && value <= 0xffffffff
}
export function formatJobResult(bytes: Uint8Array): string | null {
if (!bytes.byteLength) return ''
try {
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text)) return null
try { return JSON.stringify(JSON.parse(text), null, 2) } catch { return text }
} catch { return null }
}
@@ -10,6 +10,7 @@ import { EmptyState, PageHeading } from '@/components/ui/page-layout'
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'
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 }
@@ -17,6 +18,26 @@ 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 JobResult({ job, messages }: { job: Job; messages: Messages }) {
if (!job.result?.byteLength) return null
const text = formatJobResult(job.result)
const labels = messages.operationText
const download = () => {
const url = URL.createObjectURL(new Blob([new Uint8Array(job.result).buffer]))
const link = document.createElement('a')
link.href = url
link.download = `${job.jobId}-result`
link.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
return <details className="mt-3 rounded-lg border border-[var(--app-line)] p-3">
<summary className="cursor-pointer text-xs font-medium">{labels.jobResult} · {prettyBytes(job.result.byteLength)}</summary>
{text === null ? <p className="mt-2 text-xs text-muted-foreground">{labels.binaryResult}</p> : <pre dir="auto" className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words text-xs">{text.slice(0, 65536)}</pre>}
{text && text.length > 65536 && <p className="mt-2 text-xs text-muted-foreground">{labels.resultTruncated}</p>}
<Button type="button" variant="outline" size="sm" className="mt-2" onClick={download}><Download className="me-1 size-3" />{labels.download}</Button>
</details>
}
export function OperationsPage({ messages }: { messages: Messages }) {
const labels = messages.operationText
const stateLabels: Record<string, string> = { unspecified: labels.unspecified, pending: labels.pending, running: labels.running, succeeded: labels.succeeded, failed: labels.failed, cancelled: labels.cancelled }
@@ -42,6 +63,7 @@ export function OperationsPage({ messages }: { messages: Messages }) {
const [params, setParams] = useState('{"from":0,"to":100000}')
const [mode, setMode] = useState('JOB_MODE_DISTRIBUTED')
const [maxTasks, setMaxTasks] = useState(0)
const [taskTimeout, setTaskTimeout] = useState(0)
const [minMemory, setMinMemory] = useState(0)
const [minGpus, setMinGpus] = useState(0)
const [jobPrivate, setJobPrivate] = useState(false)
@@ -106,7 +128,8 @@ export function OperationsPage({ messages }: { messages: Messages }) {
event.preventDefault(); setBusy(true)
try {
const parsed = JSON.parse(params)
const result = await getNodeApi().call<{ job?: Job }>('submitJob', { workload, params: new TextEncoder().encode(JSON.stringify(parsed)), mode: mode === 'JOB_MODE_FULL_WORKER' ? 2 : 1, maxTasks, minMemoryBytes: minMemory, minGpus, private: jobPrivate })
if (!validTaskTimeout(taskTimeout)) throw new Error(labels.invalidTaskTimeout)
const result = await getNodeApi().call<{ job?: Job }>('submitJob', { workload, params: new TextEncoder().encode(JSON.stringify(parsed)), mode: mode === 'JOB_MODE_FULL_WORKER' ? 2 : 1, maxTasks, taskTimeoutSeconds: taskTimeout, minMemoryBytes: minMemory, minGpus, private: jobPrivate })
if (result.job) setJobs((current) => [result.job!, ...current.filter((job) => job.jobId !== result.job!.jobId)])
toast.success(labels.jobSubmitted)
} catch (error) { toast.error(labels.submitFailed, { description: error instanceof Error ? error.message : String(error) }) }
@@ -173,7 +196,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">{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}{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>{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 === '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>