Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions web/src/components/Modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function ModalHost() {
switch (modal.name) {
case 'newsnap': return <SnapshotModal preset={p.dataset as string | undefined} onClose={closeModal} />;
case 'newpool': return <NewPoolModal onClose={closeModal} />;
case 'importpool': return <ImportPoolModal preset={p.name as string | undefined} onClose={closeModal} />;
case 'newds': return <NewDatasetModal vol={!!p.vol} onClose={closeModal} />;
case 'editds': return <EditDatasetModal ds={p.ds as Dataset} onClose={closeModal} />;
case 'propsds': return <DatasetPropsModal ds={p.ds as Dataset} onClose={closeModal} />;
Expand Down Expand Up @@ -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<string[] | null>(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 (
<ModalBox onClose={onClose} label={t('imp_title')}>
<form onSubmit={submit}>
<h3>{t('imp_title')}</h3>
<p className="desc">{t('imp_desc')}</p>
{!list && <div style={{ padding: '18px 0', textAlign: 'center' }}><Spinner label={t('loading')} /></div>}
{list && list.length === 0 && (
<>
<div className="empty">{t('imp_empty')}</div>
{preset && <p className="form-err" role="alert">{t('imp_not_importable', { pool: preset })}</p>}
</>
)}
{list && list.length > 0 && (<>
<label id="imp-group-lbl">{t('imp_select')}</label>
<div role="radiogroup" aria-labelledby="imp-group-lbl">
{list.map((name) => (
<div key={name} className={`diskpick ${sel === name ? 'sel' : ''}`} role="radio"
aria-checked={sel === name} tabIndex={0}
onClick={() => { setSel(name); setConfirm(''); }}
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setSel(name); setConfirm(''); } }}>
<span className="mono">{name}</span>
<span className="muted" style={{ flex: 1 }}>{t('imp_pool_hint')}</span>
</div>
))}
</div>
{presetMissing && <p className="form-err" role="alert">{t('imp_not_importable', { pool: preset })}</p>}
<p className="desc" style={{ marginTop: 12, color: 'var(--warn)' }}>⚠️ {t('imp_warn')}</p>
<label htmlFor="imp-confirm">{t('ex_confirm_lbl_pool')}</label>
<input id="imp-confirm" placeholder={sel} value={confirm}
onChange={(e) => setConfirm(e.target.value)} autoComplete="off" />
</>)}
{err && <p className="form-err" role="alert">{err}</p>}
<div className="m-actions">
<button type="button" className="btn" onClick={onClose}>{t('cancel')}</button>
<SubmitBtn label={t('imp_btn')} busy={busy}
disabled={!isAdmin || !sel || confirm.trim() !== sel} />
</div>
</form>
</ModalBox>
);
}

// ---------- nuevo dataset / zvol ----------
function NewDatasetModal({ vol, onClose }: { vol: boolean; onClose: () => void }) {
const { t, refresh, notify } = useApp();
Expand Down
4 changes: 3 additions & 1 deletion web/src/data/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ export class HttpProvider implements DataProvider {
createPool = (r: CreatePoolReq) => post<void>('/pools', r);
importPool = async (name?: string): Promise<string[]> => {
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<void>(`/pools/${enc(pool)}/scrub`, { action });
Expand Down
14 changes: 14 additions & 0 deletions web/src/ui/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand All @@ -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 <nombre>). Ú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',
Expand Down Expand Up @@ -748,6 +755,7 @@ const en: Record<I18nKey, string> = {
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.',
Expand All @@ -767,6 +775,12 @@ const en: Record<I18nKey, string> = {

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 <name>). 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',
Expand Down
6 changes: 5 additions & 1 deletion web/src/views/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -126,6 +128,8 @@ export default function Dashboard() {
<b>{t('dash_missing_title')}: {m.name}</b>
<div className="muted" style={{ marginTop: 2 }}>{t('dash_missing_body', { pools: m.name, mins })}</div>
</div>
<button className="btn sm" disabled={!isAdmin} title={!isAdmin ? t('no_permission') : undefined}
onClick={() => openModal('importpool', { name: m.name })}>{t('dash_missing_import')}</button>
<button className="btn sm" onClick={() => navigate('pools')}>{t('dash_see_all')}</button>
</div>
);
Expand Down
14 changes: 1 addition & 13 deletions web/src/views/Pools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -16,7 +14,6 @@ export default function Pools() {
const { openModal } = useModal();
const { data, loading, reload, setData } = useData((p) => p.getPools());
const [filter, setFilter] = useState<Filter>('all');
const [msg, setMsg] = useState('');

// Tiempo real: progreso de scrub y temperaturas de vdevs sin recargar todo
useEffect(() => subscribeEvents((ev) => {
Expand All @@ -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');

Expand All @@ -58,12 +47,11 @@ export default function Pools() {
{filtered.map((p) => <PoolCard key={p.name} pool={p} onChanged={reload} />)}
</div>
{data && filtered.length === 0 && <div className="empty">{t('empty')}</div>}
{msg && <p className="desc" style={{ marginTop: 12, fontSize: 13, color: 'var(--text2)' }}>{msg}</p>}
<div className="sect">
<button className="btn primary" disabled={!isAdmin} title={!isAdmin ? t('no_permission') : undefined}
onClick={() => openModal('newpool')}>{t('pool_create')}</button>
<button className="btn" style={{ marginLeft: 8 }} disabled={!isAdmin}
title={!isAdmin ? t('no_permission') : undefined} onClick={importPool}>{t('pool_import')}</button>
title={!isAdmin ? t('no_permission') : undefined} onClick={() => openModal('importpool')}>{t('pool_import')}</button>
</div>
</div>
);
Expand Down
Loading