diff --git a/flake.nix b/flake.nix index f21c859..a34adb6 100644 --- a/flake.nix +++ b/flake.nix @@ -57,7 +57,7 @@ src = ./frontend; - npmDepsHash = "sha256-hFoGepzNZjDFaHhb6tO3FUmW6AdEyOTXIQ6rDcUokLo="; + npmDepsHash = "sha256-cN+xm9wBRcpDHuYy8PelV1YWhudw4y8B6Ap4+3En8/4="; installPhase = '' mkdir -p $out diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0164266..22fa32d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6,10 +6,12 @@ "": { "name": "apiary-frontend", "dependencies": { + "@types/humanize-duration": "^3.27.4", "@types/node": "^22.13.9", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "chart.js": "^4.4.8", + "humanize-duration": "^3.32.1", "react": "^19.0.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.0.0", @@ -20,6 +22,7 @@ "@ngneat/falso": "^7.3.0", "parcel": "^2.13.3", "process": "^0.11.10", + "svgo": "^3.3.2", "ts-loader": "^9.5.2", "typescript": "^5.8.2" } @@ -2056,6 +2059,16 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -2094,6 +2107,12 @@ "license": "MIT", "peer": true }, + "node_modules/@types/humanize-duration": { + "version": "3.27.4", + "resolved": "https://registry.npmjs.org/@types/humanize-duration/-/humanize-duration-3.27.4.tgz", + "integrity": "sha512-yaf7kan2Sq0goxpbcwTQ+8E9RP6HutFBPv74T/IA/ojcHKhuKVlk2YFYyHhWZeLvZPzzLE3aatuQB4h0iqyyUA==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2419,6 +2438,13 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -2619,6 +2645,86 @@ } } }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -3027,6 +3133,12 @@ "entities": "^4.5.0" } }, + "node_modules/humanize-duration": { + "version": "3.32.1", + "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.32.1.tgz", + "integrity": "sha512-inh5wue5XdfObhu/IGEMiA1nUXigSGcaKNemcbLRKa7jXYGDZXr3LoT9pTIzq2hPEbld7w/qv9h+ikWGz8fL1g==", + "license": "Unlicense" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3462,6 +3574,13 @@ "node": ">=6.11.5" } }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -3616,6 +3735,19 @@ "dev": true, "license": "MIT" }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -4081,6 +4213,16 @@ "node": ">= 8" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -4132,6 +4274,42 @@ "node": ">=8" } }, + "node_modules/svgo": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", + "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^5.1.0", + "css-tree": "^2.3.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.0.0" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/swr": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index f2d31fd..b48be21 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,14 +8,17 @@ "@ngneat/falso": "^7.3.0", "parcel": "^2.13.3", "process": "^0.11.10", + "svgo": "^3.3.2", "ts-loader": "^9.5.2", "typescript": "^5.8.2" }, "dependencies": { + "@types/humanize-duration": "^3.27.4", "@types/node": "^22.13.9", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "chart.js": "^4.4.8", + "humanize-duration": "^3.32.1", "react": "^19.0.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.0.0", diff --git a/frontend/src/assets/apiary.svg b/frontend/src/assets/apiary.svg new file mode 100644 index 0000000..433f4c4 --- /dev/null +++ b/frontend/src/assets/apiary.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/css/style.css b/frontend/src/css/style.css index ff07f3f..2910985 100644 --- a/frontend/src/css/style.css +++ b/frontend/src/css/style.css @@ -1,10 +1,36 @@ :root { + --main-color-bg: white; + --main-color: black; + /* Menu */ --menu-color-bg: #212529; --menu-color-text: hsla(0,0%,100%,.5); --menu-color-text-hover: white; --menu-color-title-text: white; - --table-color-bg: #009879; + + /* Table */ + --table-color-header-bg: var(--menu-color-bg); + --table-color-header-text: #dddddd; + --table-row-odd: #dddddd; + --table-row-even: #f3f3f3; } + +.dark-mode { + --main-color-bg: #15141a; + --main-color: white; + /* Menu */ + --menu-color-bg: #35393d; + --menu-color-text: hsla(0,0%,100%,.5); + --menu-color-text-hover: white; + --menu-color-title-text: white; + + /* Table */ + --table-color-header-bg: var(--menu-color-bg); + --table-color-header-text: var(--menu-color-text); + --table-row-odd: #302f36; + --table-row-even: #232229; +} + + #root { padding: 0; margin: 0; @@ -12,6 +38,8 @@ width: 100%; } body { + background-color: var(--main-color-bg); + color: var(--main-color); margin: 0; margin-top: 100px; } @@ -21,7 +49,6 @@ body { *::after { padding: 0; margin: 0; - border-box: box-sizing; } a { @@ -59,12 +86,10 @@ a { .navbar h2 { line-height: 40px; align-items: center; - padding-left: 10px; padding-right: 10px; } .navbar a { - color: var(--menu-color-text); height: 1; } @@ -100,11 +125,15 @@ a { text-align: left; } #menu-title { - color: white; + color: var(--menu-color-text-hover); font-family: "Secular One", sans-serif; font-size: 30px; font-weight: 300; } +#menu-logo { + padding: 5px; + +} .menu-link { color: var(--menu-color-text); } @@ -114,15 +143,6 @@ a { .menu-link-active { color: var(--menu-color-text-hover); } -.sub-menu-link { - color: var(--menu-color-text); -} -.sub-menu-link:hover { - color: var(--menu-color-text-hover); -} -.sub-menu-link-active { - color: var(--menu-color-text-hover); -} .stats-pie { height: 50vh; width: 50vw; @@ -137,8 +157,8 @@ a { box-shadow: 0 0 20px rgba(0, 0, 0, 0.15); } .live-table thead tr { - background-color: var(--table-color-bg); - color: #ffffff; + background-color: var(--table-color-header-bg); + color: var(--table-color-header-text); text-align: left; } .live-table th, @@ -146,17 +166,17 @@ a { padding: 12px 15px; } .live-table tbody tr { - border-bottom: 1px solid #dddddd; + border-bottom: 1px solid var(--table-row-odd); } .live-table tbody tr:nth-of-type(even) { - background-color: #f3f3f3; + background-color: var(--table-row-even); } .live-table tbody tr:last-of-type { border-bottom: 2px solid var(--table-color-bg); } .live-table tbody tr.active-row { font-weight: bold; - color: #009879; + color: var(--table-color-bg); } .content { diff --git a/frontend/src/js/api.ts b/frontend/src/js/api.ts index 43597ab..679607b 100644 --- a/frontend/src/js/api.ts +++ b/frontend/src/js/api.ts @@ -1,4 +1,5 @@ -import { randUserName, randPastDate, randIp, randPassword, randUuid, randNumber } from '@ngneat/falso'; +import { randUserName, randRecentDate, randIp, randPassword, randUuid, randNumber } from '@ngneat/falso'; +import { r } from 'react-router/dist/development/fog-of-war-Cm1iXIp7'; export interface LoginAttempt { readonly date: string; @@ -23,16 +24,18 @@ export interface StatsResult { count: number; } +export type StatsType = 'password' | 'username' | 'ip' | 'country' | 'attempts'; + export interface ApiaryAPI { live(fn: (a: LoginAttempt) => void): void; - stats(statsType: string, limit: number): Promise; + stats(statsType: StatsType, limit: number): Promise; query(queryType: string, query: string): Promise; totals(): Promise; } function fakeLoginAttempt(): LoginAttempt { return { - date: randPastDate().toISOString(), + date: randRecentDate({days: 2}).toISOString(), remoteIP: randIp().toString(), username: randUserName().toString(), password: randPassword().toString(), @@ -51,8 +54,18 @@ export class DummyApiaryAPIClient implements ApiaryAPI { return () => { clearInterval(interval) } } - async stats(_type: string, limit: number): Promise { + async stats(_type: StatsType, limit: number): Promise { const stats = Array.from({ length: limit }, () => { + switch (_type) { + case 'password': + return { name: randPassword().toString(), count: randNumber().valueOf() } + case 'username': + return { name: randUserName().toString(), count: randNumber().valueOf() } + case 'ip': + return { name: randIp().toString(), count: randNumber().valueOf() } + case 'country': + return { name: 'NO', count: randNumber().valueOf() } + } return { name: randUserName().toString(), count: randNumber().valueOf() } }); return Promise.resolve(stats); @@ -83,7 +96,7 @@ export class ApiaryAPIClient implements ApiaryAPI { es.close(); } } - async stats(statsType: string, limit: number): Promise { + async stats(statsType: StatsType, limit: number): Promise { const resp = await fetch(`/api/stats?type=${statsType}&limit=${limit}`) if (!resp.ok) { throw new Error('Failed to fetch query') diff --git a/frontend/src/js/app.tsx b/frontend/src/js/app.tsx index 65656dc..a022641 100644 --- a/frontend/src/js/app.tsx +++ b/frontend/src/js/app.tsx @@ -1,12 +1,14 @@ import React from "react"; import { createRoot } from "react-dom/client"; import { useState, useEffect } from 'react'; -import { ApiaryAPI, LoginAttempt, DummyApiaryAPIClient, ApiaryAPIClient, TotalStats, StatsResult } from "./api"; +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 } from "chart.js"; import { Pie } from "react-chartjs-2"; +import humanizeDuration from "humanize-duration"; ChartJS.register(Tooltip, ArcElement, Legend); +const logo = new URL("../assets/apiary.svg", import.meta.url) interface AppProps { api: ApiaryAPI @@ -36,57 +38,88 @@ let chartColors = [ ] export function App({ api }: AppProps) { + const [mode, setMode] = useState("light"); + const headerProps: HeaderMenuProps = { + title: "apiary.home.2rjus.net", + logo: logo, + 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("dark-mode"); + } else { + document.body.classList.remove("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 ( <> -
-
- - } /> - } /> - } /> - } /> - +
+ +
+ + } /> + } /> + } /> + } /> + } /> + } /> + +
); } -export function Stats({ api }: AppProps) { - const [stats, setStats] = useState([]); - const [statsType, setStatsType] = useState("password") +export interface StatsProps { + api: ApiaryAPI + type: StatsType +} - const activeMenu = (name: string): boolean => { - if (statsType === name) { - return true - } - return false - } - const subMenuItems: SubMenuProps = { - items: [ - { - name: "Passwords", - active: () => { return activeMenu("password") }, - onClick: () => setStatsType("password") - }, - { - name: "Usernames", - active: () => { return activeMenu("username") }, - onClick: () => setStatsType("username") - }, - { - name: "IPs", - active: () => { return activeMenu("ip") }, - onClick: () => setStatsType("ip") - }, - ] - } +export function Stats({ api, type }: StatsProps) { + const [stats, setStats] = useState(null) + const [currentType, setCurrentType] = useState(type) useEffect(() => { async function getStats() { try { - let newStats = await api.stats(statsType, 10); + let newStats = await api.stats(type, 10); if (JSON.stringify(newStats) !== JSON.stringify(stats)) { setStats(newStats) } @@ -95,7 +128,14 @@ export function Stats({ api }: AppProps) { } } - getStats() + if (currentType !== type) { + setCurrentType(type) + getStats() + } + + if (stats === null) { + getStats() + } const interval = setInterval(() => { getStats() @@ -104,11 +144,11 @@ export function Stats({ api }: AppProps) { return () => { clearInterval(interval); } - }, [stats, statsType]) + }, [stats, type]) + return ( <> - - {stats.length > 0 ? :

Loading...

} + {(stats != null && stats.length > 0) ? :

Loading...

} ); } @@ -193,7 +233,7 @@ export function Live({ api }: AppProps) { useEffect(() => { const cleanup = api.live((a) => { setLiveList((list) => { - return [...list, a]; + return [a, ...list]; }); }); @@ -210,30 +250,67 @@ export function Live({ api }: AppProps) { 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 ( + {displayDate} + ) +} 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 ( - {a.date} - {a.username} - {a.password} - {a.remoteIP} - {a.country} + + {a.username} + {a.password} + {a.remoteIP} + {a.country} ) }) + + useEffect(() => { + const interval = setInterval(() => { + setNow(new Date()) + }, 1000) + + return () => { + clearInterval(interval) + } + }, [now]) + return ( <> - - - - - + + + + + @@ -296,7 +373,7 @@ export function Header() {
DateUsernamePasswordIPCountryDateUsernamePasswordIPCountry