From d6d9e0461f4b18785ac83ac4b63af9e442f2a4a4 Mon Sep 17 00:00:00 2001 From: Nacho Date: Thu, 24 Sep 2026 09:07:08 +0200 Subject: [PATCH] feat(pools): import an existing pool from the web UI (#138) --- web/src/components/Modals.tsx | 76 +++++++++++++++++++++++++++++++++++ web/src/data/http.ts | 4 +- web/src/ui/i18n.ts | 14 +++++++ web/src/views/Dashboard.tsx | 6 ++- web/src/views/Pools.tsx | 14 +------ 5 files changed, 99 insertions(+), 15 deletions(-) diff --git a/web/src/components/Modals.tsx b/web/src/components/Modals.tsx index bd37b0d..d682861 100644 --- a/web/src/components/Modals.tsx +++ b/web/src/components/Modals.tsx @@ -59,6 +59,7 @@ export function ModalHost() { switch (modal.name) { case 'newsnap': return ; case 'newpool': return ; + case 'importpool': return ; case 'newds': return ; case 'editds': return ; case 'propsds': return ; @@ -340,6 +341,81 @@ function NewPoolModal({ onClose }: { onClose: () => void }) { ); } +// ---------- importar pool existente (zpool import) ---------- +// Lista los pools importables ('zpool import') y permite importar uno con +// confirmación escrita. Tras importar, refresca para que el pool aparezca en +// las vistas y desaparezca el aviso de "pool sin importar" (#138). +function ImportPoolModal({ preset, onClose }: { preset?: string; onClose: () => void }) { + const { t, refresh, isAdmin, notify } = useApp(); + const [list, setList] = useState(null); + const [sel, setSel] = useState(preset ?? ''); + const [confirm, setConfirm] = useState(''); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(''); + + useEffect(() => { + let alive = true; + getProvider().importPool() + .then((names) => { if (alive) setList(names); }) + .catch((e) => { if (alive) { setList([]); setErr(errorMessage(e, t)); } }); + return () => { alive = false; }; + }, [t]); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!sel) return; + setBusy(true); setErr(''); + try { + await getProvider().importPool(sel); + refresh(); onClose(); + notify(t('toast_pool_imported'), 'ok'); + } catch (ex) { const msg = errorMessage(ex, t); setErr(msg); notify(msg, 'err'); setBusy(false); } + }; + + const presetMissing = !!preset && list !== null && !list.includes(preset); + + return ( + +
+

{t('imp_title')}

+

{t('imp_desc')}

+ {!list &&
} + {list && list.length === 0 && ( + <> +
{t('imp_empty')}
+ {preset &&

{t('imp_not_importable', { pool: preset })}

} + + )} + {list && list.length > 0 && (<> + +
+ {list.map((name) => ( +
{ setSel(name); setConfirm(''); }} + onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setSel(name); setConfirm(''); } }}> + {name} + {t('imp_pool_hint')} +
+ ))} +
+ {presetMissing &&

{t('imp_not_importable', { pool: preset })}

} +

⚠️ {t('imp_warn')}

+ + setConfirm(e.target.value)} autoComplete="off" /> + )} + {err &&

{err}

} +
+ + +
+
+
+ ); +} + // ---------- nuevo dataset / zvol ---------- function NewDatasetModal({ vol, onClose }: { vol: boolean; onClose: () => void }) { const { t, refresh, notify } = useApp(); diff --git a/web/src/data/http.ts b/web/src/data/http.ts index 901fd24..9d01933 100644 --- a/web/src/data/http.ts +++ b/web/src/data/http.ts @@ -158,7 +158,9 @@ export class HttpProvider implements DataProvider { createPool = (r: CreatePoolReq) => post('/pools', r); importPool = async (name?: string): Promise => { const r = await post<{ importable?: string[] } | string[]>('/pools/import', name ? { name } : {}); - return Array.isArray(r) ? r : (r.importable ?? []); + // Con nombre el backend responde 202 sin cuerpo (r = undefined); sin nombre + // devuelve {importable:[...]}. No asumir que r existe. + return Array.isArray(r) ? r : (r?.importable ?? []); }; scrubAction = (pool: string, action: 'start' | 'pause' | 'stop') => post(`/pools/${enc(pool)}/scrub`, { action }); diff --git a/web/src/ui/i18n.ts b/web/src/ui/i18n.ts index 0286bd7..7c86f8c 100644 --- a/web/src/ui/i18n.ts +++ b/web/src/ui/i18n.ts @@ -94,6 +94,7 @@ const es = { dash_pools: 'Pools', dash_see_all: 'Ver todos', dash_missing_title: 'Pool sin importar', dash_missing_body: '{pools} no aparece en zpool list: no está importado (visto por última vez hace {mins} min). Revisa el servidor o impórtalo desde Pools.', + dash_missing_import: 'Importar', dash_alerts: 'Alertas recientes', dash_activity: 'Actividad', dash_temps: 'Temperatura de discos', dash_events: 'Registro de eventos', dash_no_alerts: 'Sin alertas. Todo en orden.', @@ -114,6 +115,12 @@ const es = { // Pools pools_all: 'Todos', pools_ok: 'Sanos', pools_warn: 'Con avisos', pool_create: '+ Crear pool', pool_import: 'Importar pool existente', + imp_title: 'Importar pool', imp_desc: 'Importa un pool existente que no está montado (equivale a zpool import ). Útil tras un corte de luz si el pool no se importó solo.', + imp_empty: 'No hay pools importables.', imp_select: 'Selecciona el pool a importar', + imp_pool_hint: 'detectado en el sistema, sin importar', + imp_not_importable: 'El pool «{pool}» no figura entre los importables. Revisa el hardware o el estado de los discos.', + imp_warn: 'Importar el pool lo monta y lo pone en uso. Asegúrate de que no lo está usando otro servidor.', + imp_btn: 'Importar', pool_used: 'usado', pool_of: 'de', pool_comp: 'Compresión', pool_frag: 'Fragmentación', pool_last_scrub: 'Último scrub', pool_errors: 'errores', pool_scrub_running: 'Scrub en curso', @@ -748,6 +755,7 @@ const en: Record = { dash_pools: 'Pools', dash_see_all: 'View all', dash_missing_title: 'Pool not imported', dash_missing_body: '{pools} is missing from zpool list: it is not imported (last seen {mins} min ago). Check the server or import it from Pools.', + dash_missing_import: 'Import', dash_alerts: 'Recent alerts', dash_activity: 'Activity', dash_temps: 'Disk temperatures', dash_events: 'Event log', dash_no_alerts: 'No alerts. Everything is fine.', @@ -767,6 +775,12 @@ const en: Record = { pools_all: 'All', pools_ok: 'Healthy', pools_warn: 'With warnings', pool_create: '+ Create pool', pool_import: 'Import existing pool', + imp_title: 'Import pool', imp_desc: 'Import an existing pool that is not mounted (equivalent to zpool import ). Useful after a power loss if the pool did not auto-import.', + imp_empty: 'No importable pools.', imp_select: 'Select the pool to import', + imp_pool_hint: 'detected on the system, not imported', + imp_not_importable: 'Pool "{pool}" is not in the importable list. Check the hardware or the disk state.', + imp_warn: 'Importing mounts the pool and puts it in use. Make sure no other server is using it.', + imp_btn: 'Import', pool_used: 'used', pool_of: 'of', pool_comp: 'Compression', pool_frag: 'Fragmentation', pool_last_scrub: 'Last scrub', pool_errors: 'errors', pool_scrub_running: 'Scrub running', diff --git a/web/src/views/Dashboard.tsx b/web/src/views/Dashboard.tsx index 0d8da2a..b5c5899 100644 --- a/web/src/views/Dashboard.tsx +++ b/web/src/views/Dashboard.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { subscribeEvents } from '../data/events'; import { useData } from '../ui/useData'; import { useApp, alertTargetView } from '../ui/store'; +import { useModal } from '../components/Modal'; import { fmtBytes, fmtBytesPair, fmtDateTime, fmtInt, fmtPct, timeAgo } from '../ui/format'; import { KpiCard, Spinner } from '../components/ui'; import { PoolCard } from '../components/PoolCard'; @@ -35,7 +36,8 @@ function AlertRow({ a }: { a: Alert }) { } export default function Dashboard() { - const { t, navigate } = useApp(); + const { t, navigate, isAdmin } = useApp(); + const { openModal } = useModal(); const ov = useData((p) => p.getOverview()); const pools = useData((p) => p.getPools()); const perf = useData((p) => p.getPerformance()); @@ -126,6 +128,8 @@ export default function Dashboard() { {t('dash_missing_title')}: {m.name}
{t('dash_missing_body', { pools: m.name, mins })}
+ ); diff --git a/web/src/views/Pools.tsx b/web/src/views/Pools.tsx index 3e9206e..8f9668f 100644 --- a/web/src/views/Pools.tsx +++ b/web/src/views/Pools.tsx @@ -6,8 +6,6 @@ import { useApp } from '../ui/store'; import { Spinner } from '../components/ui'; import { PoolCard } from '../components/PoolCard'; import { useModal } from '../components/Modal'; -import { getProvider } from '../data'; -import { errorMessage } from '../ui/store'; type Filter = 'all' | 'ok' | 'warn'; @@ -16,7 +14,6 @@ export default function Pools() { const { openModal } = useModal(); const { data, loading, reload, setData } = useData((p) => p.getPools()); const [filter, setFilter] = useState('all'); - const [msg, setMsg] = useState(''); // Tiempo real: progreso de scrub y temperaturas de vdevs sin recargar todo useEffect(() => subscribeEvents((ev) => { @@ -35,14 +32,6 @@ export default function Pools() { // eslint-disable-next-line react-hooks/exhaustive-deps }), []); - const importPool = async () => { - setMsg(''); - try { - const list = await getProvider().importPool(); - setMsg(list.length ? `Importables: ${list.join(', ')}` : t('empty')); - } catch (e) { setMsg(errorMessage(e, t)); } - }; - const filtered = (data ?? []).filter((p) => filter === 'all' ? true : filter === 'ok' ? p.status === 'ONLINE' : p.status !== 'ONLINE'); @@ -58,12 +47,11 @@ export default function Pools() { {filtered.map((p) => )} {data && filtered.length === 0 &&
{t('empty')}
} - {msg &&

{msg}

}
+ title={!isAdmin ? t('no_permission') : undefined} onClick={() => openModal('importpool')}>{t('pool_import')}
);