first commit
This commit is contained in:
118
frontend/app/admin/page.tsx
Normal file
118
frontend/app/admin/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import Shell from "@/components/Shell";
|
||||
import { apiFetch, getCsrfFromCookie } from "@/lib/api";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function AdminPage() {
|
||||
const [routers, setRouters] = useState<any[]>([]);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
// add router
|
||||
const [name, setName] = useState("r1");
|
||||
const [host, setHost] = useState("192.168.88.1");
|
||||
const [verify, setVerify] = useState(false);
|
||||
|
||||
// add credential
|
||||
const [routerId, setRouterId] = useState<number>(1);
|
||||
const [method, setMethod] = useState("rest");
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [secret, setSecret] = useState("");
|
||||
|
||||
async function load() {
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await apiFetch("/api/routers");
|
||||
setRouters(r);
|
||||
if (r?.length) setRouterId(r[0].id);
|
||||
} catch (e:any) { setErr(e.message || "error"); }
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function createRouter() {
|
||||
setErr(null);
|
||||
try {
|
||||
const csrf = getCsrfFromCookie();
|
||||
await apiFetch("/api/routers", {
|
||||
method: "POST",
|
||||
headers: { "X-CSRF-Token": csrf || "" },
|
||||
body: JSON.stringify({
|
||||
name, host, verify_ssl: verify, preferred_method: "auto",
|
||||
port_rest: 443, port_ssh: 22, port_api: 8728, tags: ""
|
||||
})
|
||||
});
|
||||
await load();
|
||||
} catch (e:any) { setErr(e.message || "error"); }
|
||||
}
|
||||
|
||||
async function addCred() {
|
||||
setErr(null);
|
||||
try {
|
||||
const csrf = getCsrfFromCookie();
|
||||
await apiFetch(`/api/routers/${routerId}/credentials`, {
|
||||
method: "POST",
|
||||
headers: { "X-CSRF-Token": csrf || "" },
|
||||
body: JSON.stringify({ method, username, secret, extra_json: { scheme: "https" } })
|
||||
});
|
||||
setSecret("");
|
||||
await load();
|
||||
} catch (e:any) { setErr(e.message || "error"); }
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<div className="text-2xl font-semibold">Admin</div>
|
||||
<div className="text-sm text-zinc-400 mt-1">Routery i poświadczenia (wymaga roli admin)</div>
|
||||
{err && <div className="mt-4 text-sm text-red-400">{err}</div>}
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 xl:grid-cols-2 gap-3">
|
||||
<div className="p-4 rounded-xl border border-zinc-800 bg-zinc-950">
|
||||
<div className="font-medium">Add router</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
<input className="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={name} onChange={e=>setName(e.target.value)} placeholder="name" />
|
||||
<input className="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={host} onChange={e=>setHost(e.target.value)} placeholder="host/ip" />
|
||||
<label className="text-sm text-zinc-300 flex items-center gap-2">
|
||||
<input type="checkbox" checked={verify} onChange={e=>setVerify(e.target.checked)} />
|
||||
verify SSL
|
||||
</label>
|
||||
<button onClick={createRouter} className="px-3 py-2 rounded-lg bg-zinc-100 text-zinc-900">Create router</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl border border-zinc-800 bg-zinc-950">
|
||||
<div className="font-medium">Add credentials</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
<select className="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={routerId} onChange={e=>setRouterId(Number(e.target.value))}>
|
||||
{routers.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
<select className="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={method} onChange={e=>setMethod(e.target.value)}>
|
||||
<option value="rest">rest</option>
|
||||
<option value="ssh">ssh</option>
|
||||
<option value="api">api</option>
|
||||
</select>
|
||||
<input className="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={username} onChange={e=>setUsername(e.target.value)} placeholder="username" />
|
||||
<input className="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={secret} onChange={e=>setSecret(e.target.value)} placeholder="password/token" />
|
||||
<button onClick={addCred} className="px-3 py-2 rounded-lg bg-zinc-100 text-zinc-900">Save credentials</button>
|
||||
<div className="text-xs text-zinc-400">REST: ustaw scheme w extra_json (domyślnie https). SSH/API to szkielety.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 rounded-xl border border-zinc-800 bg-zinc-950">
|
||||
<div className="font-medium">Routers</div>
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{routers.map(r => (
|
||||
<div key={r.id} className="flex items-center justify-between border border-zinc-800 rounded-lg p-3">
|
||||
<div>
|
||||
<div className="font-medium">{r.name}</div>
|
||||
<div className="text-xs text-zinc-400">{r.host} • pref: {r.preferred_method}</div>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">id: {r.id}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
179
frontend/app/dashboards/[id]/page.tsx
Normal file
179
frontend/app/dashboards/[id]/page.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import Shell from "@/components/Shell";
|
||||
import TrafficChart from "@/components/TrafficChart";
|
||||
import { apiFetch, getCsrfFromCookie, WS_BASE } from "@/lib/api";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type Panel = { id:number; title:string; router_id:number; config:any };
|
||||
|
||||
export default function DashboardView({ params }: { params: { id: string } }) {
|
||||
const dashboardId = Number(params.id);
|
||||
const [panels, setPanels] = useState<Panel[]>([]);
|
||||
const [dashName, setDashName] = useState<string>("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
// create panel form
|
||||
const [title, setTitle] = useState("WAN");
|
||||
const [routerId, setRouterId] = useState<number>(1);
|
||||
const [interfaces, setInterfaces] = useState<string>("ether1");
|
||||
const [metrics, setMetrics] = useState<string>("rx_bps,tx_bps");
|
||||
const [intervalMs, setIntervalMs] = useState<number>(1000);
|
||||
const [windowPts, setWindowPts] = useState<number>(120);
|
||||
|
||||
const [routers, setRouters] = useState<any[]>([]);
|
||||
const dataMap = useRef<Map<number, any[]>>(new Map()); // panelId -> points
|
||||
|
||||
async function load() {
|
||||
setErr(null);
|
||||
try {
|
||||
const d = await apiFetch(`/api/dashboards/${dashboardId}`);
|
||||
setDashName(d.dashboard.name);
|
||||
setPanels(d.panels);
|
||||
} catch (e:any) { setErr(e.message || "error"); }
|
||||
}
|
||||
|
||||
async function loadRouters() {
|
||||
try {
|
||||
const r = await apiFetch("/api/routers");
|
||||
setRouters(r);
|
||||
if (r?.length) setRouterId(r[0].id);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); loadRouters(); }, [dashboardId]);
|
||||
|
||||
async function createPanel() {
|
||||
setErr(null);
|
||||
try {
|
||||
const csrf = getCsrfFromCookie();
|
||||
const cfg = {
|
||||
interfaces: interfaces.split(",").map(s=>s.trim()).filter(Boolean),
|
||||
metrics: metrics.split(",").map(s=>s.trim()).filter(Boolean),
|
||||
interval_ms: intervalMs,
|
||||
window: windowPts
|
||||
};
|
||||
await apiFetch(`/api/dashboards/${dashboardId}/panels`, {
|
||||
method: "POST",
|
||||
headers: { "X-CSRF-Token": csrf || "" },
|
||||
body: JSON.stringify({ title, router_id: routerId, config: cfg })
|
||||
});
|
||||
await load();
|
||||
} catch (e:any) { setErr(e.message || "error"); }
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-2xl font-semibold">{dashName || "Dashboard"}</div>
|
||||
<div className="text-sm text-zinc-400">Live charts</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <div className="mt-4 text-sm text-red-400">{err}</div>}
|
||||
|
||||
<div className="mt-6 p-4 rounded-xl border border-zinc-800 bg-zinc-950">
|
||||
<div className="font-medium">Add panel</div>
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-5 gap-2">
|
||||
<input className="px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={title} onChange={e=>setTitle(e.target.value)} placeholder="title"/>
|
||||
<select className="px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={routerId} onChange={e=>setRouterId(Number(e.target.value))}>
|
||||
{routers.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
<input className="px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={interfaces} onChange={e=>setInterfaces(e.target.value)} placeholder="interfaces: ether1,ether2"/>
|
||||
<input className="px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700" value={metrics} onChange={e=>setMetrics(e.target.value)} placeholder="metrics: rx_bps,tx_bps"/>
|
||||
<button onClick={createPanel} className="px-3 py-2 rounded-lg bg-zinc-100 text-zinc-900">Create</button>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-1 md:grid-cols-5 gap-2 text-sm text-zinc-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-400">interval ms</span>
|
||||
<input className="w-28 px-2 py-1 rounded bg-zinc-900 border border-zinc-700" type="number" value={intervalMs} onChange={e=>setIntervalMs(Number(e.target.value))}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-400">window pts</span>
|
||||
<input className="w-28 px-2 py-1 rounded bg-zinc-900 border border-zinc-700" type="number" value={windowPts} onChange={e=>setWindowPts(Number(e.target.value))}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 xl:grid-cols-2 gap-3">
|
||||
{panels.map(p => (
|
||||
<PanelCard key={p.id} panel={p} dataMapRef={dataMap} />
|
||||
))}
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelCard({ panel, dataMapRef }: { panel: Panel; dataMapRef: any }) {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const series = useMemo(() => {
|
||||
const cfg = panel.config || {};
|
||||
const ifs: string[] = cfg.interfaces || [];
|
||||
const ms: string[] = cfg.metrics || ["rx_bps","tx_bps"];
|
||||
const out: string[] = [];
|
||||
for (const i of (ifs.length ? ifs : ["*"])) {
|
||||
for (const m of ms) out.push(`${i}:${m}`);
|
||||
}
|
||||
return out;
|
||||
}, [panel]);
|
||||
|
||||
useEffect(() => {
|
||||
let stop = false;
|
||||
const windowPts = Number(panel.config?.window || 120);
|
||||
|
||||
const ws = new WebSocket(`${WS_BASE.replace("http","ws")}/ws/stream`);
|
||||
wsRef.current = ws;
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ panelId: panel.id }));
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "traffic" && msg.panelId === panel.id) {
|
||||
const rows = msg.data || [];
|
||||
const now = new Date();
|
||||
const t = now.toLocaleTimeString();
|
||||
const points = (dataMapRef.current.get(panel.id) || []) as any[];
|
||||
|
||||
// multi-series: iface:metric
|
||||
const pt: any = { t };
|
||||
for (const r of rows) {
|
||||
const iface = r.iface;
|
||||
pt[`${iface}:rx_bps`] = Number(r.rx_bps || 0);
|
||||
pt[`${iface}:tx_bps`] = Number(r.tx_bps || 0);
|
||||
}
|
||||
points.push(pt);
|
||||
while (points.length > windowPts) points.shift();
|
||||
dataMapRef.current.set(panel.id, points);
|
||||
if (!stop) setData([...points]);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
const ping = setInterval(() => {
|
||||
try { ws.send("ping"); } catch {}
|
||||
}, 15000);
|
||||
|
||||
return () => {
|
||||
stop = true;
|
||||
clearInterval(ping);
|
||||
try { ws.close(); } catch {}
|
||||
};
|
||||
}, [panel.id]);
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-xl border border-zinc-800 bg-zinc-950">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{panel.title}</div>
|
||||
<div className="text-xs text-zinc-400">panelId: {panel.id} • routerId: {panel.router_id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<TrafficChart data={data} series={series.filter(s => s !== "*:rx_bps" && s !== "*:tx_bps")} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
frontend/app/dashboards/page.tsx
Normal file
68
frontend/app/dashboards/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import Shell from "@/components/Shell";
|
||||
import { apiFetch, getCsrfFromCookie } from "@/lib/api";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Dashboard = { id: number; name: string; is_shared: boolean };
|
||||
|
||||
export default function DashboardsPage() {
|
||||
const [items, setItems] = useState<Dashboard[]>([]);
|
||||
const [name, setName] = useState("Main");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setErr(null);
|
||||
try {
|
||||
const data = await apiFetch("/api/dashboards");
|
||||
setItems(data);
|
||||
} catch (e: any) {
|
||||
setErr(e.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function create() {
|
||||
setErr(null);
|
||||
try {
|
||||
const csrf = getCsrfFromCookie();
|
||||
await apiFetch("/api/dashboards", {
|
||||
method: "POST",
|
||||
headers: { "X-CSRF-Token": csrf || "" },
|
||||
body: JSON.stringify({ name, is_shared: false })
|
||||
});
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setErr(e.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-2xl font-semibold">Dashboards</div>
|
||||
<div className="text-sm text-zinc-400">Twoje dashboardy</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input className="px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700"
|
||||
value={name} onChange={e=>setName(e.target.value)} />
|
||||
<button onClick={create} className="px-3 py-2 rounded-lg bg-zinc-100 text-zinc-900">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <div className="mt-4 text-sm text-red-400">{err}</div>}
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{items.map(d => (
|
||||
<Link key={d.id} href={`/dashboards/${d.id}`} className="block p-4 rounded-xl border border-zinc-800 bg-zinc-950 hover:bg-zinc-900">
|
||||
<div className="font-medium">{d.name}</div>
|
||||
<div className="text-xs text-zinc-400 mt-1">id: {d.id}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
8
frontend/app/globals.css
Normal file
8
frontend/app/globals.css
Normal file
@@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root { color-scheme: dark; }
|
||||
html, body { height: 100%; }
|
||||
body { @apply bg-zinc-950 text-zinc-100; }
|
||||
a { @apply text-zinc-100; }
|
||||
19
frontend/app/layout.tsx
Normal file
19
frontend/app/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "mt-traffic",
|
||||
description: "MikroTik live traffic dashboards"
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="pl" className="dark">
|
||||
<body>
|
||||
<div className="min-h-screen">
|
||||
{children}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
40
frontend/app/login/page.tsx
Normal file
40
frontend/app/login/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
const r = useRouter();
|
||||
const [email, setEmail] = useState("admin@example.com");
|
||||
const [password, setPassword] = useState("admin1234");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
try {
|
||||
await apiFetch("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) });
|
||||
r.push("/dashboards");
|
||||
} catch (e: any) {
|
||||
setErr(e.message || "login failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto pt-20">
|
||||
<div className="text-2xl font-semibold">Login</div>
|
||||
<form onSubmit={onSubmit} className="mt-6 space-y-3">
|
||||
<input className="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700"
|
||||
value={email} onChange={e=>setEmail(e.target.value)} placeholder="email" />
|
||||
<input className="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700"
|
||||
type="password" value={password} onChange={e=>setPassword(e.target.value)} placeholder="password" />
|
||||
{err && <div className="text-sm text-red-400">{err}</div>}
|
||||
<button className="w-full px-3 py-2 rounded-lg bg-zinc-100 text-zinc-900">Sign in</button>
|
||||
</form>
|
||||
<div className="mt-3 text-xs text-zinc-400">
|
||||
Domyślny admin: admin@example.com / admin1234
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
frontend/app/page.tsx
Normal file
14
frontend/app/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="max-w-xl mx-auto pt-20">
|
||||
<div className="text-3xl font-bold">mt-traffic</div>
|
||||
<p className="mt-2 text-zinc-300">Live monitoring MikroTik z dashboardami.</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Link href="/login" className="px-4 py-2 rounded-lg bg-zinc-100 text-zinc-900">Login</Link>
|
||||
<Link href="/dashboards" className="px-4 py-2 rounded-lg border border-zinc-700">Dashboards</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user