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
140 changes: 115 additions & 25 deletions internal/httpapi/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ package httpapi
import (
"context"
"net/http"
"os"
"runtime"
"strconv"
"strings"
"time"

"easyzfs/internal/db"
Expand Down Expand Up @@ -35,21 +37,109 @@ func (s *Server) getVersion(w http.ResponseWriter, r *http.Request) {
caps = c
}
}
writeJSON(w, http.StatusOK, map[string]any{
"name": "EasyZFS",
"version": s.version,
"build": s.build,
"go": runtime.Version(),
"os_arch": runtime.GOOS + "/" + runtime.GOARCH,
"uptime_sec": int64(time.Since(s.started).Seconds()),
"rss_bytes": memRSS(),
"db_bytes": db.SizeBytes(s.cfg.DBPath),
"db_path": s.cfg.DBPath,
"zfs_version": caps.Version,
"capabilities": caps,
"demo": s.cfg.Demo,
out := map[string]any{
"name": "EasyZFS",
"version": s.version,
"build": s.build,
"go": runtime.Version(),
"os_arch": runtime.GOOS + "/" + runtime.GOARCH,
"uptime_sec": int64(time.Since(s.started).Seconds()),
"rss_bytes": memRSS(),
"db_bytes": db.SizeBytes(s.cfg.DBPath),
"db_path": s.cfg.DBPath,
"zfs_version": caps.Version,
"capabilities": caps,
"demo": s.cfg.Demo,
"pendingUpdate": pendingUpdateJSON(s.updater),
})
}
// Métricas del host (best effort: se omiten si no se pueden leer).
if l1, l5, l15, ok := loadAvg(); ok {
out["load1"], out["load5"], out["load15"] = l1, l5, l15
}
if total, avail, ok := memInfo(); ok {
out["mem_total_bytes"], out["mem_avail_bytes"] = total, avail
}
if hit, size, ok := arcStats(); ok {
out["arc_hit_pct"], out["arc_size_bytes"] = hit, size
}
writeJSON(w, http.StatusOK, out)
}

// loadAvg lee /proc/loadavg (load 1/5/15).
func loadAvg() (l1, l5, l15 float64, ok bool) {
b, err := os.ReadFile("/proc/loadavg")
if err != nil {
return 0, 0, 0, false
}
f := strings.Fields(string(b))
if len(f) < 3 {
return 0, 0, 0, false
}
l1, err1 := strconv.ParseFloat(f[0], 64)
l5, err5 := strconv.ParseFloat(f[1], 64)
l15, err15 := strconv.ParseFloat(f[2], 64)
if err1 != nil || err5 != nil || err15 != nil {
return 0, 0, 0, false
}
return l1, l5, l15, true
}

// memInfo lee MemTotal/MemAvailable de /proc/meminfo (en bytes).
func memInfo() (total, avail uint64, ok bool) {
b, err := os.ReadFile("/proc/meminfo")
if err != nil {
return 0, 0, false
}
for _, line := range strings.Split(string(b), "\n") {
f := strings.Fields(line)
if len(f) < 2 {
continue
}
kb, err := strconv.ParseUint(f[1], 10, 64)
if err != nil {
continue
}
switch strings.TrimSuffix(f[0], ":") {
case "MemTotal":
total = kb * 1024
case "MemAvailable":
avail = kb * 1024
}
}
return total, avail, total > 0
}

// arcStats calcula el hit ratio del ARC de ZFS a partir de
// /proc/spl/kstat/zfs/arcstats (hits/(hits+misses)). Devuelve también el
// tamaño actual del ARC en bytes.
func arcStats() (hitPct float64, sizeBytes uint64, ok bool) {
b, err := os.ReadFile("/proc/spl/kstat/zfs/arcstats")
if err != nil {
return 0, 0, false
}
var hits, misses, size uint64
for _, line := range strings.Split(string(b), "\n") {
f := strings.Fields(line)
if len(f) < 3 {
continue
}
v, err := strconv.ParseUint(f[2], 10, 64)
if err != nil {
continue
}
switch f[0] {
case "hits":
hits = v
case "misses":
misses = v
case "size":
size = v
}
}
if hits+misses == 0 {
return 0, size, false
}
return float64(hits) / float64(hits+misses) * 100, size, true
}

// getSettings — GET /api/settings.
Expand Down Expand Up @@ -133,17 +223,17 @@ func validateSettings(st settingsBody) string {

// settingsBody — body de PUT /api/settings (idéntico a settings.Settings).
type settingsBody struct {
Lang string `json:"lang"`
CapWarnPct int `json:"cap_warn_pct"`
CapCritPct int `json:"cap_crit_pct"`
DiskTempC int `json:"disk_temp_c"`
Webhook string `json:"webhook"`
NotifyScrubErrors bool `json:"notify_scrub_errors"`
NotifySmartChange bool `json:"notify_smart_change"`
DemoEnabled bool `json:"demo_enabled"`
BackupEnabled bool `json:"backup_enabled"`
BackupFreqHours int `json:"backup_freq_hours"`
BackupRetentionDays int `json:"backup_retention_days"`
Lang string `json:"lang"`
CapWarnPct int `json:"cap_warn_pct"`
CapCritPct int `json:"cap_crit_pct"`
DiskTempC int `json:"disk_temp_c"`
Webhook string `json:"webhook"`
NotifyScrubErrors bool `json:"notify_scrub_errors"`
NotifySmartChange bool `json:"notify_smart_change"`
DemoEnabled bool `json:"demo_enabled"`
BackupEnabled bool `json:"backup_enabled"`
BackupFreqHours int `json:"backup_freq_hours"`
BackupRetentionDays int `json:"backup_retention_days"`
}

// listAlerts — GET /api/alerts → últimas 100.
Expand Down
72 changes: 72 additions & 0 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import ErrorBoundary from './components/ErrorBoundary';
import PullToRefresh from './components/PullToRefresh';
import { getProvider } from './data';
import { subscribeEvents } from './data/events';
import { fmtBytes, fmtBytesPair, fmtDuration } from './ui/format';
import type { VersionInfo } from './data/types';
import { timeAgo } from './ui/format';
import { lazyRetry } from './ui/lazyRetry';
import { useUpdateAvailable } from './ui/updatecheck';
Expand Down Expand Up @@ -118,6 +120,73 @@ function AlertsPanel({ onClose }: { onClose: () => void }) {
);
}

// Panel de métricas del host en el sidebar (uptime, load, RAM daemon/sistema,
// ARC hit). Los campos son best-effort: una fila solo se pinta si el server
// la reporta (/api/version). Se refresca cada 30 s.
function SideStats() {
const [v, setV] = useState<VersionInfo | null>(null);
useEffect(() => {
let alive = true;
const load = () => getProvider().getVersion().then((x) => alive && setV(x)).catch(() => {});
load();
const id = setInterval(load, 30000);
return () => { alive = false; clearInterval(id); };
}, []);
if (!v) return null;
const memUsed = v.mem_total_bytes && v.mem_avail_bytes != null
? v.mem_total_bytes - v.mem_avail_bytes : null;
const memPct = memUsed != null && v.mem_total_bytes
? Math.round((memUsed / v.mem_total_bytes) * 100) : null;
return (
<div className="side-stats">
<div className="ss-row">
<span className="ss-k">UPTIME</span>
<span className="ss-v g">{fmtDuration(v.uptime_sec)}</span>
</div>
{v.load1 != null && (
<div className="ss-row">
<span className="ss-k">LOAD</span>
<span className="ss-v">{v.load1.toFixed(2)} {v.load5?.toFixed(2)} {v.load15?.toFixed(2)}</span>
</div>
)}
<div className="ss-row">
<span className="ss-k">RAM · daemon</span>
<span className="ss-v">{fmtBytes(v.rss_bytes)}</span>
</div>
{memUsed != null && v.mem_total_bytes != null && (
<div className="ss-row ss-col">
<div className="ss-line">
<span className="ss-k">RAM · system</span>
<span className="ss-v">{(() => { const p = fmtBytesPair(memUsed, v.mem_total_bytes); return `${p.used} / ${p.total}`; })()}</span>
</div>
{memPct != null && <div className="minibar"><i style={{ width: `${memPct}%` }} /></div>}
</div>
)}
{v.arc_hit_pct != null && (
<div className="ss-row">
<span className="ss-k">ARC HIT</span>
<span className="ss-v g">{v.arc_hit_pct.toFixed(1)}%</span>
</div>
)}
</div>
);
}

// Reloj de consola en la topbar (mono, tabular; oculto < 1100px por CSS).
function Clock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const p = (n: number) => String(n).padStart(2, '0');
return (
<span className="clock" aria-hidden="true">
{p(now.getHours())}:{p(now.getMinutes())}<b>:{p(now.getSeconds())}</b>
</span>
);
}

function Shell() {
const { t, route, navigate, demo, exitDemo, user, isAdmin, ready, family, mode, themeEff, setMode } = useApp();
const { openModal } = useModal();
Expand Down Expand Up @@ -223,6 +292,7 @@ function Shell() {
);
})}
</nav>
{!collapsed && <SideStats />}
<div className="sidefoot">
<div className="sideactions">
<a href="#/settings" className={active === 'settings' ? 'active' : ''}
Expand Down Expand Up @@ -296,6 +366,8 @@ function Shell() {
</div>
</div>
<div className="head-actions">
<span className="live-badge"><span className="live-dot" />LIVE</span>
<Clock />
<button className="iconbtn" title={t('a11y_alerts')} aria-label={t('a11y_alerts')}
style={{ position: 'relative' }} onClick={() => { setShowAlerts((v) => !v); setHasPending(false); }}>
<IconBell />
Expand Down
65 changes: 36 additions & 29 deletions web/src/components/PoolCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { errorMessage, useApp } from '../ui/store';
import { t } from '../ui/i18n';
import { fmtBytes, fmtBytesPair, fmtPct, fmtRatio, timeAgo } from '../ui/format';
import { statusLabel } from '../ui/labels';
import { Badge, InfoBubble, Meter, Switch } from './ui';
import { Badge, InfoBubble, Switch } from './ui';
import { Sparkline } from './Sparkline';
import { useModal } from './Modal';
import { useEffect, useState } from 'react';
Expand Down Expand Up @@ -117,38 +117,43 @@ export function PoolCard({ pool, onChanged }: { pool: Pool; onChanged: () => voi
const canExpand = isAdmin && !!caps?.raidz_expansion &&
(pool.raidz_vdevs ?? []).length > 0 && free.length > 0 && !expanding;

const ledCls = ok ? 'g' : pool.status === 'DEGRADED' ? 'a' : 'r';
return (
<div className="card">
<div className="poolhead">
<div className="pool-head">
<span className={`led ${ledCls}`} />
<div className="grow">
<div className="t1" style={{ fontSize: 16, fontWeight: 700, display: 'flex', gap: 9, alignItems: 'center' }}>
<div className="pool-name">
{pool.name} <Badge tone={ok ? 'ok' : 'warn'}>{statusLabel(pool.status, t)}</Badge>
{pool.checkpoint && <Badge tone="info">{t('ck_badge')}</Badge>}
</div>
<div className="t2">{pool.topo}</div>
<TopoHelp topo={pool.topo} />
<Meter pct={pct} />
{hist.length > 0 && (
<div className="sparks" aria-label={t('pool_history_series')}>
{hist.sort((a, b) => a.days - b.days).map((h) => (
<span key={h.days} className="spark" title={t('pool_history_days', { days: h.days })}>
<Sparkline points={h.points} width={72} height={26}
ariaLabel={t('pool_history_days', { days: h.days })} />
<em>{h.days}d</em>
</span>
))}
</div>
)}
<div className="pool-raid">{pool.topo} <TopoHelp topo={pool.topo} /></div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontWeight: 700, fontSize: 17 }}>
<div className="pool-cap">
<div className="big">
{cap.used}
<span className="muted" style={{ fontWeight: 500 }}>
{' '}{t('pool_of')} {cap.total}
</span>
<span className="dim">{' '}{t('pool_of')} {cap.total}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text2)' }}>{fmtPct(pct)} {t('pool_used')}</div>
<div className="pct">{fmtPct(pct)} {t('pool_used')}</div>
</div>
</div>

<div className="pool-bar">
<div className={`segbar ${pct >= 90 ? 'crit' : pct >= 80 ? 'warn' : ''}`}
role="progressbar" aria-valuenow={Math.round(pct)} aria-valuemin={0} aria-valuemax={100}>
<i style={{ width: `${Math.min(100, Math.max(0, pct))}%` }} />
</div>
{hist.length > 0 && (
<div className="sparks" aria-label={t('pool_history_series')}>
{hist.sort((a, b) => a.days - b.days).map((h) => (
<span key={h.days} className="spark" title={t('pool_history_days', { days: h.days })}>
<Sparkline points={h.points} width={72} height={26}
ariaLabel={t('pool_history_days', { days: h.days })} />
<em>{h.days}d</em>
</span>
))}
</div>
)}
</div>

<div className="poolmeta">
Expand Down Expand Up @@ -205,22 +210,24 @@ export function PoolCard({ pool, onChanged }: { pool: Pool; onChanged: () => voi
// que desaparece solo al terminar la reconstrucción.
if (v.replacing && v.status !== 'ONLINE') {
return (
<div className="vdev" key={v.dev} style={{ opacity: 0.55 }}>
<div className="vdev out" key={v.dev}>
<span className="led o" />
<span className="badge info" style={{ padding: '2px 7px' }}>{t('vdev_outgoing')}</span>
<span className="dname" title={v.dev}>{shortDev(v)}</span>
<span style={{ fontSize: 12, color: 'var(--text2)' }}>{t('vdev_outgoing_hint')}</span>
<span className="drole">{t('vdev_outgoing_hint')}</span>
</div>
);
}
return (
<div className="vdev" key={v.dev} style={v.replacing ? { flexWrap: 'wrap' } : undefined}>
<div className={`vdev${v.status !== 'ONLINE' ? ' faulted' : ''}`} key={v.dev} style={v.replacing ? { flexWrap: 'wrap' } : undefined}>
<span className={`led ${v.status === 'ONLINE' ? 'g' : v.status === 'OFFLINE' ? 'o' : 'r'}`} />
<span className={`badge ${v.status === 'ONLINE' ? 'ok' : 'err'}`} style={{ padding: '2px 7px' }}>{statusLabel(v.status, t)}</span>
<span className="dname" title={v.dev}>{shortDev(v)}</span>
{v.replacing && (
<span className="badge info" style={{ padding: '1px 7px' }} title={t('vdev_new_hint')}>{t('vdev_new')}</span>
)}
<span>{v.role !== '—' ? v.role : ''}</span>
<span style={{ marginLeft: 'auto' }}>{v.temp_c}°C</span>
<span className="drole">{v.role !== '—' ? v.role : ''}</span>
<span className="temp" style={{ marginLeft: 'auto' }}>{v.temp_c}°C</span>
{v.replacing ? (
<span className="vdevjoin" title={t('vdev_joining_hint')}>
{t('vdev_joining')} · {Math.round(pool.scrub.pct)}%
Expand Down Expand Up @@ -254,7 +261,7 @@ export function PoolCard({ pool, onChanged }: { pool: Pool; onChanged: () => voi

{err && <p className="form-err" style={{ padding: '0 16px' }} role="alert">{err}</p>}

<div style={{ display: 'flex', gap: 7, padding: '0 16px 15px', flexWrap: 'wrap' }}>
<div className="pool-actions">
{!resilvering && !expanding && (
<button className="btn sm" title={running ? t('pool_scrub_pause_hint') : t('pool_scrub_hint')}
onClick={() => scrub(running ? 'pause' : 'start')}>
Expand Down
5 changes: 3 additions & 2 deletions web/src/components/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,12 +194,13 @@ export function InfoBubble({ title, glyph = '?', children }: { title?: string; g
}

// Pie de tarjeta KPI con valor opcional de porcentaje
export function KpiCard({ label, value, small, foot, meter }: {
export function KpiCard({ label, value, small, foot, meter, led }: {
label: string; value: ReactNode; small?: string; foot?: ReactNode; meter?: number;
led?: 'g' | 'a' | 'r' | 'c' | 'o';
}) {
return (
<div className="card kpi">
<div className="lbl">{label}</div>
<div className="lbl">{led && <span className={`led ${led}`} />}{label}</div>
<div className="val">{value}{small ? <> <small>{small}</small></> : null}</div>
{meter !== undefined && <Meter pct={meter} />}
{foot && <div className="foot">{foot}</div>}
Expand Down
Loading
Loading