first commit

This commit is contained in:
Mateusz Gruszczyński
2026-03-04 15:21:03 +01:00
commit 5429f176c9
53 changed files with 3808 additions and 0 deletions

2
frontend/.env.example Normal file
View File

@@ -0,0 +1,2 @@
NEXT_PUBLIC_API_BASE=http://localhost:8000
NEXT_PUBLIC_WS_BASE=ws://localhost:8000

24
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json /app/
RUN npm install
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules /app/node_modules
COPY . /app
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/.next /app/.next
COPY --from=builder /app/public /app/public
COPY --from=builder /app/node_modules /app/node_modules
EXPOSE 3000
CMD ["npm","run","start"]

118
frontend/app/admin/page.tsx Normal file
View 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>
);
}

View 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>
);
}

View 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
View 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
View 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>
);
}

View 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
View 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>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
export default function Shell({ children }: { children: React.ReactNode }) {
const [me, setMe] = useState<{email:string, role:string} | null>(null);
useEffect(() => {
apiFetch("/auth/me").then(setMe).catch(() => setMe(null));
}, []);
return (
<div className="flex">
<aside className="w-64 shrink-0 border-r border-zinc-800 min-h-screen p-4">
<div className="text-lg font-semibold">mt-traffic</div>
<div className="mt-4 space-y-2 text-sm">
<Link className="block hover:text-white text-zinc-300" href="/dashboards">Dashboards</Link>
<Link className="block hover:text-white text-zinc-300" href="/admin">Admin</Link>
</div>
<div className="mt-6 text-xs text-zinc-400">
{me ? (
<div>
<div>{me.email}</div>
<div className="uppercase">{me.role}</div>
</div>
) : (
<div>Not logged in</div>
)}
</div>
</aside>
<main className="flex-1 p-6">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from "recharts";
type Point = { t: string } & Record<string, number>;
function fmtBps(v: number) {
const units = ["bps","Kbps","Mbps","Gbps"];
let val = v, i = 0;
while (val >= 1000 && i < units.length-1) { val /= 1000; i++; }
const digits = val < 10 && i > 0 ? 2 : 0;
return `${val.toFixed(digits)} ${units[i]}`;
}
export default function TrafficChart({ data, series }: { data: Point[]; series: string[] }) {
return (
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<XAxis dataKey="t" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} tickFormatter={(v)=>fmtBps(Number(v))} />
<Tooltip formatter={(v:any)=>fmtBps(Number(v))} />
<Legend />
{series.map((s) => (
<Line key={s} type="monotone" dataKey={s} dot={false} strokeWidth={2} />
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}

26
frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,26 @@
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE || "http://localhost:8000";
export const WS_BASE = process.env.NEXT_PUBLIC_WS_BASE || "ws://localhost:8000";
export async function apiFetch(path: string, opts: RequestInit = {}) {
const res = await fetch(`${API_BASE}${path}`, {
...opts,
credentials: "include",
headers: {
...(opts.headers || {}),
"Content-Type": "application/json"
}
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `HTTP ${res.status}`);
}
const ct = res.headers.get("content-type") || "";
if (ct.includes("application/json")) return res.json();
return res.text();
}
export function getCsrfFromCookie(): string | null {
if (typeof document === "undefined") return null;
const m = document.cookie.match(/(?:^|; )mt_csrf=([^;]+)/);
return m ? decodeURIComponent(m[1]) : null;
}

5
frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

17
frontend/next.config.js Normal file
View File

@@ -0,0 +1,17 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
poweredByHeader: false,
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" }
]
}
];
}
};
module.exports = nextConfig;

1999
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "mt-traffic-frontend",
"private": true,
"version": "0.1.0",
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000"
},
"dependencies": {
"next": "14.2.15",
"react": "18.3.1",
"react-dom": "18.3.1",
"recharts": "2.12.7"
},
"devDependencies": {
"@types/node": "25.3.3",
"@types/react": "19.2.14",
"autoprefixer": "10.4.20",
"postcss": "8.4.47",
"tailwindcss": "3.4.14",
"typescript": "5.6.3"
}
}

View File

@@ -0,0 +1 @@
module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };

View File

View File

@@ -0,0 +1,9 @@
import type { Config } from "tailwindcss";
export default {
content: ["./app/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {}
},
plugins: []
} satisfies Config;

35
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,35 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"esModuleInterop": true,
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}