Compare commits
10 Commits
f12dd46c21
...
8c94a30a81
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c94a30a81 | |||
| 5ab7ae1064 | |||
| adf1a4f6fd | |||
| 2cea3ef1c2 | |||
| c4f4340642 | |||
| d29473d3b1 | |||
| 672473603e | |||
| 7b011326a6 | |||
| 3d8bbbb2d6 | |||
| 7997773129 |
@@ -25,4 +25,5 @@ INIT_ADMIN_PASSWORD=ChangeMe123!
|
|||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
FRONTEND_PORT=5173
|
FRONTEND_PORT=5173
|
||||||
VITE_API_URL=http://localhost:8000/api/v1
|
# For reverse proxy + SSL prefer relative path to avoid mixed-content.
|
||||||
|
VITE_API_URL=/api/v1
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Link, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { useAuth } from "./state";
|
import { useAuth } from "./state";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { DashboardPage } from "./pages/DashboardPage";
|
import { DashboardPage } from "./pages/DashboardPage";
|
||||||
@@ -17,20 +17,36 @@ function Protected({ children }) {
|
|||||||
|
|
||||||
function Layout({ children }) {
|
function Layout({ children }) {
|
||||||
const { me, logout } = useAuth();
|
const { me, logout } = useAuth();
|
||||||
|
const navClass = ({ isActive }) => `nav-btn${isActive ? " active" : ""}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<h1>NexaPG</h1>
|
<h1>NexaPG</h1>
|
||||||
<nav>
|
<nav className="sidebar-nav">
|
||||||
<Link to="/">Dashboard</Link>
|
<NavLink to="/" end className={navClass}>
|
||||||
<Link to="/targets">Targets</Link>
|
<span className="nav-icon">DB</span>
|
||||||
<Link to="/query-insights">Query Insights</Link>
|
<span className="nav-label">Dashboard</span>
|
||||||
{me?.role === "admin" && <Link to="/admin/users">Admin</Link>}
|
</NavLink>
|
||||||
|
<NavLink to="/targets" className={navClass}>
|
||||||
|
<span className="nav-icon">TG</span>
|
||||||
|
<span className="nav-label">Targets</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/query-insights" className={navClass}>
|
||||||
|
<span className="nav-icon">QI</span>
|
||||||
|
<span className="nav-label">Query Insights</span>
|
||||||
|
</NavLink>
|
||||||
|
{me?.role === "admin" && (
|
||||||
|
<NavLink to="/admin/users" className={navClass}>
|
||||||
|
<span className="nav-icon">AD</span>
|
||||||
|
<span className="nav-label">Admin</span>
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="profile">
|
<div className="profile">
|
||||||
<div>{me?.email}</div>
|
<div>{me?.email}</div>
|
||||||
<div className="role">{me?.role}</div>
|
<div className="role">{me?.role}</div>
|
||||||
<button onClick={logout}>Logout</button>
|
<button className="logout-btn" onClick={logout}>Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<main className="main">{children}</main>
|
<main className="main">{children}</main>
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api/v1";
|
function resolveApiUrl() {
|
||||||
|
const raw = (import.meta.env.VITE_API_URL || "").trim();
|
||||||
|
const fallback = "/api/v1";
|
||||||
|
if (!raw) return fallback;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw, window.location.origin);
|
||||||
|
if (window.location.protocol === "https:" && parsed.protocol === "http:") {
|
||||||
|
// Avoid mixed-content when UI is served over HTTPS.
|
||||||
|
parsed.protocol = "https:";
|
||||||
|
}
|
||||||
|
return parsed.toString().replace(/\/$/, "");
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_URL = resolveApiUrl();
|
||||||
|
|
||||||
export async function apiFetch(path, options = {}, tokens, onUnauthorized) {
|
export async function apiFetch(path, options = {}, tokens, onUnauthorized) {
|
||||||
const headers = {
|
const headers = {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { useAuth } from "../state";
|
|||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [email, setEmail] = useState("admin@example.com");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("ChangeMe123!");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -27,13 +27,29 @@ export function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="login-wrap">
|
<div className="login-wrap">
|
||||||
<form className="card login-card" onSubmit={submit}>
|
<form className="card login-card" onSubmit={submit}>
|
||||||
<h2>Login</h2>
|
<div className="login-eyebrow">NexaPG Monitor</div>
|
||||||
<label>Email</label>
|
<h2>Willkommen zurück</h2>
|
||||||
<input value={email} onChange={(e) => setEmail(e.target.value)} />
|
<p className="login-subtitle">Melde dich an, um Monitoring und Query Insights zu öffnen.</p>
|
||||||
<label>Passwort</label>
|
<div className="input-shell">
|
||||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="E-Mail"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-shell">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
<button disabled={loading}>{loading ? "Bitte warten..." : "Einloggen"}</button>
|
<button className="login-cta" disabled={loading}>{loading ? "Bitte warten..." : "Einloggen"}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,6 +36,24 @@ function formatSeconds(value) {
|
|||||||
return `${(value / 3600).toFixed(1)}h`;
|
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 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMetric(targetId, metric, range, tokens, refresh) {
|
async function loadMetric(targetId, metric, range, tokens, refresh) {
|
||||||
const { from, to } = toQueryRange(range);
|
const { from, to } = toQueryRange(range);
|
||||||
return apiFetch(
|
return apiFetch(
|
||||||
@@ -54,6 +72,7 @@ export function TargetDetailPage() {
|
|||||||
const [locks, setLocks] = useState([]);
|
const [locks, setLocks] = useState([]);
|
||||||
const [activity, setActivity] = useState([]);
|
const [activity, setActivity] = useState([]);
|
||||||
const [overview, setOverview] = useState(null);
|
const [overview, setOverview] = useState(null);
|
||||||
|
const [targetMeta, setTargetMeta] = useState(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -62,19 +81,21 @@ export function TargetDetailPage() {
|
|||||||
(async () => {
|
(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [connections, xacts, cache, locksTable, activityTable, overviewData] = await Promise.all([
|
const [connections, xacts, cache, locksTable, activityTable, overviewData, targetInfo] = await Promise.all([
|
||||||
loadMetric(id, "connections_total", range, tokens, refresh),
|
loadMetric(id, "connections_total", range, tokens, refresh),
|
||||||
loadMetric(id, "xacts_total", range, tokens, refresh),
|
loadMetric(id, "xacts_total", range, tokens, refresh),
|
||||||
loadMetric(id, "cache_hit_ratio", range, tokens, refresh),
|
loadMetric(id, "cache_hit_ratio", range, tokens, refresh),
|
||||||
apiFetch(`/targets/${id}/locks`, {}, tokens, refresh),
|
apiFetch(`/targets/${id}/locks`, {}, tokens, refresh),
|
||||||
apiFetch(`/targets/${id}/activity`, {}, tokens, refresh),
|
apiFetch(`/targets/${id}/activity`, {}, tokens, refresh),
|
||||||
apiFetch(`/targets/${id}/overview`, {}, tokens, refresh),
|
apiFetch(`/targets/${id}/overview`, {}, tokens, refresh),
|
||||||
|
apiFetch(`/targets/${id}`, {}, tokens, refresh),
|
||||||
]);
|
]);
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setSeries({ connections, xacts, cache });
|
setSeries({ connections, xacts, cache });
|
||||||
setLocks(locksTable);
|
setLocks(locksTable);
|
||||||
setActivity(activityTable);
|
setActivity(activityTable);
|
||||||
setOverview(overviewData);
|
setOverview(overviewData);
|
||||||
|
setTargetMeta(targetInfo);
|
||||||
setError("");
|
setError("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (active) setError(String(e.message || e));
|
if (active) setError(String(e.message || e));
|
||||||
@@ -88,13 +109,29 @@ export function TargetDetailPage() {
|
|||||||
}, [id, range, tokens, refresh]);
|
}, [id, range, tokens, refresh]);
|
||||||
|
|
||||||
const chartData = useMemo(
|
const chartData = useMemo(
|
||||||
() =>
|
() => {
|
||||||
(series.connections || []).map((point, idx) => ({
|
const con = series.connections || [];
|
||||||
ts: new Date(point.ts).toLocaleTimeString(),
|
const xacts = series.xacts || [];
|
||||||
connections: point.value,
|
const cache = series.cache || [];
|
||||||
xacts: series.xacts?.[idx]?.value || 0,
|
return con.map((point, idx) => {
|
||||||
cache: series.cache?.[idx]?.value || 0,
|
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]
|
[series]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -107,7 +144,10 @@ export function TargetDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2>Target Detail #{id}</h2>
|
<h2>
|
||||||
|
Target Detail {targetMeta?.name || `#${id}`}
|
||||||
|
{targetMeta?.dbname ? ` (${targetMeta.dbname})` : ""}
|
||||||
|
</h2>
|
||||||
{overview && (
|
{overview && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Database Overview</h3>
|
<h3>Database Overview</h3>
|
||||||
@@ -121,7 +161,10 @@ export function TargetDetailPage() {
|
|||||||
<span>Uptime</span><strong>{formatSeconds(overview.instance.uptime_seconds)}</strong>
|
<span>Uptime</span><strong>{formatSeconds(overview.instance.uptime_seconds)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div><span>Database</span><strong>{overview.instance.current_database || "-"}</strong></div>
|
<div><span>Database</span><strong>{overview.instance.current_database || "-"}</strong></div>
|
||||||
<div><span>Port</span><strong>{overview.instance.port ?? "-"}</strong></div>
|
<div>
|
||||||
|
<span>Target Port</span>
|
||||||
|
<strong>{targetMeta?.port ?? "-"}</strong>
|
||||||
|
</div>
|
||||||
<div title="Groesse der aktuell verbundenen Datenbank">
|
<div title="Groesse der aktuell verbundenen Datenbank">
|
||||||
<span>Current DB Size</span><strong>{formatBytes(overview.storage.current_database_size_bytes)}</strong>
|
<span>Current DB Size</span><strong>{formatBytes(overview.storage.current_database_size_bytes)}</strong>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,11 +266,12 @@ export function TargetDetailPage() {
|
|||||||
<ResponsiveContainer width="100%" height="85%">
|
<ResponsiveContainer width="100%" height="85%">
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<XAxis dataKey="ts" hide />
|
<XAxis dataKey="ts" hide />
|
||||||
<YAxis />
|
<YAxis yAxisId="left" />
|
||||||
<Tooltip />
|
<YAxis yAxisId="right" orientation="right" domain={[0, 100]} />
|
||||||
<Line type="monotone" dataKey="connections" stroke="#38bdf8" dot={false} />
|
<Tooltip content={<MetricsTooltip />} />
|
||||||
<Line type="monotone" dataKey="xacts" stroke="#22c55e" dot={false} />
|
<Line yAxisId="left" type="monotone" dataKey="connections" stroke="#38bdf8" dot={false} strokeWidth={2} />
|
||||||
<Line type="monotone" dataKey="cache" stroke="#f59e0b" dot={false} />
|
<Line yAxisId="left" type="monotone" dataKey="tps" stroke="#22c55e" dot={false} strokeWidth={2} />
|
||||||
|
<Line yAxisId="right" type="monotone" dataKey="cache" stroke="#f59e0b" dot={false} strokeWidth={2} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ body {
|
|||||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: radial-gradient(circle at top right, #1d335f, #0b1020 55%);
|
background: radial-gradient(circle at top right, #1d335f, #0b1020 55%);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -27,7 +28,8 @@ a {
|
|||||||
.shell {
|
.shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 260px 1fr;
|
grid-template-columns: 260px 1fr;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -37,14 +39,60 @@ a {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
height: 100vh;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar nav {
|
.sidebar-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #223056;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(180deg, #101b3a, #0d1530);
|
||||||
|
color: #c6d5ef;
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
border-color: #2e4f98;
|
||||||
|
background: linear-gradient(180deg, #13224b, #101b3a);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn.active {
|
||||||
|
border-color: #38bdf8;
|
||||||
|
box-shadow: inset 0 0 0 1px #38bdf860;
|
||||||
|
background: linear-gradient(180deg, #16305f, #101f43);
|
||||||
|
color: #ecf5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid #2b3f74;
|
||||||
|
background: #0d1631;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.profile {
|
.profile {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
border-top: 1px solid #223056;
|
border-top: 1px solid #223056;
|
||||||
@@ -58,6 +106,8 @@ a {
|
|||||||
|
|
||||||
.main {
|
.main {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
@@ -120,6 +170,17 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-color: #374f8f;
|
||||||
|
background: linear-gradient(180deg, #13224b, #101b3a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -152,12 +213,96 @@ td {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(900px 500px at 20% 15%, #15356a44, transparent),
|
||||||
|
radial-gradient(900px 500px at 80% 90%, #1c4a7a33, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
width: min(420px, 90vw);
|
width: min(460px, 95vw);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
|
border-color: #335aa4;
|
||||||
|
padding: 26px;
|
||||||
|
background: linear-gradient(180deg, #182548f0, #141f3df0);
|
||||||
|
box-shadow: 0 20px 50px #050b1f66;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
animation: loginEnter 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-eyebrow {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a8badb;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 34px;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
margin: 0 0 2px 0;
|
||||||
|
color: #9db0d2;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-shell {
|
||||||
|
border: 1px solid #2b3f74;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0;
|
||||||
|
background: #0e1731;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-shell:focus-within {
|
||||||
|
border-color: #38bdf8;
|
||||||
|
box-shadow: 0 0 0 3px #38bdf820;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-shell input {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 11px 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-cta {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-color: #3f74d6;
|
||||||
|
background: linear-gradient(90deg, #2e7cd4, #265fb4);
|
||||||
|
padding: 11px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-cta:hover {
|
||||||
|
border-color: #6aa8ff;
|
||||||
|
filter: brightness(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card input:-webkit-autofill,
|
||||||
|
.login-card input:-webkit-autofill:hover,
|
||||||
|
.login-card input:-webkit-autofill:focus {
|
||||||
|
-webkit-text-fill-color: #e5edf7;
|
||||||
|
-webkit-box-shadow: 0 0 0px 1000px #0e1731 inset;
|
||||||
|
transition: background-color 9999s ease-out 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loginEnter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.query {
|
.query {
|
||||||
@@ -214,17 +359,58 @@ td {
|
|||||||
margin: 8px 0 0 18px;
|
margin: 8px 0 0 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-tooltip {
|
||||||
|
background: #0f1934ee;
|
||||||
|
border: 1px solid #2f4a8b;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
min-width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-tooltip-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9cb2d8;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-tooltip-item {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-tooltip-item.c1 {
|
||||||
|
color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-tooltip-item.c2 {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-tooltip-item.c3 {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
|
body {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
.shell {
|
.shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
.grid.two,
|
.grid.two,
|
||||||
.grid.three {
|
.grid.three {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.main {
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user