Monitoring: Server-Auslastung über P42-S.M. mit Detailansicht und 2-Tage-Verlauf
- Neue Prüfart "Server-Auslastung (P42-S.M.)": Adresse + Token (Token bleibt im Backend, wird nie an den Browser gegeben) - Karte zeigt CPU, RAM, vollste Platte, Load, Temperatur, Uptime - Klick öffnet große Ansicht: Kacheln, Platten, Threads, Temperaturen, Netzwerk, Verlaufsdiagramme (6 h / 24 h / 2 Tage) mit Tooltip und Tabelle - Hintergrund-Task speichert jede Minute einen Mittelwert (monitor_samples, 2 Tage), unabhängig davon, ob die Seite offen ist - Docker: zusätzlich im Netz p42, update-docker.sh legt es bei Bedarf an Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
94bda25516
commit
f16c8c73c6
13 files changed
+923
-35
No files matched your search
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
// Schlankes Zeitreihen-Diagramm (SVG): eine y-Achse, dünne Linien, Lücken bei fehlenden Messungen,
|
||||
// Fadenkreuz + Tooltip mit allen Reihen (Maus, Touch und Pfeiltasten).
|
||||
|
||||
const HEIGHT = 190;
|
||||
const M = { top: 10, right: 14, bottom: 24, left: 56 };
|
||||
|
||||
const timeFmt = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
const dayFmt = new Intl.DateTimeFormat('de-DE', { weekday: 'short' });
|
||||
const fullFmt = new Intl.DateTimeFormat('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
// "Runde" Obergrenze für die y-Achse: 1, 2, 2.5, 5 × 10^n – bei Bytes je Einheit (500 KB statt 488 KB)
|
||||
function niceMax(v, binary) {
|
||||
if (!(v > 0)) return 1;
|
||||
const unit = binary ? 1024 ** Math.max(0, Math.floor(Math.log(v) / Math.log(1024))) : 1;
|
||||
const m = v / unit;
|
||||
const p = 10 ** Math.floor(Math.log10(m));
|
||||
// Bei Bytes nur Stufen, deren Viertel sich glatt beschriften lassen (8 MB → 2, 4, 6, 8 MB)
|
||||
const steps = binary ? [1, 2, 4, 8, 10] : [1, 2, 2.5, 5, 10];
|
||||
return steps.map((f) => f * p).find((n) => n >= m) * unit;
|
||||
}
|
||||
|
||||
function tickStep(hours) {
|
||||
if (hours <= 6) return 3600;
|
||||
if (hours <= 24) return 4 * 3600;
|
||||
return 6 * 3600;
|
||||
}
|
||||
|
||||
export default function LineChart({ title, points, series, from, to, bucket, yMax, binary, format, loading }) {
|
||||
const wrap = useRef(null);
|
||||
const [width, setWidth] = useState(600);
|
||||
const [hover, setHover] = useState(null); // Index in points
|
||||
|
||||
useEffect(() => {
|
||||
const el = wrap.current;
|
||||
if (!el) return undefined;
|
||||
const ro = new ResizeObserver(([entry]) => setWidth(Math.max(260, entry.contentRect.width)));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const innerW = width - M.left - M.right;
|
||||
const innerH = HEIGHT - M.top - M.bottom;
|
||||
const x = (t) => M.left + ((t - from) / (to - from)) * innerW;
|
||||
|
||||
const top = useMemo(() => {
|
||||
if (yMax) return yMax;
|
||||
const max = Math.max(0, ...points.flatMap((p) => series.map((s) => p[s.key] ?? 0)));
|
||||
return niceMax(max * 1.1, binary);
|
||||
}, [points, series, yMax, binary]);
|
||||
const y = (v) => M.top + innerH - (v / top) * innerH;
|
||||
|
||||
// Pfade: bei Lücken (Server weg, Dashboard aus) neu ansetzen statt quer durchzuziehen
|
||||
const paths = useMemo(() => series.map((s) => {
|
||||
let d = '';
|
||||
let prev = null;
|
||||
for (const p of points) {
|
||||
const v = p[s.key];
|
||||
if (v == null) { prev = null; continue; }
|
||||
const gap = prev && p.t - prev.t > bucket * 2.5;
|
||||
d += `${!prev || gap ? 'M' : 'L'}${x(p.t).toFixed(1)},${y(v).toFixed(1)}`;
|
||||
prev = p;
|
||||
}
|
||||
return d;
|
||||
}), [points, series, bucket, top, width, from, to]);
|
||||
|
||||
const yTicks = [0, 0.25, 0.5, 0.75, 1].map((f) => f * top);
|
||||
const xTicks = [];
|
||||
const step = tickStep((to - from) / 3600);
|
||||
// Ticks auf volle Stunden in lokaler Zeit
|
||||
const offset = new Date(from * 1000).getTimezoneOffset() * 60;
|
||||
for (let t = Math.ceil((from - offset) / step) * step + offset; t <= to; t += step) xTicks.push(t);
|
||||
|
||||
function pick(clientX) {
|
||||
if (!points.length) return;
|
||||
const rect = wrap.current.getBoundingClientRect();
|
||||
const t = from + ((clientX - rect.left - M.left) / innerW) * (to - from);
|
||||
let best = 0;
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
if (Math.abs(points[i].t - t) < Math.abs(points[best].t - t)) best = i;
|
||||
}
|
||||
setHover(best);
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (!points.length) return;
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
const dir = e.key === 'ArrowLeft' ? -1 : 1;
|
||||
setHover((h) => Math.min(points.length - 1, Math.max(0, (h ?? points.length - 1) + (h == null ? 0 : dir))));
|
||||
} else if (e.key === 'Escape') {
|
||||
setHover(null);
|
||||
}
|
||||
}
|
||||
|
||||
const hp = hover != null ? points[hover] : null;
|
||||
const last = points.at(-1);
|
||||
const tipLeft = hp ? Math.min(Math.max(x(hp.t), 90), width - 90) : 0;
|
||||
|
||||
return (
|
||||
<figure className={`chart ${loading ? 'is-loading' : ''}`}>
|
||||
<figcaption className="chart-head">
|
||||
<span className="chart-title">{title}</span>
|
||||
{series.length > 1 && (
|
||||
<span className="chart-legend">
|
||||
{series.map((s) => (
|
||||
<span key={s.key} className="chart-key">
|
||||
<i style={{ background: s.color }} />
|
||||
{s.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</figcaption>
|
||||
|
||||
<div className="chart-plot" ref={wrap}>
|
||||
{!points.length ? (
|
||||
<p className="chart-empty">Noch keine Werte – der Verlauf füllt sich jede Minute.</p>
|
||||
) : (
|
||||
<svg
|
||||
width={width}
|
||||
height={HEIGHT}
|
||||
role="img"
|
||||
aria-label={`${title}: Verlauf. Mit Pfeiltasten durch die Werte gehen.`}
|
||||
tabIndex={0}
|
||||
onPointerMove={(e) => pick(e.clientX)}
|
||||
onPointerDown={(e) => pick(e.clientX)}
|
||||
onPointerLeave={(e) => e.pointerType === 'mouse' && setHover(null)}
|
||||
onKeyDown={onKey}
|
||||
onBlur={() => setHover(null)}
|
||||
>
|
||||
{yTicks.map((v) => (
|
||||
<g key={v}>
|
||||
<line className="chart-grid" x1={M.left} x2={width - M.right} y1={y(v)} y2={y(v)} />
|
||||
<text className="chart-axis" x={M.left - 8} y={y(v)} dy="0.32em" textAnchor="end">{format(v, true)}</text>
|
||||
</g>
|
||||
))}
|
||||
{xTicks.map((t) => {
|
||||
const d = new Date(t * 1000);
|
||||
const label = d.getHours() === 0 ? dayFmt.format(d) : timeFmt.format(d);
|
||||
return (
|
||||
<text key={t} className="chart-axis" x={x(t)} y={HEIGHT - 6} textAnchor="middle">{label}</text>
|
||||
);
|
||||
})}
|
||||
{series.map((s, i) => (
|
||||
<path key={s.key} d={paths[i]} className="chart-line" style={{ stroke: s.color }} />
|
||||
))}
|
||||
{/* Letzter Wert als Punkt: zeigt, wo "jetzt" ist */}
|
||||
{!hp && last && series.map((s) => last[s.key] != null && (
|
||||
<circle key={s.key} className="chart-dot" cx={x(last.t)} cy={y(last[s.key])} r={4} style={{ fill: s.color }} />
|
||||
))}
|
||||
{hp && (
|
||||
<g>
|
||||
<line className="chart-cross" x1={x(hp.t)} x2={x(hp.t)} y1={M.top} y2={M.top + innerH} />
|
||||
{series.map((s) => hp[s.key] != null && (
|
||||
<circle key={s.key} className="chart-dot" cx={x(hp.t)} cy={y(hp[s.key])} r={4.5} style={{ fill: s.color }} />
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
{hp && (
|
||||
<div className="chart-tip" style={{ left: tipLeft }} role="status">
|
||||
<div className="chart-tip-time">{fullFmt.format(new Date(hp.t * 1000))}</div>
|
||||
{series.map((s) => (
|
||||
<div key={s.key} className="chart-tip-row">
|
||||
<i style={{ background: s.color }} />
|
||||
<strong>{hp[s.key] == null ? '—' : format(hp[s.key])}</strong>
|
||||
<span>{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
fmtBytes, fmtNum, fmtPct, fmtRate, fmtTemp, fmtUptime, level, maxTemp,
|
||||
} from '../serverStats.js';
|
||||
import LineChart from './LineChart.jsx';
|
||||
|
||||
// Große Ansicht eines P42-S.M.-Servers: aktuelle Werte (alle 5 s) + Verlauf bis 2 Tage (jede Minute neu)
|
||||
|
||||
const LIVE_SECONDS = 5;
|
||||
const RANGES = [6, 24, 48];
|
||||
const S1 = 'var(--series-1)';
|
||||
const S2 = 'var(--series-2)';
|
||||
const pct = (v, axis) => (axis ? `${Math.round(v)} %` : fmtPct(v));
|
||||
const rate = (v, axis) => (axis ? fmtBytes(v, 1).replace(',0 ', ' ') : fmtRate(v));
|
||||
const tableTime = new Intl.DateTimeFormat('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
export default function ServerDetail({ monitor, initial, onClose }) {
|
||||
const dialog = useRef(null);
|
||||
const [live, setLive] = useState(initial || null);
|
||||
const [hours, setHours] = useState(48);
|
||||
const [history, setHistory] = useState(null);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
dialog.current?.showModal();
|
||||
}, []);
|
||||
|
||||
const loadLive = useCallback(async () => {
|
||||
try {
|
||||
setLive(await api('GET', `/api/monitors/${monitor.id}/metrics`));
|
||||
} catch (e) {
|
||||
setLive({ ok: false, detail: e.message });
|
||||
}
|
||||
}, [monitor.id]);
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
setLoadingHistory(true);
|
||||
try {
|
||||
setHistory(await api('GET', `/api/monitors/${monitor.id}/history?hours=${hours}`));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoadingHistory(false);
|
||||
}
|
||||
}, [monitor.id, hours]);
|
||||
|
||||
useEffect(() => {
|
||||
loadLive();
|
||||
const timer = setInterval(loadLive, LIVE_SECONDS * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadLive]);
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
const timer = setInterval(loadHistory, 60 * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadHistory]);
|
||||
|
||||
const m = live?.ok ? live.metrics : null;
|
||||
const to = Math.floor(Date.now() / 1000);
|
||||
const from = to - hours * 3600;
|
||||
const points = history?.points || [];
|
||||
const chart = { points, from, to, bucket: history?.bucketSeconds || 60, loading: loadingHistory };
|
||||
const hasTemp = points.some((p) => p.temp != null);
|
||||
const realNets = (m?.network?.interfaces || []).filter((i) => !i.virtual);
|
||||
const virtualNets = (m?.network?.interfaces || []).filter((i) => i.virtual);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
className="modal server-modal"
|
||||
ref={dialog}
|
||||
onClose={onClose}
|
||||
onClick={(e) => e.target === dialog.current && dialog.current.close()}
|
||||
>
|
||||
<div className="server-detail">
|
||||
<header className="server-detail-head">
|
||||
<div>
|
||||
<h2>{monitor.name}</h2>
|
||||
{m ? (
|
||||
<p className="server-detail-sub">
|
||||
{m.host.hostname} · {m.host.os} · Kernel {m.host.kernel} · läuft seit {fmtUptime(m.host.uptimeSeconds)}
|
||||
<br />
|
||||
{m.cpu.model} · {m.cpu.threads} Threads{m.cpu.mhz ? ` · ${fmtNum(m.cpu.mhz, 0)} MHz` : ''}
|
||||
</p>
|
||||
) : (
|
||||
<p className="server-detail-sub">{monitor.target}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="server-detail-actions">
|
||||
<span className={`badge ${!live ? 'muted' : live.ok ? 'ok' : 'bad'}`}>
|
||||
{!live ? 'Lade …' : live.ok ? 'Online' : live.detail || 'Offline'}
|
||||
</span>
|
||||
<button className="btn ghost" type="button" onClick={() => dialog.current.close()} aria-label="Schließen">✕</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{m && (
|
||||
<div className="server-tiles">
|
||||
<Tile label="CPU" value={fmtPct(m.cpu.percent)} pct={m.cpu.percent} sub={`iowait ${fmtPct(m.cpu.iowaitPercent)}`} />
|
||||
<Tile
|
||||
label="RAM"
|
||||
value={fmtPct(m.memory.percent)}
|
||||
pct={m.memory.percent}
|
||||
sub={`${fmtBytes(m.memory.usedBytes)} von ${fmtBytes(m.memory.totalBytes)}`}
|
||||
/>
|
||||
<Tile
|
||||
label="Swap"
|
||||
value={m.swap.totalBytes ? fmtPct(m.swap.percent) : 'aus'}
|
||||
pct={m.swap.totalBytes ? m.swap.percent : null}
|
||||
sub={m.swap.totalBytes ? `${fmtBytes(m.swap.usedBytes)} von ${fmtBytes(m.swap.totalBytes)}` : ''}
|
||||
/>
|
||||
<Tile label="Load" value={fmtNum(m.cpu.load['1m'])} sub={`5 min ${fmtNum(m.cpu.load['5m'])} · 15 min ${fmtNum(m.cpu.load['15m'])}`} />
|
||||
<Tile label="Temperatur" value={fmtTemp(maxTemp(m))} sub="höchster Sensor" />
|
||||
<Tile label="Netzwerk" value={`↓ ${fmtRate(m.network.rxBytesPerSec)}`} sub={`↑ ${fmtRate(m.network.txBytesPerSec)}`} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="server-range" role="group" aria-label="Zeitraum">
|
||||
<span className="eyebrow">Verlauf</span>
|
||||
{RANGES.map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
className={`chip ${hours === h ? 'active' : ''}`}
|
||||
aria-pressed={hours === h}
|
||||
onClick={() => setHours(h)}
|
||||
>
|
||||
{h === 48 ? '2 Tage' : `${h} h`}
|
||||
</button>
|
||||
))}
|
||||
{history && <span className="hint">Mittelwerte je {Math.round(history.bucketSeconds / 60)} min</span>}
|
||||
</div>
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
<div className="server-charts">
|
||||
<LineChart
|
||||
title="Auslastung"
|
||||
series={[{ key: 'cpu', label: 'CPU', color: S1 }, { key: 'mem', label: 'RAM', color: S2 }]}
|
||||
yMax={100}
|
||||
format={pct}
|
||||
{...chart}
|
||||
/>
|
||||
<LineChart
|
||||
title="Netzwerk"
|
||||
series={[{ key: 'rx', label: 'Empfangen', color: S1 }, { key: 'tx', label: 'Gesendet', color: S2 }]}
|
||||
format={rate}
|
||||
binary
|
||||
{...chart}
|
||||
/>
|
||||
<LineChart
|
||||
title="Platten-Aktivität"
|
||||
series={[{ key: 'read', label: 'Lesen', color: S1 }, { key: 'write', label: 'Schreiben', color: S2 }]}
|
||||
format={rate}
|
||||
binary
|
||||
{...chart}
|
||||
/>
|
||||
<LineChart title="Load (1 min)" series={[{ key: 'load', label: 'Load', color: S1 }]} format={(v) => fmtNum(v)} {...chart} />
|
||||
{hasTemp && (
|
||||
<LineChart title="Temperatur (höchster Sensor)" series={[{ key: 'temp', label: 'Temperatur', color: S1 }]} format={(v) => fmtTemp(v)} {...chart} />
|
||||
)}
|
||||
<LineChart title="Swap" series={[{ key: 'swap', label: 'Swap', color: S1 }]} yMax={100} format={pct} {...chart} />
|
||||
</div>
|
||||
|
||||
{points.length > 0 && (
|
||||
<details className="server-table">
|
||||
<summary>Verlauf als Tabelle</summary>
|
||||
<div className="server-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Zeit</th><th>CPU</th><th>RAM</th><th>Swap</th><th>Load</th><th>Empfangen</th><th>Gesendet</th><th>Lesen</th><th>Schreiben</th><th>Temp.</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...points].reverse().map((p) => (
|
||||
<tr key={p.t}>
|
||||
<td>{tableTime.format(new Date(p.t * 1000))}</td>
|
||||
<td>{fmtPct(p.cpu)}</td>
|
||||
<td>{fmtPct(p.mem)}</td>
|
||||
<td>{fmtPct(p.swap)}</td>
|
||||
<td>{fmtNum(p.load)}</td>
|
||||
<td>{fmtRate(p.rx)}</td>
|
||||
<td>{fmtRate(p.tx)}</td>
|
||||
<td>{fmtRate(p.read)}</td>
|
||||
<td>{fmtRate(p.write)}</td>
|
||||
<td>{fmtTemp(p.temp)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{m && (
|
||||
<div className="server-sections">
|
||||
<section>
|
||||
<h3>Platten</h3>
|
||||
{m.disks.map((d) => (
|
||||
<Meter
|
||||
key={d.device}
|
||||
label={d.mount}
|
||||
pct={d.percent}
|
||||
detail={`${fmtBytes(d.usedBytes)} von ${fmtBytes(d.totalBytes)} · ${fmtBytes(d.freeBytes)} frei`}
|
||||
title={`${d.device} (${d.fs})`}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>CPU-Threads</h3>
|
||||
<div className="server-cores">
|
||||
{m.cpu.perCorePercent.map((v, i) => (
|
||||
<Meter key={i} label={`#${i}`} pct={v} compact />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{m.temperatures.length > 0 && (
|
||||
<section>
|
||||
<h3>Temperaturen</h3>
|
||||
<dl className="server-list">
|
||||
{m.temperatures.map((t) => (
|
||||
<div key={`${t.sensor}-${t.label}`}>
|
||||
<dt>{t.sensor} · {t.label}</dt>
|
||||
<dd>
|
||||
{fmtTemp(t.celsius)}
|
||||
{t.criticalCelsius ? <small> / kritisch {fmtTemp(t.criticalCelsius)}</small> : null}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h3>Netzwerk</h3>
|
||||
<dl className="server-list">
|
||||
{realNets.map((n) => (
|
||||
<div key={n.interface}>
|
||||
<dt>{n.interface}</dt>
|
||||
<dd>↓ {fmtRate(n.rxBytesPerSec)} · ↑ {fmtRate(n.txBytesPerSec)}<small> · gesamt ↓ {fmtBytes(n.rxBytesTotal)} ↑ {fmtBytes(n.txBytesTotal)}</small></dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{virtualNets.length > 0 && (
|
||||
<details>
|
||||
<summary className="hint">{virtualNets.length} Docker-Netze</summary>
|
||||
<dl className="server-list">
|
||||
{virtualNets.map((n) => (
|
||||
<div key={n.interface}>
|
||||
<dt>{n.interface}</dt>
|
||||
<dd>↓ {fmtRate(n.rxBytesPerSec)} · ↑ {fmtRate(n.txBytesPerSec)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
)}
|
||||
<p className="hint">
|
||||
Prozesse: {m.processes.total} · Platten gerade ↓ {fmtRate(m.diskIo.readBytesPerSec)} ↑ {fmtRate(m.diskIo.writeBytesPerSec)}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({ label, value, sub, pct }) {
|
||||
const lv = level(pct);
|
||||
return (
|
||||
<div className={`server-tile ${lv}`}>
|
||||
<span className="eyebrow">{label}</span>
|
||||
<strong>{value}{lv === 'bad' && <span className="sr-flag"> kritisch</span>}</strong>
|
||||
{sub && <small>{sub}</small>}
|
||||
{pct != null && <span className="meter-bar"><i style={{ width: `${Math.min(100, pct)}%` }} /></span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Meter({ label, pct, detail, title, compact }) {
|
||||
const lv = level(pct);
|
||||
return (
|
||||
<div className={`meter ${lv} ${compact ? 'compact' : ''}`} title={title}>
|
||||
<div className="meter-label">
|
||||
<span>{label}</span>
|
||||
<strong>{fmtPct(pct)}{lv === 'bad' && !compact ? ' · kritisch' : ''}</strong>
|
||||
</div>
|
||||
<span className="meter-bar"><i style={{ width: `${Math.min(100, pct || 0)}%` }} /></span>
|
||||
{detail && <small>{detail}</small>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user