Introduced a mechanism to detect and handle when a target is unreachable, including a detailed offline state message with host and port information. Updated the UI to display a card notifying users of the target's offline status and styled the card accordingly in CSS.
606 lines
24 KiB
JavaScript
606 lines
24 KiB
JavaScript
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
|
import { apiFetch } from "../api";
|
|
import { useAuth } from "../state";
|
|
|
|
const ranges = {
|
|
"15m": 15 * 60 * 1000,
|
|
"1h": 60 * 60 * 1000,
|
|
"24h": 24 * 60 * 60 * 1000,
|
|
"7d": 7 * 24 * 60 * 60 * 1000,
|
|
};
|
|
|
|
function toQueryRange(range) {
|
|
const to = new Date();
|
|
const from = new Date(to.getTime() - ranges[range]);
|
|
return { from: from.toISOString(), to: to.toISOString() };
|
|
}
|
|
|
|
function formatBytes(value) {
|
|
if (value === null || value === undefined) return "-";
|
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
let n = Number(value);
|
|
let i = 0;
|
|
while (n >= 1024 && i < units.length - 1) {
|
|
n /= 1024;
|
|
i += 1;
|
|
}
|
|
return `${n.toFixed(i >= 2 ? 2 : 0)} ${units[i]}`;
|
|
}
|
|
|
|
function formatSeconds(value) {
|
|
if (value === null || value === undefined) return "-";
|
|
if (value < 60) return `${value.toFixed(1)}s`;
|
|
if (value < 3600) return `${(value / 60).toFixed(1)}m`;
|
|
return `${(value / 3600).toFixed(1)}h`;
|
|
}
|
|
|
|
function formatNumber(value, digits = 2) {
|
|
if (value === null || value === undefined || Number.isNaN(Number(value))) return "-";
|
|
return Number(value).toFixed(digits);
|
|
}
|
|
|
|
function formatHostMetricUnavailable() {
|
|
return "N/A (agentless)";
|
|
}
|
|
|
|
function formatDiskSpaceAgentless(diskSpace) {
|
|
if (!diskSpace) return formatHostMetricUnavailable();
|
|
if (diskSpace.free_bytes !== null && diskSpace.free_bytes !== undefined) {
|
|
return formatBytes(diskSpace.free_bytes);
|
|
}
|
|
if (diskSpace.status === "unavailable") return formatHostMetricUnavailable();
|
|
return "-";
|
|
}
|
|
|
|
function MetricsTooltip({ active, payload, label }) {
|
|
if (!active || !payload || payload.length === 0) return null;
|
|
const row = payload[0]?.payload || {};
|
|
return (
|
|
<div className="chart-tooltip">
|
|
<div className="chart-tooltip-time">{label}</div>
|
|
<div className="chart-tooltip-item c1">connections: {formatNumber(row.connections, 0)}</div>
|
|
<div className="chart-tooltip-item c2">tps: {formatNumber(row.tps, 2)}</div>
|
|
<div className="chart-tooltip-item c3">cache: {formatNumber(row.cache, 2)}%</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function didMetricSeriesChange(prev = [], next = []) {
|
|
if (!Array.isArray(prev) || !Array.isArray(next)) return true;
|
|
if (prev.length !== next.length) return true;
|
|
if (prev.length === 0 && next.length === 0) return false;
|
|
const prevLast = prev[prev.length - 1];
|
|
const nextLast = next[next.length - 1];
|
|
return prevLast?.ts !== nextLast?.ts || Number(prevLast?.value) !== Number(nextLast?.value);
|
|
}
|
|
|
|
function isTargetUnreachableError(err) {
|
|
return err?.code === "target_unreachable" || err?.status === 503;
|
|
}
|
|
|
|
async function loadMetric(targetId, metric, range, tokens, refresh) {
|
|
const { from, to } = toQueryRange(range);
|
|
return apiFetch(
|
|
`/targets/${targetId}/metrics?metric=${encodeURIComponent(metric)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
|
|
{},
|
|
tokens,
|
|
refresh
|
|
);
|
|
}
|
|
|
|
export function TargetDetailPage() {
|
|
const { id } = useParams();
|
|
const navigate = useNavigate();
|
|
const { tokens, refresh, uiMode } = useAuth();
|
|
const [range, setRange] = useState("1h");
|
|
const [liveMode, setLiveMode] = useState(false);
|
|
const [series, setSeries] = useState({});
|
|
const [locks, setLocks] = useState([]);
|
|
const [activity, setActivity] = useState([]);
|
|
const [overview, setOverview] = useState(null);
|
|
const [targetMeta, setTargetMeta] = useState(null);
|
|
const [owners, setOwners] = useState([]);
|
|
const [groupTargets, setGroupTargets] = useState([]);
|
|
const [offlineState, setOfflineState] = useState(null);
|
|
const [error, setError] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
const refreshRef = useRef(refresh);
|
|
|
|
useEffect(() => {
|
|
refreshRef.current = refresh;
|
|
}, [refresh]);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
const loadAll = async () => {
|
|
if (!series.connections?.length) {
|
|
setLoading(true);
|
|
}
|
|
try {
|
|
const [connections, xacts, cache, targetInfo, ownerRows, allTargets] = await Promise.all([
|
|
loadMetric(id, "connections_total", range, tokens, refreshRef.current),
|
|
loadMetric(id, "xacts_total", range, tokens, refreshRef.current),
|
|
loadMetric(id, "cache_hit_ratio", range, tokens, refreshRef.current),
|
|
apiFetch(`/targets/${id}`, {}, tokens, refreshRef.current),
|
|
apiFetch(`/targets/${id}/owners`, {}, tokens, refreshRef.current),
|
|
apiFetch("/targets", {}, tokens, refreshRef.current),
|
|
]);
|
|
if (!active) return;
|
|
setSeries({ connections, xacts, cache });
|
|
setTargetMeta(targetInfo);
|
|
setOwners(ownerRows);
|
|
const groupId = targetInfo?.tags?.monitor_group_id;
|
|
if (groupId) {
|
|
const sameGroup = allTargets
|
|
.filter((item) => item?.tags?.monitor_group_id === groupId)
|
|
.sort((a, b) => (a.dbname || "").localeCompare(b.dbname || ""));
|
|
setGroupTargets(sameGroup);
|
|
} else {
|
|
setGroupTargets([]);
|
|
}
|
|
try {
|
|
const [locksTable, activityTable, overviewData] = await Promise.all([
|
|
apiFetch(`/targets/${id}/locks`, {}, tokens, refreshRef.current),
|
|
apiFetch(`/targets/${id}/activity`, {}, tokens, refreshRef.current),
|
|
apiFetch(`/targets/${id}/overview`, {}, tokens, refreshRef.current),
|
|
]);
|
|
if (!active) return;
|
|
setLocks(locksTable);
|
|
setActivity(activityTable);
|
|
setOverview(overviewData);
|
|
setOfflineState(null);
|
|
} catch (liveErr) {
|
|
if (!active) return;
|
|
if (isTargetUnreachableError(liveErr)) {
|
|
setLocks([]);
|
|
setActivity([]);
|
|
setOverview(null);
|
|
setOfflineState({
|
|
message:
|
|
"Target is currently unreachable. Check host/port, network route, SSL mode, and database availability.",
|
|
host: liveErr?.details?.host || targetInfo?.host || "-",
|
|
port: liveErr?.details?.port || targetInfo?.port || "-",
|
|
requestId: liveErr?.requestId || null,
|
|
});
|
|
} else {
|
|
throw liveErr;
|
|
}
|
|
}
|
|
setError("");
|
|
} catch (e) {
|
|
if (active) setError(String(e.message || e));
|
|
} finally {
|
|
if (active) setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadAll();
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [id, range, tokens?.accessToken, tokens?.refreshToken]);
|
|
|
|
useEffect(() => {
|
|
if (!liveMode) return;
|
|
let active = true;
|
|
const intervalId = setInterval(async () => {
|
|
try {
|
|
const [connections, xacts, cache] = await Promise.all([
|
|
loadMetric(id, "connections_total", "15m", tokens, refreshRef.current),
|
|
loadMetric(id, "xacts_total", "15m", tokens, refreshRef.current),
|
|
loadMetric(id, "cache_hit_ratio", "15m", tokens, refreshRef.current),
|
|
]);
|
|
if (!active) return;
|
|
const nextSeries = { connections, xacts, cache };
|
|
setSeries((prev) => {
|
|
const changed =
|
|
didMetricSeriesChange(prev.connections, nextSeries.connections) ||
|
|
didMetricSeriesChange(prev.xacts, nextSeries.xacts) ||
|
|
didMetricSeriesChange(prev.cache, nextSeries.cache);
|
|
return changed ? nextSeries : prev;
|
|
});
|
|
} catch {
|
|
// Keep previous chart values if a live tick fails.
|
|
}
|
|
}, 3000);
|
|
|
|
return () => {
|
|
active = false;
|
|
clearInterval(intervalId);
|
|
};
|
|
}, [liveMode, id, tokens?.accessToken, tokens?.refreshToken]);
|
|
|
|
const chartData = useMemo(
|
|
() => {
|
|
const con = series.connections || [];
|
|
const xacts = series.xacts || [];
|
|
const cache = series.cache || [];
|
|
return con.map((point, idx) => {
|
|
const prev = xacts[idx - 1];
|
|
const curr = xacts[idx];
|
|
let tps = 0;
|
|
if (prev && curr) {
|
|
const dt = (new Date(curr.ts).getTime() - new Date(prev.ts).getTime()) / 1000;
|
|
const dx = (curr.value || 0) - (prev.value || 0);
|
|
if (dt > 0 && dx >= 0) {
|
|
tps = dx / dt;
|
|
}
|
|
}
|
|
return {
|
|
ts: new Date(point.ts).toLocaleTimeString(),
|
|
connections: point.value,
|
|
tps,
|
|
cache: (cache[idx]?.value || 0) * 100,
|
|
};
|
|
});
|
|
},
|
|
[series]
|
|
);
|
|
|
|
const easySummary = useMemo(() => {
|
|
if (!overview) return null;
|
|
const latest = chartData[chartData.length - 1];
|
|
const issues = [];
|
|
const warnings = [];
|
|
|
|
if (overview.partial_failures?.length > 0) warnings.push("Some advanced metrics are currently unavailable.");
|
|
if ((overview.performance?.deadlocks || 0) > 0) issues.push("Deadlocks were detected.");
|
|
if ((overview.replication?.mode === "standby") && (overview.replication?.replay_lag_seconds || 0) > 5) {
|
|
issues.push("Replication lag is above 5 seconds.");
|
|
}
|
|
if ((latest?.cache || 0) > 0 && latest.cache < 90) warnings.push("Cache hit ratio is below 90%.");
|
|
if ((latest?.connections || 0) > 120) warnings.push("Connection count is relatively high.");
|
|
if ((locks?.length || 0) > 150) warnings.push("High number of active locks.");
|
|
|
|
const health = issues.length > 0 ? "problem" : warnings.length > 0 ? "warning" : "ok";
|
|
const message =
|
|
health === "ok"
|
|
? "Everything looks healthy. No major risks detected right now."
|
|
: health === "warning"
|
|
? "System is operational, but there are signals you should watch."
|
|
: "Attention required. Critical signals need investigation.";
|
|
|
|
return {
|
|
health,
|
|
message,
|
|
issues,
|
|
warnings,
|
|
latest,
|
|
};
|
|
}, [overview, chartData, locks]);
|
|
|
|
if (loading) return <div className="card">Loading target detail...</div>;
|
|
if (error) return <div className="card error">{error}</div>;
|
|
|
|
const role = overview?.instance?.role || "-";
|
|
const isPrimary = role === "primary";
|
|
const isStandby = role === "standby";
|
|
|
|
return (
|
|
<div>
|
|
<h2>
|
|
Target Detail {targetMeta?.name || `#${id}`}
|
|
{targetMeta?.dbname ? ` (${targetMeta.dbname})` : ""}
|
|
</h2>
|
|
{groupTargets.length > 1 && (
|
|
<div className="field target-db-switcher">
|
|
<label>Database in this target group</label>
|
|
<select
|
|
value={String(id)}
|
|
onChange={(e) => {
|
|
const targetId = e.target.value;
|
|
if (targetId && String(targetId) !== String(id)) {
|
|
navigate(`/targets/${targetId}`);
|
|
}
|
|
}}
|
|
>
|
|
{groupTargets.map((item) => (
|
|
<option key={item.id} value={item.id}>
|
|
{item.dbname} ({item.name})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
<div className="owner-row">
|
|
<span className="muted">Responsible users:</span>
|
|
{owners.length > 0 ? owners.map((item) => <span key={item.user_id} className="owner-pill">{item.email}</span>) : <span className="muted">none assigned</span>}
|
|
</div>
|
|
{offlineState && (
|
|
<div className="card target-offline-card">
|
|
<h3>Target Offline</h3>
|
|
<p>{offlineState.message}</p>
|
|
<div className="target-offline-meta">
|
|
<span><strong>Host:</strong> {offlineState.host}</span>
|
|
<span><strong>Port:</strong> {offlineState.port}</span>
|
|
{offlineState.requestId ? <span><strong>Request ID:</strong> {offlineState.requestId}</span> : null}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{uiMode === "easy" && overview && easySummary && (
|
|
<>
|
|
<div className={`card easy-status ${easySummary.health}`}>
|
|
<h3>System Health</h3>
|
|
<p>{easySummary.message}</p>
|
|
<div className="easy-badge-row">
|
|
<span className={`easy-badge ${easySummary.health}`}>
|
|
{easySummary.health === "ok" ? "OK" : easySummary.health === "warning" ? "Warning" : "Problem"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid three">
|
|
<div className="card stat">
|
|
<strong>{formatNumber(easySummary.latest?.connections, 0)}</strong>
|
|
<span>Current Connections</span>
|
|
</div>
|
|
<div className="card stat">
|
|
<strong>{formatNumber(easySummary.latest?.tps, 2)}</strong>
|
|
<span>Transactions/sec (approx)</span>
|
|
</div>
|
|
<div className="card stat">
|
|
<strong>{formatNumber(easySummary.latest?.cache, 2)}%</strong>
|
|
<span>Cache Hit Ratio</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h3>Quick Explanation</h3>
|
|
{easySummary.issues.length === 0 && easySummary.warnings.length === 0 && (
|
|
<p>No immediate problems were detected. Keep monitoring over time.</p>
|
|
)}
|
|
{easySummary.issues.length > 0 && (
|
|
<>
|
|
<strong>Problems</strong>
|
|
<ul className="easy-list">
|
|
{easySummary.issues.map((item, idx) => <li key={`i-${idx}`}>{item}</li>)}
|
|
</ul>
|
|
</>
|
|
)}
|
|
{easySummary.warnings.length > 0 && (
|
|
<>
|
|
<strong>Things to watch</strong>
|
|
<ul className="easy-list">
|
|
{easySummary.warnings.map((item, idx) => <li key={`w-${idx}`}>{item}</li>)}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid two">
|
|
<div className="card">
|
|
<h3>Instance Summary</h3>
|
|
<div className="overview-metrics">
|
|
<div><span>Role</span><strong>{overview.instance.role}</strong></div>
|
|
<div><span>Uptime</span><strong>{formatSeconds(overview.instance.uptime_seconds)}</strong></div>
|
|
<div><span>Target Port</span><strong>{targetMeta?.port ?? "-"}</strong></div>
|
|
<div><span>Current DB Size</span><strong>{formatBytes(overview.storage.current_database_size_bytes)}</strong></div>
|
|
<div><span>Replication Clients</span><strong>{overview.replication.active_replication_clients ?? "-"}</strong></div>
|
|
<div><span>Autovacuum Workers</span><strong>{overview.performance.autovacuum_workers ?? "-"}</strong></div>
|
|
</div>
|
|
</div>
|
|
<div className="card">
|
|
<h3>Activity Snapshot</h3>
|
|
<div className="overview-metrics">
|
|
<div><span>Running Sessions</span><strong>{activity.filter((a) => a.state === "active").length}</strong></div>
|
|
<div><span>Total Sessions</span><strong>{activity.length}</strong></div>
|
|
<div><span>Current Locks</span><strong>{locks.length}</strong></div>
|
|
<div><span>Deadlocks</span><strong>{overview.performance.deadlocks ?? 0}</strong></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{uiMode === "dba" && overview && (
|
|
<div className="card">
|
|
<h3>Database Overview</h3>
|
|
<p className="muted" style={{ marginTop: 2 }}>
|
|
Agentless mode: host-level CPU, RAM, and free-disk metrics are not available.
|
|
</p>
|
|
<div className="grid three overview-kv">
|
|
<div><span>PostgreSQL Version</span><strong>{overview.instance.server_version || "-"}</strong></div>
|
|
<div>
|
|
<span>Role</span>
|
|
<strong className={isPrimary ? "pill primary" : isStandby ? "pill standby" : "pill"}>{role}</strong>
|
|
</div>
|
|
<div title="Time since PostgreSQL postmaster start">
|
|
<span>Uptime</span><strong>{formatSeconds(overview.instance.uptime_seconds)}</strong>
|
|
</div>
|
|
<div><span>Database</span><strong>{overview.instance.current_database || "-"}</strong></div>
|
|
<div>
|
|
<span>Target Port</span>
|
|
<strong>{targetMeta?.port ?? "-"}</strong>
|
|
</div>
|
|
<div title="Current database total size">
|
|
<span>Current DB Size</span><strong>{formatBytes(overview.storage.current_database_size_bytes)}</strong>
|
|
</div>
|
|
<div title="Total WAL directory size (when available)">
|
|
<span>WAL Size</span><strong>{formatBytes(overview.storage.wal_directory_size_bytes)}</strong>
|
|
</div>
|
|
<div title={overview.storage.disk_space?.message || "Agentless mode: host-level free disk is unavailable."}>
|
|
<span>Free Disk</span><strong>{formatDiskSpaceAgentless(overview.storage.disk_space)}</strong>
|
|
</div>
|
|
<div title="Replication replay delay on standby">
|
|
<span>Replay Lag</span>
|
|
<strong className={overview.replication.replay_lag_seconds > 5 ? "lag-bad" : ""}>
|
|
{formatSeconds(overview.replication.replay_lag_seconds)}
|
|
</strong>
|
|
</div>
|
|
<div><span>Replication Slots</span><strong>{overview.replication.replication_slots_count ?? "-"}</strong></div>
|
|
<div><span>Repl Clients</span><strong>{overview.replication.active_replication_clients ?? "-"}</strong></div>
|
|
<div><span>Autovacuum Workers</span><strong>{overview.performance.autovacuum_workers ?? "-"}</strong></div>
|
|
<div title="Host CPU requires OS-level telemetry">
|
|
<span>Host CPU</span><strong>{formatHostMetricUnavailable()}</strong>
|
|
</div>
|
|
<div title="Host RAM requires OS-level telemetry">
|
|
<span>Host RAM</span><strong>{formatHostMetricUnavailable()}</strong>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid two">
|
|
<div>
|
|
<h4>Size all databases</h4>
|
|
<table>
|
|
<thead><tr><th>Database</th><th>Size</th></tr></thead>
|
|
<tbody>
|
|
{(overview.storage.all_databases || []).map((d) => (
|
|
<tr key={d.name}><td>{d.name}</td><td>{formatBytes(d.size_bytes)}</td></tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div>
|
|
<h4>Largest Tables (Top 5)</h4>
|
|
<table>
|
|
<thead><tr><th>Table</th><th>Size</th></tr></thead>
|
|
<tbody>
|
|
{(overview.storage.largest_tables || []).map((t, i) => (
|
|
<tr key={`${t.schema}.${t.table}.${i}`}><td>{t.schema}.{t.table}</td><td>{formatBytes(t.size_bytes)}</td></tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid two">
|
|
<div>
|
|
<h4>Replication Clients</h4>
|
|
<table>
|
|
<thead><tr><th>Client</th><th>State</th><th>Lag</th></tr></thead>
|
|
<tbody>
|
|
{(overview.replication.clients || []).map((c, i) => (
|
|
<tr key={`${c.client_addr || "n/a"}-${i}`}>
|
|
<td>{c.client_addr || c.application_name || "-"}</td>
|
|
<td>{c.state || "-"}</td>
|
|
<td className={(c.replay_lag_seconds || 0) > 5 ? "lag-bad" : ""}>
|
|
{c.replay_lag_seconds != null ? formatSeconds(c.replay_lag_seconds) : formatBytes(c.replay_lag_bytes)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div>
|
|
<h4>Performance Core Metrics</h4>
|
|
<div className="overview-metrics">
|
|
<div><span>xact_commit</span><strong>{overview.performance.xact_commit ?? "-"}</strong></div>
|
|
<div><span>xact_rollback</span><strong>{overview.performance.xact_rollback ?? "-"}</strong></div>
|
|
<div><span>deadlocks</span><strong>{overview.performance.deadlocks ?? "-"}</strong></div>
|
|
<div><span>temp_files</span><strong>{overview.performance.temp_files ?? "-"}</strong></div>
|
|
<div><span>temp_bytes</span><strong>{formatBytes(overview.performance.temp_bytes)}</strong></div>
|
|
<div><span>blk_read_time</span><strong>{overview.performance.blk_read_time ?? "-"} ms</strong></div>
|
|
<div><span>blk_write_time</span><strong>{overview.performance.blk_write_time ?? "-"} ms</strong></div>
|
|
<div><span>checkpoints_timed</span><strong>{overview.performance.checkpoints_timed ?? "-"}</strong></div>
|
|
<div><span>checkpoints_req</span><strong>{overview.performance.checkpoints_req ?? "-"}</strong></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{overview.partial_failures?.length > 0 && (
|
|
<div className="partial-warning">
|
|
<strong>Partial data warnings:</strong>
|
|
<ul>
|
|
{overview.partial_failures.map((w, i) => <li key={i}>{w}</li>)}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="range-picker">
|
|
<button
|
|
type="button"
|
|
className={`live-btn ${liveMode ? "active" : ""}`}
|
|
onClick={() => {
|
|
setLiveMode((prev) => {
|
|
const next = !prev;
|
|
if (next) setRange("15m");
|
|
return next;
|
|
});
|
|
}}
|
|
>
|
|
LIVE
|
|
</button>
|
|
{Object.keys(ranges).map((r) => (
|
|
<button
|
|
key={r}
|
|
onClick={() => {
|
|
setLiveMode(false);
|
|
setRange(r);
|
|
}}
|
|
className={r === range ? "active" : ""}
|
|
>
|
|
{r}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="card" style={{ height: 320 }}>
|
|
<h3>Connections / TPS (approx) / Cache Hit Ratio</h3>
|
|
<ResponsiveContainer width="100%" height="85%">
|
|
<LineChart data={chartData}>
|
|
<XAxis dataKey="ts" hide />
|
|
<YAxis yAxisId="left" />
|
|
<YAxis yAxisId="right" orientation="right" domain={[0, 100]} />
|
|
<Tooltip content={<MetricsTooltip />} />
|
|
<Line yAxisId="left" type="monotone" dataKey="connections" stroke="#38bdf8" dot={false} strokeWidth={2} isAnimationActive={false} />
|
|
<Line yAxisId="left" type="monotone" dataKey="tps" stroke="#22c55e" dot={false} strokeWidth={2} isAnimationActive={false} />
|
|
<Line yAxisId="right" type="monotone" dataKey="cache" stroke="#f59e0b" dot={false} strokeWidth={2} isAnimationActive={false} />
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
<div className="grid two">
|
|
<div className="card">
|
|
<h3>Locks</h3>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Type</th>
|
|
<th>Mode</th>
|
|
<th>Granted</th>
|
|
<th>Relation</th>
|
|
<th>PID</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{locks.map((l, i) => (
|
|
<tr key={i}>
|
|
<td>{l.locktype}</td>
|
|
<td>{l.mode}</td>
|
|
<td>{String(l.granted)}</td>
|
|
<td>{l.relation || "-"}</td>
|
|
<td>{l.pid}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="card">
|
|
<h3>Activity</h3>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>PID</th>
|
|
<th>User</th>
|
|
<th>State</th>
|
|
<th>Wait</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{activity.map((a) => (
|
|
<tr key={a.pid}>
|
|
<td>{a.pid}</td>
|
|
<td>{a.usename}</td>
|
|
<td>{a.state}</td>
|
|
<td>{a.wait_event_type || "-"}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|