Add easy & DBA mode
This commit is contained in:
@@ -16,7 +16,7 @@ function Protected({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Layout({ children }) {
|
function Layout({ children }) {
|
||||||
const { me, logout } = useAuth();
|
const { me, logout, uiMode, setUiMode } = useAuth();
|
||||||
const navClass = ({ isActive }) => `nav-btn${isActive ? " active" : ""}`;
|
const navClass = ({ isActive }) => `nav-btn${isActive ? " active" : ""}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,6 +63,18 @@ function Layout({ children }) {
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="profile">
|
<div className="profile">
|
||||||
|
<div className="mode-switch-block">
|
||||||
|
<div className="mode-switch-label">View Mode</div>
|
||||||
|
<button
|
||||||
|
className={`mode-toggle ${uiMode === "easy" ? "easy" : "dba"}`}
|
||||||
|
onClick={() => setUiMode(uiMode === "easy" ? "dba" : "easy")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="mode-pill">Easy</span>
|
||||||
|
<span className="mode-pill">DBA</span>
|
||||||
|
</button>
|
||||||
|
<small>{uiMode === "easy" ? "Simple health guidance" : "Advanced DBA metrics"}</small>
|
||||||
|
</div>
|
||||||
<div>{me?.email}</div>
|
<div>{me?.email}</div>
|
||||||
<div className="role">{me?.role}</div>
|
<div className="role">{me?.role}</div>
|
||||||
<button className="logout-btn" onClick={logout}>Logout</button>
|
<button className="logout-btn" onClick={logout}>Logout</button>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ async function loadMetric(targetId, metric, range, tokens, refresh) {
|
|||||||
|
|
||||||
export function TargetDetailPage() {
|
export function TargetDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { tokens, refresh } = useAuth();
|
const { tokens, refresh, uiMode } = useAuth();
|
||||||
const [range, setRange] = useState("1h");
|
const [range, setRange] = useState("1h");
|
||||||
const [series, setSeries] = useState({});
|
const [series, setSeries] = useState({});
|
||||||
const [locks, setLocks] = useState([]);
|
const [locks, setLocks] = useState([]);
|
||||||
@@ -135,6 +135,38 @@ export function TargetDetailPage() {
|
|||||||
[series]
|
[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 (loading) return <div className="card">Loading target detail...</div>;
|
||||||
if (error) return <div className="card error">{error}</div>;
|
if (error) return <div className="card error">{error}</div>;
|
||||||
|
|
||||||
@@ -148,7 +180,82 @@ export function TargetDetailPage() {
|
|||||||
Target Detail {targetMeta?.name || `#${id}`}
|
Target Detail {targetMeta?.name || `#${id}`}
|
||||||
{targetMeta?.dbname ? ` (${targetMeta.dbname})` : ""}
|
{targetMeta?.dbname ? ` (${targetMeta.dbname})` : ""}
|
||||||
</h2>
|
</h2>
|
||||||
{overview && (
|
{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">
|
<div className="card">
|
||||||
<h3>Database Overview</h3>
|
<h3>Database Overview</h3>
|
||||||
<div className="grid three overview-kv">
|
<div className="grid three overview-kv">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { createContext, useContext, useMemo, useState } from "react";
|
|||||||
import { API_URL } from "./api";
|
import { API_URL } from "./api";
|
||||||
|
|
||||||
const AuthCtx = createContext(null);
|
const AuthCtx = createContext(null);
|
||||||
|
const UI_MODE_KEY = "nexapg_ui_mode";
|
||||||
|
|
||||||
function loadStorage() {
|
function loadStorage() {
|
||||||
try {
|
try {
|
||||||
@@ -11,10 +12,21 @@ function loadStorage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadUiMode() {
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(UI_MODE_KEY);
|
||||||
|
if (value === "easy" || value === "dba") return value;
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
return "dba";
|
||||||
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
const initial = loadStorage();
|
const initial = loadStorage();
|
||||||
const [tokens, setTokens] = useState(initial?.tokens || null);
|
const [tokens, setTokens] = useState(initial?.tokens || null);
|
||||||
const [me, setMe] = useState(initial?.me || null);
|
const [me, setMe] = useState(initial?.me || null);
|
||||||
|
const [uiMode, setUiModeState] = useState(loadUiMode);
|
||||||
|
|
||||||
const persist = (nextTokens, nextMe) => {
|
const persist = (nextTokens, nextMe) => {
|
||||||
if (nextTokens && nextMe) {
|
if (nextTokens && nextMe) {
|
||||||
@@ -78,7 +90,13 @@ export function AuthProvider({ children }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = useMemo(() => ({ tokens, me, login, logout, refresh }), [tokens, me]);
|
const setUiMode = (nextMode) => {
|
||||||
|
const mode = nextMode === "easy" ? "easy" : "dba";
|
||||||
|
setUiModeState(mode);
|
||||||
|
localStorage.setItem(UI_MODE_KEY, mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ tokens, me, login, logout, refresh, uiMode, setUiMode }), [tokens, me, uiMode]);
|
||||||
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,52 @@ a {
|
|||||||
color: #d7e4fa;
|
color: #d7e4fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mode-switch-block {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #2a588d;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #0d2244;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9eb8d6;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-toggle {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #3170ae;
|
||||||
|
background: #0b1a36;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-pill {
|
||||||
|
padding: 7px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9db4d1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-toggle.easy .mode-pill:first-child,
|
||||||
|
.mode-toggle.dba .mode-pill:last-child {
|
||||||
|
background: linear-gradient(180deg, #165a96, #124978);
|
||||||
|
color: #eff8ff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-block small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #9bb5d4;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.role {
|
.role {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
@@ -495,6 +541,57 @@ select:-webkit-autofill {
|
|||||||
margin: 8px 0 0 18px;
|
margin: 8px 0 0 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.easy-status {
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-status.ok {
|
||||||
|
border-color: #1d8d5c;
|
||||||
|
background: linear-gradient(180deg, #123b39, #112a31);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-status.warning {
|
||||||
|
border-color: #ad7f25;
|
||||||
|
background: linear-gradient(180deg, #3a3214, #2d2610);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-status.problem {
|
||||||
|
border-color: #b54242;
|
||||||
|
background: linear-gradient(180deg, #3f1d22, #2f1519);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-badge-row {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-badge.ok {
|
||||||
|
color: #86efac;
|
||||||
|
border-color: #2f8d5d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-badge.warning {
|
||||||
|
color: #fde68a;
|
||||||
|
border-color: #9a7a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-badge.problem {
|
||||||
|
color: #fecaca;
|
||||||
|
border-color: #b64a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-list {
|
||||||
|
margin: 8px 0 10px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.chart-tooltip {
|
.chart-tooltip {
|
||||||
background: #0f1934ee;
|
background: #0f1934ee;
|
||||||
border: 1px solid #2f4a8b;
|
border: 1px solid #2f4a8b;
|
||||||
|
|||||||
Reference in New Issue
Block a user