Init first files

This commit is contained in:
2026-02-12 09:09:13 +01:00
parent 6535699b0e
commit d1d8ae43a4
61 changed files with 2424 additions and 0 deletions

14
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
ARG VITE_API_URL=/api/v1
ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build
FROM nginx:1.29-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --retries=5 CMD wget -qO- http://127.0.0.1/ || exit 1

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NexaPG Monitor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

11
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,11 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

21
frontend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "nexapg-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 5173"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1",
"recharts": "^2.15.4"
},
"devDependencies": {
"@vitejs/plugin-react": "^5.0.2",
"vite": "^7.1.5"
}
}

63
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,63 @@
import React from "react";
import { Link, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { useAuth } from "./state";
import { LoginPage } from "./pages/LoginPage";
import { DashboardPage } from "./pages/DashboardPage";
import { TargetsPage } from "./pages/TargetsPage";
import { TargetDetailPage } from "./pages/TargetDetailPage";
import { QueryInsightsPage } from "./pages/QueryInsightsPage";
import { AdminUsersPage } from "./pages/AdminUsersPage";
function Protected({ children }) {
const { tokens } = useAuth();
const location = useLocation();
if (!tokens?.accessToken) return <Navigate to="/login" state={{ from: location.pathname }} replace />;
return children;
}
function Layout({ children }) {
const { me, logout } = useAuth();
return (
<div className="shell">
<aside className="sidebar">
<h1>NexaPG</h1>
<nav>
<Link to="/">Dashboard</Link>
<Link to="/targets">Targets</Link>
<Link to="/query-insights">Query Insights</Link>
{me?.role === "admin" && <Link to="/admin/users">Admin</Link>}
</nav>
<div className="profile">
<div>{me?.email}</div>
<div className="role">{me?.role}</div>
<button onClick={logout}>Logout</button>
</div>
</aside>
<main className="main">{children}</main>
</div>
);
}
export function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="*"
element={
<Protected>
<Layout>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/targets" element={<TargetsPage />} />
<Route path="/targets/:id" element={<TargetDetailPage />} />
<Route path="/query-insights" element={<QueryInsightsPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
</Routes>
</Layout>
</Protected>
}
/>
</Routes>
);
}

28
frontend/src/api.js Normal file
View File

@@ -0,0 +1,28 @@
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api/v1";
export async function apiFetch(path, options = {}, tokens, onUnauthorized) {
const headers = {
"Content-Type": "application/json",
...(options.headers || {}),
};
if (tokens?.accessToken) {
headers.Authorization = `Bearer ${tokens.accessToken}`;
}
let res = await fetch(`${API_URL}${path}`, { ...options, headers });
if (res.status === 401 && tokens?.refreshToken && onUnauthorized) {
const refreshed = await onUnauthorized();
if (refreshed) {
headers.Authorization = `Bearer ${refreshed.accessToken}`;
res = await fetch(`${API_URL}${path}`, { ...options, headers });
}
}
if (!res.ok) {
const txt = await res.text();
throw new Error(txt || `HTTP ${res.status}`);
}
if (res.status === 204) return null;
return res.json();
}
export { API_URL };

16
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { AuthProvider } from "./state";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,84 @@
import React, { useEffect, useState } from "react";
import { apiFetch } from "../api";
import { useAuth } from "../state";
export function AdminUsersPage() {
const { tokens, refresh, me } = useAuth();
const [users, setUsers] = useState([]);
const [form, setForm] = useState({ email: "", password: "", role: "viewer" });
const [error, setError] = useState("");
const load = async () => {
setUsers(await apiFetch("/admin/users", {}, tokens, refresh));
};
useEffect(() => {
if (me?.role === "admin") load().catch((e) => setError(String(e.message || e)));
}, [me]);
if (me?.role !== "admin") return <div className="card">Nur fuer Admin.</div>;
const create = async (e) => {
e.preventDefault();
try {
await apiFetch("/admin/users", { method: "POST", body: JSON.stringify(form) }, tokens, refresh);
setForm({ email: "", password: "", role: "viewer" });
await load();
} catch (e) {
setError(String(e.message || e));
}
};
const remove = async (id) => {
try {
await apiFetch(`/admin/users/${id}`, { method: "DELETE" }, tokens, refresh);
await load();
} catch (e) {
setError(String(e.message || e));
}
};
return (
<div>
<h2>Admin Users</h2>
{error && <div className="card error">{error}</div>}
<form className="card grid three" onSubmit={create}>
<input value={form.email} placeholder="email" onChange={(e) => setForm({ ...form, email: e.target.value })} />
<input
type="password"
value={form.password}
placeholder="passwort"
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
<option value="viewer">viewer</option>
<option value="operator">operator</option>
<option value="admin">admin</option>
</select>
<button>User anlegen</button>
</form>
<div className="card">
<table>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Role</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>{u.id}</td>
<td>{u.email}</td>
<td>{u.role}</td>
<td>{u.id !== me.id && <button onClick={() => remove(u.id)}>Delete</button>}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { apiFetch } from "../api";
import { useAuth } from "../state";
export function DashboardPage() {
const { tokens, refresh } = useAuth();
const [targets, setTargets] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let active = true;
(async () => {
try {
const data = await apiFetch("/targets", {}, tokens, refresh);
if (active) setTargets(data);
} catch (e) {
if (active) setError(String(e.message || e));
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [tokens, refresh]);
if (loading) return <div className="card">Lade Dashboard...</div>;
if (error) return <div className="card error">{error}</div>;
return (
<div>
<h2>Dashboard Overview</h2>
<div className="grid three">
<div className="card stat">
<strong>{targets.length}</strong>
<span>Targets</span>
</div>
<div className="card stat">
<strong>{targets.length}</strong>
<span>Status OK (placeholder)</span>
</div>
<div className="card stat">
<strong>0</strong>
<span>Alerts (placeholder)</span>
</div>
</div>
<div className="card">
<h3>Targets</h3>
<table>
<thead>
<tr>
<th>Name</th>
<th>Host</th>
<th>DB</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{targets.map((t) => (
<tr key={t.id}>
<td>{t.name}</td>
<td>{t.host}:{t.port}</td>
<td>{t.dbname}</td>
<td><Link to={`/targets/${t.id}`}>Details</Link></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../state";
export function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("admin@example.com");
const [password, setPassword] = useState("ChangeMe123!");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const submit = async (e) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(email, password);
navigate("/");
} catch {
setError("Login fehlgeschlagen");
} finally {
setLoading(false);
}
};
return (
<div className="login-wrap">
<form className="card login-card" onSubmit={submit}>
<h2>Login</h2>
<label>Email</label>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<label>Passwort</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
{error && <p className="error">{error}</p>}
<button disabled={loading}>{loading ? "Bitte warten..." : "Einloggen"}</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import React, { useEffect, useState } from "react";
import { apiFetch } from "../api";
import { useAuth } from "../state";
export function QueryInsightsPage() {
const { tokens, refresh } = useAuth();
const [targets, setTargets] = useState([]);
const [targetId, setTargetId] = useState("");
const [rows, setRows] = useState([]);
const [error, setError] = useState("");
useEffect(() => {
(async () => {
try {
const t = await apiFetch("/targets", {}, tokens, refresh);
setTargets(t);
if (t.length > 0) setTargetId(String(t[0].id));
} catch (e) {
setError(String(e.message || e));
}
})();
}, []);
useEffect(() => {
if (!targetId) return;
(async () => {
try {
const data = await apiFetch(`/targets/${targetId}/top-queries`, {}, tokens, refresh);
setRows(data);
} catch (e) {
setError(String(e.message || e));
}
})();
}, [targetId, tokens, refresh]);
return (
<div>
<h2>Query Insights</h2>
<p>Hinweis: Benötigt aktivierte Extension <code>pg_stat_statements</code> auf dem Zielsystem.</p>
{error && <div className="card error">{error}</div>}
<div className="card">
<label>Target </label>
<select value={targetId} onChange={(e) => setTargetId(e.target.value)}>
{targets.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
<div className="card">
<table>
<thead>
<tr>
<th>Time</th>
<th>Calls</th>
<th>Total ms</th>
<th>Mean ms</th>
<th>Rows</th>
<th>Query</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td>{new Date(r.ts).toLocaleString()}</td>
<td>{r.calls}</td>
<td>{r.total_time.toFixed(2)}</td>
<td>{r.mean_time.toFixed(2)}</td>
<td>{r.rows}</td>
<td className="query">{r.query_text || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
import React, { useEffect, useMemo, useState } from "react";
import { 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() };
}
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 { tokens, refresh } = useAuth();
const [range, setRange] = useState("1h");
const [series, setSeries] = useState({});
const [locks, setLocks] = useState([]);
const [activity, setActivity] = useState([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
(async () => {
setLoading(true);
try {
const [connections, xacts, cache, locksTable, activityTable] = await Promise.all([
loadMetric(id, "connections_total", range, tokens, refresh),
loadMetric(id, "xacts_total", range, tokens, refresh),
loadMetric(id, "cache_hit_ratio", range, tokens, refresh),
apiFetch(`/targets/${id}/locks`, {}, tokens, refresh),
apiFetch(`/targets/${id}/activity`, {}, tokens, refresh),
]);
if (!active) return;
setSeries({ connections, xacts, cache });
setLocks(locksTable);
setActivity(activityTable);
setError("");
} catch (e) {
if (active) setError(String(e.message || e));
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [id, range, tokens, refresh]);
const chartData = useMemo(
() =>
(series.connections || []).map((point, idx) => ({
ts: new Date(point.ts).toLocaleTimeString(),
connections: point.value,
xacts: series.xacts?.[idx]?.value || 0,
cache: series.cache?.[idx]?.value || 0,
})),
[series]
);
if (loading) return <div className="card">Lade Target Detail...</div>;
if (error) return <div className="card error">{error}</div>;
return (
<div>
<h2>Target Detail #{id}</h2>
<div className="range-picker">
{Object.keys(ranges).map((r) => (
<button key={r} onClick={() => 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 />
<Tooltip />
<Line type="monotone" dataKey="connections" stroke="#38bdf8" dot={false} />
<Line type="monotone" dataKey="xacts" stroke="#22c55e" dot={false} />
<Line type="monotone" dataKey="cache" stroke="#f59e0b" dot={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>
);
}

View File

@@ -0,0 +1,114 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { apiFetch } from "../api";
import { useAuth } from "../state";
const emptyForm = {
name: "",
host: "",
port: 5432,
dbname: "",
username: "",
password: "",
sslmode: "prefer",
tags: {},
};
export function TargetsPage() {
const { tokens, refresh, me } = useAuth();
const [targets, setTargets] = useState([]);
const [form, setForm] = useState(emptyForm);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const canManage = me?.role === "admin" || me?.role === "operator";
const load = async () => {
setLoading(true);
try {
setTargets(await apiFetch("/targets", {}, tokens, refresh));
setError("");
} catch (e) {
setError(String(e.message || e));
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const createTarget = async (e) => {
e.preventDefault();
try {
await apiFetch("/targets", { method: "POST", body: JSON.stringify(form) }, tokens, refresh);
setForm(emptyForm);
await load();
} catch (e) {
setError(String(e.message || e));
}
};
const deleteTarget = async (id) => {
if (!confirm("Target löschen?")) return;
try {
await apiFetch(`/targets/${id}`, { method: "DELETE" }, tokens, refresh);
await load();
} catch (e) {
setError(String(e.message || e));
}
};
return (
<div>
<h2>Targets Management</h2>
{error && <div className="card error">{error}</div>}
{canManage && (
<form className="card grid two" onSubmit={createTarget}>
<input placeholder="Name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
<input placeholder="Host" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} required />
<input placeholder="Port" value={form.port} onChange={(e) => setForm({ ...form, port: Number(e.target.value) })} type="number" />
<input placeholder="DB Name" value={form.dbname} onChange={(e) => setForm({ ...form, dbname: e.target.value })} required />
<input placeholder="Username" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
<input placeholder="Passwort" type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
<select value={form.sslmode} onChange={(e) => setForm({ ...form, sslmode: e.target.value })}>
<option value="disable">disable</option>
<option value="prefer">prefer</option>
<option value="require">require</option>
</select>
<button>Target anlegen</button>
</form>
)}
<div className="card">
{loading ? (
<p>Lade Targets...</p>
) : (
<table>
<thead>
<tr>
<th>Name</th>
<th>Host</th>
<th>DB</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{targets.map((t) => (
<tr key={t.id}>
<td>{t.name}</td>
<td>{t.host}:{t.port}</td>
<td>{t.dbname}</td>
<td>
<Link to={`/targets/${t.id}`}>Details</Link>{" "}
{canManage && <button onClick={() => deleteTarget(t.id)}>Delete</button>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

89
frontend/src/state.jsx Normal file
View File

@@ -0,0 +1,89 @@
import React, { createContext, useContext, useMemo, useState } from "react";
import { API_URL } from "./api";
const AuthCtx = createContext(null);
function loadStorage() {
try {
return JSON.parse(localStorage.getItem("nexapg_auth") || "null");
} catch {
return null;
}
}
export function AuthProvider({ children }) {
const initial = loadStorage();
const [tokens, setTokens] = useState(initial?.tokens || null);
const [me, setMe] = useState(initial?.me || null);
const persist = (nextTokens, nextMe) => {
if (nextTokens && nextMe) {
localStorage.setItem("nexapg_auth", JSON.stringify({ tokens: nextTokens, me: nextMe }));
} else {
localStorage.removeItem("nexapg_auth");
}
};
const refresh = async () => {
if (!tokens?.refreshToken) return null;
const res = await fetch(`${API_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: tokens.refreshToken }),
});
if (!res.ok) {
setTokens(null);
setMe(null);
persist(null, null);
return null;
}
const data = await res.json();
const nextTokens = { accessToken: data.access_token, refreshToken: data.refresh_token };
setTokens(nextTokens);
persist(nextTokens, me);
return nextTokens;
};
const login = async (email, password) => {
const res = await fetch(`${API_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error("Login failed");
const data = await res.json();
const nextTokens = { accessToken: data.access_token, refreshToken: data.refresh_token };
const meRes = await fetch(`${API_URL}/me`, {
headers: { Authorization: `Bearer ${nextTokens.accessToken}` },
});
if (!meRes.ok) throw new Error("Could not load user profile");
const profile = await meRes.json();
setTokens(nextTokens);
setMe(profile);
persist(nextTokens, profile);
};
const logout = async () => {
try {
if (tokens?.accessToken) {
await fetch(`${API_URL}/auth/logout`, {
method: "POST",
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
}
} finally {
setTokens(null);
setMe(null);
persist(null, null);
}
};
const value = useMemo(() => ({ tokens, me, login, logout, refresh }), [tokens, me]);
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthCtx);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx;
}

163
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,163 @@
:root {
--bg: #0b1020;
--bg2: #131a30;
--card: #1b233d;
--text: #e5edf7;
--muted: #98a6c0;
--accent: #38bdf8;
--danger: #ef4444;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Space Grotesk", "Segoe UI", sans-serif;
color: var(--text);
background: radial-gradient(circle at top right, #1d335f, #0b1020 55%);
}
a {
color: var(--accent);
text-decoration: none;
}
.shell {
display: grid;
grid-template-columns: 260px 1fr;
min-height: 100vh;
}
.sidebar {
background: linear-gradient(180deg, #10182f, #0a1022);
border-right: 1px solid #223056;
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
.sidebar nav {
display: flex;
flex-direction: column;
gap: 10px;
}
.profile {
margin-top: auto;
border-top: 1px solid #223056;
padding-top: 16px;
}
.role {
color: var(--muted);
margin-bottom: 10px;
}
.main {
padding: 24px;
}
.card {
background: color-mix(in oklab, var(--card), black 10%);
border: 1px solid #2a3a66;
border-radius: 14px;
padding: 16px;
margin-bottom: 16px;
}
.grid {
display: grid;
gap: 12px;
}
.grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stat strong {
font-size: 28px;
display: block;
}
input,
select,
button {
background: #10182f;
color: var(--text);
border: 1px solid #2b3f74;
border-radius: 10px;
padding: 10px;
}
button {
cursor: pointer;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th,
td {
text-align: left;
border-bottom: 1px solid #223056;
padding: 8px 6px;
}
.error {
color: #fecaca;
border-color: #7f1d1d;
}
.range-picker {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.range-picker .active {
border-color: var(--accent);
}
.login-wrap {
min-height: 100vh;
display: grid;
place-items: center;
}
.login-card {
width: min(420px, 90vw);
display: grid;
gap: 8px;
}
.query {
max-width: 400px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: sticky;
top: 0;
z-index: 2;
}
.grid.two,
.grid.three {
grid-template-columns: 1fr;
}
}

6
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
});