Files
apiary/frontend/src/js/app.tsx

468 lines
13 KiB
TypeScript

import React from "react";
import { createRoot } from "react-dom/client";
import { useState, useEffect } from 'react';
import { ApiaryAPI, LoginAttempt, DummyApiaryAPIClient, ApiaryAPIClient, TotalStats, StatsResult, StatsType } from "./api";
import { BrowserRouter, NavLink, Routes, Route } from "react-router";
import { Chart as ChartJS, Tooltip, ArcElement, Legend, ChartOptions } from "chart.js";
import { Pie } from "react-chartjs-2";
import humanizeDuration from "humanize-duration";
import * as classes from "../css/style.module.css";
ChartJS.register(Tooltip, ArcElement, Legend);
console.log(classes);
interface AppProps {
api: ApiaryAPI
}
let chartColors = [
"#1abc9c",
"#2ecc71",
"#3498db",
"#9b59b6",
"#34495e",
"#16a085",
"#27ae60",
"#2980b9",
"#8e44ad",
"#2c3e50",
"#f1c40f",
"#e67e22",
"#e74c3c",
"#ecf0f1",
"#95a5a6",
"#f39c12",
"#d35400",
"#c0392b",
"#bdc3c7",
"#7f8c8d",
]
export function App({ api }: AppProps) {
const [mode, setMode] = useState("light");
const headerProps: HeaderMenuProps = {
title: "apiary.home.2rjus.net",
items: [
{
name: "Totals",
path: "/",
},
{
name: "Passwords",
path: "/stats/password",
},
{
name: "Usernames",
path: "/stats/username",
},
{
name: "IPs",
path: "/stats/ip",
},
{
name: "Live",
path: "/live",
},
{
name: "Query",
path: "/query",
}
]
}
const onSelectMode = (mode: string) => {
setMode(mode);
if (mode === "dark") {
document.body.classList.add(classes.dark_mode);
} else {
document.body.classList.remove(classes.dark_mode);
}
}
useEffect(() => {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => onSelectMode(e.matches ? "dark" : "light"));
onSelectMode(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
return () => {
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', (e) => onSelectMode(e.matches ? "dark" : "light"));
}
}, [])
return (
<>
<BrowserRouter>
<div id="app">
<HeaderMenu title={headerProps.title} items={headerProps.items} />
<div className={classes.content}>
<Routes>
<Route path="/" element={<Home api={api} />} />
<Route path="/stats/password" element={<Stats api={api} type="password" />} />
<Route path="/stats/username" element={<Stats api={api} type="username" />} />
<Route path="/stats/ip" element={<Stats api={api} type="ip" />} />
<Route path="/live" element={<Live api={api} />} />
<Route path="/query" element={<Query api={api} />} />
</Routes>
</div>
</div>
</BrowserRouter >
</>
);
}
export interface StatsProps {
api: ApiaryAPI
type: StatsType
}
export function Stats({ api, type }: StatsProps) {
const [stats, setStats] = useState<StatsResult[] | null>(null)
const [currentType, setCurrentType] = useState(type)
useEffect(() => {
async function getStats() {
try {
let newStats = await api.stats(type, 10);
if (JSON.stringify(newStats) !== JSON.stringify(stats)) {
setStats(newStats)
}
} catch (e) {
console.log("Error getting stats", e)
}
}
if (currentType !== type) {
setCurrentType(type)
getStats()
}
if (stats === null) {
getStats()
}
const interval = setInterval(() => {
getStats()
}, 60000)
return () => {
clearInterval(interval);
}
}, [stats, type])
return (
<>
{(stats != null && stats.length > 0) ? <StatsPie data={stats} /> : <p>Loading...</p>}
</>
);
}
export interface StatsPieProps {
data: StatsResult[]
}
export function StatsPie({ data }: StatsPieProps) {
const labels = data.map((d) => d.name);
const values = data.map((d) => d.count);
const piedata = {
labels,
datasets: [{
label: "# of attempts",
data: values,
backgroundColor: chartColors,
borderWidth: 1
}]
};
console.log(piedata)
const getTextColor = () => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'white' : 'black'
}
const options: ChartOptions<"pie"> = {
plugins: {
legend: {
display: true,
align: "start",
labels: {
color: getTextColor(),
}
}
}
}
return (
<div className={classes.stats_pie}>
<Pie data={piedata} options={options} />
</div>
)
}
export function Home({ api }: AppProps) {
const [totals, setTotals] = useState<TotalStats | null>(null)
useEffect(() => {
async function getTotals() {
let totals = await api.totals();
setTotals(totals);
}
if (!totals) {
getTotals();
}
const interval = setInterval(() => {
getTotals();
}, 5000)
return () => {
clearInterval(interval);
}
})
return (
<>
{totals ? <Totals totals={totals} /> : <p>Loading...</p>}
</>
);
}
export interface TotalsProps {
totals: TotalStats
}
export function Totals({ totals }: TotalsProps) {
return (
<div className={classes.totals}>
<div className={classes.totals_key}>
<h2>Unique passwords</h2>
<h2>Unique username</h2>
<h2>Unique IPs</h2>
<h2>Total attempts</h2>
</div>
<div className={classes.totals_value}>
<h2>{totals.password}</h2>
<h2>{totals.username}</h2>
<h2>{totals.ip}</h2>
<h2>{totals.attempts}</h2>
</div>
</div>
)
}
export function Live({ api }: AppProps) {
let list: LoginAttempt[] = [];
let [liveList, setLiveList] = useState(list);
useEffect(() => {
const cleanup = api.live((a) => {
setLiveList((list) => {
return [a, ...list];
});
});
return cleanup
}, [liveList, api]);
return (
<>
<LiveList list={liveList} />
</>
);
}
export interface LiveListProps {
list: LoginAttempt[]
};
export interface DateTDProps {
date: Date
now: Date
}
export function DateTD({ date, now }: DateTDProps) {
const [displayDate, setDisplayDate] = useState(date.toLocaleString());
useEffect(() => {
if (now.getTime() - date.getTime() < 14400000) {
const newDate = humanizeDuration(now.getTime() - date.getTime(), { largest: 1, round: true }) + " ago";
if (newDate !== displayDate) {
setDisplayDate(newDate);
}
}
}, [displayDate, now])
return (
<td className={classes.live_table_date}>{displayDate}</td>
)
}
export function LiveList({ list }: LiveListProps) {
const [now, setNow] = useState(new Date())
let items = list.map((a) => {
const attemptDate = new Date(a.date);
const key = `${a.username}-${a.password}-${a.remoteIP}-${a.date}`;
return (
<tr key={a.date}>
<DateTD date={attemptDate} now={now} />
<td className={classes.live_table_username}>{a.username}</td>
<td className={classes.live_table_password}>{a.password}</td>
<td className={classes.live_table_ip}>{a.remoteIP}</td>
<td className={classes.live_table_country}>{a.country}</td>
</tr>
)
})
useEffect(() => {
const interval = setInterval(() => {
setNow(new Date())
}, 1000)
return () => {
clearInterval(interval)
}
}, [now])
return (
<>
<table className={classes.live_table}>
<thead>
<tr>
<th className={classes.live_table_date}>Date</th>
<th className={classes.live_table_username}>Username</th>
<th className={classes.live_table_password}>Password</th>
<th className={classes.live_table_ip}>IP</th>
<th className={classes.live_table_country}>Country</th>
</tr>
</thead>
<tbody>
{items}
</tbody>
</table>
</>
)
};
export function Query({ api }: AppProps) {
const [liveList, setLiveList] = useState<LoginAttempt[]>([]);
const [queryErr, setQueryErr] = useState<Error | null>(null);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const value = event.currentTarget.query.value;
if (value === "") {
setQueryErr(new Error("Query cannot be empty"));
return
}
try {
const results = await api.query("", value)
setQueryErr(null);
setLiveList(results);
} catch (e) {
if (e instanceof Error) {
setQueryErr(e);
}
}
}
return (
<>
<form onSubmit={handleSubmit}>
<input placeholder="Search..." name="query" type="text" />
</form>
{queryErr ? <ErrorBox message={queryErr.message} /> : null}
<LiveList list={liveList} />
</>
);
}
interface ErrorBoxProps {
message: string | null
};
export function ErrorBox({ message }: ErrorBoxProps) {
return (
<div className={classes.error_box}>
<p>Error: {message}</p>
</div>
)
}
export function Header() {
return (
<div className={classes.navbar}>
<h2 id="menu-title">apiary.home.2rjus.net</h2>
<nav className={classes.nav_flex}>
<ul>
<li><NavLink to="/" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Home</NavLink></li>
<li><NavLink to="/stats/password" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Stats</NavLink></li>
<li><NavLink to="/live" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Live</NavLink></li>
<li><NavLink to="/query" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Query</NavLink></li>
</ul>
</nav>
</div >
);
}
interface HeaderItem {
name: string
path: string
}
interface HeaderMenuProps {
title: string
items: Array<HeaderItem>
}
export function HeaderMenu({ title, items }: HeaderMenuProps) {
const menuItems = items.map((item) => {
return (
<li key={item.path}>
<NavLink to={item.path} className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>{item.name}</NavLink>
</li>
)
})
return (
<div className={classes.navbar}>
<img id={classes.menu_logo}></img>
<h2 id={classes.menu_title}>{title}</h2>
<nav className={classes.nav_flex}>
<ul>
{menuItems}
</ul>
</nav>
</div>
)
}
export interface SubMenuProps {
items: Array<{ name: string, active: () => boolean, onClick: () => void }>
}
export function SubMenu({ items }: SubMenuProps) {
return (
<nav className={classes.submenu}>
<ul>
{items.map((item) => {
return <li>
<a
href="#"
className={item.active() ? classes.sub_menu_active : classes.sub_menu_link}
onClick={item.onClick}>{item.name}</a>
</li>
})}
</ul>
</nav>
)
}
const rootElement = document.getElementById('root');
if (rootElement) {
const root = createRoot(rootElement);
let api: ApiaryAPI;
if (process.env.NODE_ENV === "production") {
api = new ApiaryAPIClient();
} else {
api = new DummyApiaryAPIClient();
}
root.render(
<App api={api} />
);
}