From 962a55f9c5cb2cd593ede6595e9f0539d25dc24b Mon Sep 17 00:00:00 2001 From: "HomePC\\Kevin" Date: Sun, 13 Sep 2026 12:46:03 +0200 Subject: [PATCH 1/3] fix(ui): improve access-log filters and Caddy config readability Fixes #100 --- README.md | 13 ++ bun.lock | 36 ++++ controller/src/runtime/access_logs.rs | 21 +- controller/src/runtime/access_logs/cache.rs | 84 +++++++- controller/src/runtime/access_logs/reader.rs | 28 ++- controller/src/runtime/access_logs/tests.rs | 32 +++ package.json | 2 + .../Components/ClientIpCountry.tsx | 25 +++ .../Components/ProxyAccessLogsHostFilter.tsx | 152 +++++++++++++++ .../Components/ProxyAccessLogsPageView.tsx | 2 + .../Components/ProxyAccessLogsTable.tsx | 80 ++++---- .../Hooks/useProxyAccessLogsFilterOptions.ts | 24 +++ .../Hooks/useProxyAccessLogsHostFilter.ts | 184 ++++++++++++++++++ .../Hooks/useProxyAccessLogsLogic.ts | 12 +- .../Types/proxy-access-logs.types.ts | 2 + .../Admin/ProxyAccessLogs/validation.ts | 12 +- .../Components/CaddyConfigCodeBlock.tsx | 162 +++++++++++++++ .../ProxyGlobalConfigEditorModal.tsx | 55 ++---- .../Hooks/useProxyGlobalConfigEditorLogic.ts | 88 +-------- .../Types/proxy-config-editor.types.ts | 4 +- web/src/language/Locales/de.json | 8 +- web/src/language/Locales/en.json | 8 +- web/src/language/Locales/es.json | 8 +- web/src/language/Locales/fr.json | 8 +- .../Admin/ProxyAccessLogs/client-country.ts | 8 + .../proxy-access-logs.service.ts | 33 +++- web/src/shared/Types/geoip-country.d.ts | 3 + .../shared/Types/proxy-access-logs.types.ts | 3 + web/src/styles.css | 1 + .../tests/caddy-config-code-block.test.tsx | 63 ++++++ web/src/tests/client-country.test.ts | 25 +++ web/src/tests/client-ip-country-ui.test.tsx | 50 +++++ .../tests/proxy-access-logs-service.test.ts | 13 ++ web/src/tests/proxy-access-logs-ui.test.tsx | 64 ++++-- web/src/tests/proxy-access-logs.test.ts | 4 + web/src/tests/proxy-hosts-ui.test.tsx | 49 +---- 36 files changed, 1129 insertions(+), 237 deletions(-) create mode 100644 web/src/features/Admin/ProxyAccessLogs/Components/ClientIpCountry.tsx create mode 100644 web/src/features/Admin/ProxyAccessLogs/Components/ProxyAccessLogsHostFilter.tsx create mode 100644 web/src/features/Admin/ProxyAccessLogs/Hooks/useProxyAccessLogsFilterOptions.ts create mode 100644 web/src/features/Admin/ProxyAccessLogs/Hooks/useProxyAccessLogsHostFilter.ts create mode 100644 web/src/features/Admin/ProxyHostManagement/Components/CaddyConfigCodeBlock.tsx create mode 100644 web/src/server/Admin/ProxyAccessLogs/client-country.ts create mode 100644 web/src/shared/Types/geoip-country.d.ts create mode 100644 web/src/tests/caddy-config-code-block.test.tsx create mode 100644 web/src/tests/client-country.test.ts create mode 100644 web/src/tests/client-ip-country-ui.test.tsx diff --git a/README.md b/README.md index 0ab35d1..46916f6 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,11 @@ Access Logs use the shared table pagination controls with 15 rows by default and 15, 25, 50 and 100. Filtering and paging run in the controller. Changing filters or page size, or choosing Refresh, starts again on the first page. +The searchable host dropdown includes configured domains and hosts found in the retained +log scan. The status dropdown lists status codes found in that scan. General search matches +host, method, or path and applies after a short typing pause; Apply filters also applies the +current host and status selection immediately. + Each result has a short-lived snapshot so new requests and log rotation cannot move rows between pages. Snapshots expire after two minutes and may be evicted sooner under load; the UI announces a reset and returns to the first page. Refresh loads the latest logs. The controller @@ -218,6 +223,14 @@ keeps at most eight snapshots within a 16 MiB cache. They are temporary, are los and are excluded from backups. The existing bounded log scan and visible truncation notice still apply; pagination does not promise access to logs beyond that retained window. +Client IPs include country flags when the bundled local GeoIP database can resolve them. +Lookups do not send client addresses to an external service. Private and unknown addresses +have no flag; IP geolocation is approximate and describes the logged address, which may be +a forwarding proxy. Database updates ship with updates to the pinned `geoip-country` package. +This product includes GeoLite2 Data created by [MaxMind](https://www.maxmind.com/), distributed +under the GeoLite2 terms included in `geoip-country/LICENSE` and `geoip-country/EULA`. +Flag artwork is provided by [flag-icons](https://github.com/lipis/flag-icons) under the MIT license. + ### Certificates requested from proxy hosts Create and Edit offer a new ACME certificate using the host's domains. The host row also diff --git a/bun.lock b/bun.lock index eaab4c5..a2ae4b0 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,8 @@ "@tanstack/react-start": "1.168.52", "@tanstack/react-table": "9.2.4", "drizzle-orm": "0.45.2", + "flag-icons": "7.5.0", + "geoip-country": "5.0.202609120131", "i18next": "26.4.2", "i18next-resources-to-backend": "1.2.3", "lucide-react": "1.45.0", @@ -588,12 +590,16 @@ "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + "async": ["async@2.6.4", "", { "dependencies": { "lodash": "^4.17.14" } }, "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA=="], + "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.11.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ=="], "browserslist": ["browserslist@4.28.9", "", { "dependencies": { "baseline-browser-mapping": "^2.11.20", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.420", "node-releases": "^2.0.54", "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" } }, "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg=="], + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], @@ -608,6 +614,8 @@ "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], + "countries-list": ["countries-list@3.4.1", "", {}, "sha512-sKLGAZD5EAdzQwQ19vOp/9u33MTjmZ0CkdXPYtp/An6cyIlJ/E8TwGf2FcPe1AnzHO+h7nbTfoHyXIi58Hpa+Q=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -636,14 +644,20 @@ "fast-check": ["fast-check@4.10.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fetchdts": ["fetchdts@0.1.7", "", {}, "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA=="], + "flag-icons": ["flag-icons@7.5.0", "", {}, "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "geoip-country": ["geoip-country@5.0.202609120131", "", { "dependencies": { "async": "^2.6.4", "countries-list": "^3.1.1", "ip-address": "^6.4.0", "yauzl": "^2.10.0" } }, "sha512-hmWVcyxOJkPhidyg91ohTDdq8q1LQrA6CPnHPK+Jo0yGU5bvZEyAz21LDjijc8LJ/aC4/f2pMaS/BjsGyAP1gw=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], "get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="], @@ -658,6 +672,8 @@ "i18next-resources-to-backend": ["i18next-resources-to-backend@1.2.3", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-8Y/LLAm5fqZc2ckQxtTWbu75ndjNLzF7mcYl6rhc4g+WBopIsG3YhdYqF5qhJy64tlYhlU2KSSnEgW3gGHLqwg=="], + "ip-address": ["ip-address@6.4.0", "", { "dependencies": { "jsbn": "1.1.0", "lodash.find": "4.6.0", "lodash.max": "4.0.1", "lodash.merge": "4.6.2", "lodash.padstart": "4.6.1", "lodash.repeat": "4.1.0", "sprintf-js": "1.1.2" } }, "sha512-c5uxc2WUTuRBVHT/6r4m7HIr/DfV0bF6DvLH3iZGSK8wp8iMwwZSgIq2do0asFf8q9ECug0SE+6+1ACMe4sorA=="], + "isbot": ["isbot@5.2.2", "", {}, "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], @@ -666,6 +682,8 @@ "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], + "jsbn": ["jsbn@1.1.0", "", {}, "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], @@ -694,6 +712,18 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + + "lodash.find": ["lodash.find@4.6.0", "", {}, "sha512-yaRZoAV3Xq28F1iafWN1+a0rflOej93l1DQUejs3SZ41h2O9UJBoS9aueGjPDgAl4B6tPC0NuuchLKaDQQ3Isg=="], + + "lodash.max": ["lodash.max@4.0.1", "", {}, "sha512-iykTDTb7PK33HSQmKy34zv+hh4WEu7WonJPXQcgODzUbbtradtNs8RsD/GI7XV++60KaKR1xhW56N4ISqHesfQ=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "lodash.padstart": ["lodash.padstart@4.6.1", "", {}, "sha512-sW73O6S8+Tg66eY56DBk85aQzzUJDtpoXFBgELMd5P/SotAguo+1kYO6RuYgXxA4HJH3LFTFPASX6ET6bjfriw=="], + + "lodash.repeat": ["lodash.repeat@4.1.0", "", {}, "sha512-eWsgQW89IewS95ZOcr15HHCX6FVDxq3f2PNUIng3fyzsPev9imFQxIYdFZ6crl8L56UR6ZlGDLcEb3RZsCSSqw=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lucide-react": ["lucide-react@1.45.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-yH1ubCAduho9UR7oJhRXIQXogksRILBiTuZC4/bQIGeB9JOkxMlSuEHyyZpo1Z3S0yWJO2KTSUZbjiNvVxeOUw=="], @@ -718,6 +748,8 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], @@ -772,6 +804,8 @@ "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "sprintf-js": ["sprintf-js@1.1.2", "", {}, "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="], + "srvx": ["srvx@0.11.22", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ=="], "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], @@ -818,6 +852,8 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + "zod": ["zod@4.6.2", "", {}, "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ=="], "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], diff --git a/controller/src/runtime/access_logs.rs b/controller/src/runtime/access_logs.rs index 9ab6b4b..c39ccd3 100644 --- a/controller/src/runtime/access_logs.rs +++ b/controller/src/runtime/access_logs.rs @@ -17,6 +17,7 @@ pub(crate) const MAX_SNAPSHOTS: usize = 8; pub(crate) const MAX_SNAPSHOT_BYTES: usize = 16 * 1024 * 1024; pub(crate) const MAX_SINGLE_SNAPSHOT_BYTES: usize = MAX_LOG_BYTES; const MAX_SNAPSHOT_ID: usize = 64; +const MAX_AVAILABLE_HOSTS: usize = MAX_RECORDS; const MAX_PATH: usize = 2048; const MAX_HOST: usize = 253; const MAX_METHOD: usize = 32; @@ -139,6 +140,10 @@ pub(crate) struct AccessLogResponse { pub limit: usize, pub offset: usize, pub total: usize, + #[serde(rename = "availableHosts")] + pub available_hosts: Vec, + #[serde(rename = "availableStatuses")] + pub available_statuses: Vec, #[serde(rename = "hasMore")] pub has_more: bool, pub truncated: bool, @@ -165,6 +170,15 @@ pub(crate) struct AccessLogEntry { pub protocol: String, } +fn bounded_available_hosts(hosts: impl IntoIterator) -> Vec { + let mut bounded = hosts + .into_iter() + .take(MAX_AVAILABLE_HOSTS) + .collect::>(); + bounded.shrink_to_fit(); + bounded +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(super) struct SnapshotQuery { host: Option, @@ -269,6 +283,11 @@ fn matches_query(entry: &AccessLogEntry, query: &ValidatedAccessLogQuery) -> boo .is_none_or(|host| entry.host.eq_ignore_ascii_case(host)) && query.status.is_none_or(|status| entry.status == status) && query.search.as_ref().is_none_or(|search| { + let search = search.trim(); + if search.is_empty() { + return true; + } + let search = search.to_ascii_lowercase(); [ entry.host.as_str(), entry.method.as_str(), @@ -278,7 +297,7 @@ fn matches_query(entry: &AccessLogEntry, query: &ValidatedAccessLogQuery) -> boo entry.upstream.as_deref().unwrap_or(""), ] .into_iter() - .any(|field| field.contains(search)) + .any(|field| field.to_ascii_lowercase().contains(&search)) }) } diff --git a/controller/src/runtime/access_logs/cache.rs b/controller/src/runtime/access_logs/cache.rs index 42cfa83..59ce7a7 100644 --- a/controller/src/runtime/access_logs/cache.rs +++ b/controller/src/runtime/access_logs/cache.rs @@ -25,6 +25,8 @@ struct CachedSnapshot { entries: Vec, total: usize, truncated: bool, + available_hosts: Vec, + available_statuses: Vec, expires_at: Instant, expires_at_utc: String, bytes: usize, @@ -88,6 +90,8 @@ impl SnapshotCache { let bytes = snapshot_memory_bytes( capture_query, &capture.entries, + &capture.available_hosts, + &capture.available_statuses, snapshot_id.capacity(), expires_at_utc.capacity(), ); @@ -111,6 +115,8 @@ impl SnapshotCache { total: capture.entries.len(), entries: capture.entries, truncated: capture.truncated, + available_hosts: capture.available_hosts, + available_statuses: capture.available_statuses, expires_at, expires_at_utc, bytes, @@ -183,6 +189,8 @@ fn page_response( limit: query.limit, offset: query.offset, total: snapshot.total, + available_hosts: snapshot.available_hosts.clone(), + available_statuses: snapshot.available_statuses.clone(), has_more, truncated: snapshot.truncated, snapshot: snapshot_id.to_owned(), @@ -194,6 +202,8 @@ fn page_response( fn snapshot_memory_bytes( query: &ValidatedAccessLogQuery, entries: &Vec, + available_hosts: &[String], + available_statuses: &[u16], snapshot_id_capacity: usize, expires_at_capacity: usize, ) -> usize { @@ -202,6 +212,10 @@ fn snapshot_memory_bytes( .saturating_add(snapshot_id_capacity) .saturating_add(expires_at_capacity) .saturating_add(query_memory_bytes(query)) + .saturating_add(available_filters_memory_bytes( + available_hosts, + available_statuses, + )) .saturating_add( entries .capacity() @@ -239,9 +253,15 @@ pub(super) fn cap_snapshot_entries( entries: Vec, query: &ValidatedAccessLogQuery, mut truncated: bool, + available_hosts: &[String], + available_statuses: &[u16], ) -> (Vec, bool) { let base = SNAPSHOT_FIXED_BYTES .saturating_add(query_memory_bytes(query)) + .saturating_add(available_filters_memory_bytes( + available_hosts, + available_statuses, + )) .saturating_add(SNAPSHOT_ID_BYTES * 2) .saturating_add(64); let mut used = base; @@ -261,7 +281,8 @@ pub(super) fn cap_snapshot_entries( let mut bounded = Vec::with_capacity(keep); bounded.extend(entries.into_iter().take(keep)); bounded.shrink_to_fit(); - while snapshot_memory_bytes(query, &bounded, 64, 64) > MAX_SINGLE_SNAPSHOT_BYTES + while snapshot_memory_bytes(query, &bounded, available_hosts, available_statuses, 64, 64) + > MAX_SINGLE_SNAPSHOT_BYTES && !bounded.is_empty() { let next_len = bounded.len().saturating_sub(1); @@ -271,6 +292,14 @@ pub(super) fn cap_snapshot_entries( (bounded, truncated) } +fn available_filters_memory_bytes(hosts: &[String], statuses: &[u16]) -> usize { + hosts + .len() + .saturating_mul(size_of::()) + .saturating_add(hosts.iter().map(String::capacity).sum::()) + .saturating_add(statuses.len().saturating_mul(size_of::())) +} + #[cfg(test)] mod tests { use super::*; @@ -303,11 +332,56 @@ mod tests { bytes: 0, protocol: "HTTP/2".into(), }); - assert!(snapshot_memory_bytes(&query, &entries, 64, 64) > MAX_SINGLE_SNAPSHOT_BYTES); - let (bounded, truncated) = cap_snapshot_entries(entries, &query, false); + assert!( + snapshot_memory_bytes(&query, &entries, &[], &[], 64, 64) > MAX_SINGLE_SNAPSHOT_BYTES + ); + let (bounded, truncated) = cap_snapshot_entries(entries, &query, false, &[], &[]); assert!(!truncated); assert_eq!(bounded.len(), 1); - assert!(snapshot_memory_bytes(&query, &bounded, 64, 64) < 4096); + assert!(snapshot_memory_bytes(&query, &bounded, &[], &[], 64, 64) < 4096); + } + + #[test] + fn metadata_is_reserved_before_truncating_snapshot_entries() { + let query = query(); + let hosts = (0..1000) + .map(|index| format!("host-{index}.{}.example", "a".repeat(200))) + .collect::>(); + let statuses = vec![200]; + let entry = AccessLogEntry { + timestamp: "2026-09-13T00:00:00Z".into(), + host: "app.example".into(), + method: "GET".into(), + path: format!("/{}", "a".repeat(2047)), + status: 200, + duration_ms: 1, + client_ip: "192.0.2.1".into(), + upstream: None, + bytes: 0, + protocol: "HTTP/2".into(), + }; + let (entries, truncated) = + cap_snapshot_entries(vec![entry; 2000], &query, false, &hosts, &statuses); + assert!(truncated); + assert!(!entries.is_empty()); + let cache = SnapshotCache::new(); + let response = cache + .insert( + &query, + &query, + CapturedLogs { + entries, + truncated, + available_hosts: hosts, + available_statuses: statuses, + }, + false, + Instant::now(), + ) + .unwrap(); + assert_eq!(response.available_hosts.len(), 1000); + assert!(response.truncated); + assert!(cache.state.lock().unwrap().bytes <= MAX_SINGLE_SNAPSHOT_BYTES); } #[test] @@ -323,6 +397,8 @@ mod tests { CapturedLogs { entries, truncated: false, + available_hosts: Vec::new(), + available_statuses: Vec::new(), }, false, Instant::now(), diff --git a/controller/src/runtime/access_logs/reader.rs b/controller/src/runtime/access_logs/reader.rs index 8969107..718a1c8 100644 --- a/controller/src/runtime/access_logs/reader.rs +++ b/controller/src/runtime/access_logs/reader.rs @@ -1,5 +1,6 @@ use std::{ cmp::{Ordering, Reverse}, + collections::BTreeSet, fs::{self, File}, io::{ErrorKind, Read, Seek, SeekFrom}, path::Path, @@ -28,6 +29,8 @@ pub(crate) enum ReadError { pub(crate) struct CapturedLogs { pub(crate) entries: Vec, pub(crate) truncated: bool, + pub(crate) available_hosts: Vec, + pub(crate) available_statuses: Vec, } struct IndexedEntry { @@ -91,6 +94,8 @@ pub(crate) fn read_blocking( limit: query.limit, offset: query.offset, total, + available_hosts: capture.available_hosts, + available_statuses: capture.available_statuses, has_more: query.offset.saturating_add(page_len) < total, truncated: capture.truncated, snapshot: String::new(), @@ -103,6 +108,8 @@ fn empty_capture() -> CapturedLogs { CapturedLogs { entries: Vec::new(), truncated: false, + available_hosts: Vec::new(), + available_statuses: Vec::new(), } } @@ -153,6 +160,8 @@ fn read_snapshot_blocking( let mut scanned = 0usize; let mut truncated = inventory_truncated || file_limit_truncated; let mut matches = Vec::new(); + let mut available_hosts = BTreeSet::new(); + let mut available_statuses = BTreeSet::new(); for (_, name) in files { if remaining == 0 || scanned >= MAX_RECORDS { truncated = true; @@ -188,6 +197,8 @@ fn read_snapshot_blocking( let Some(entry) = parse_entry(&value, cutoff) else { continue; }; + available_hosts.insert(entry.host.clone()); + available_statuses.insert(entry.status); if matches_query(&entry, query) { matches.push(IndexedEntry { timestamp_nanos: OffsetDateTime::parse(&entry.timestamp, &Rfc3339) @@ -202,8 +213,21 @@ fn read_snapshot_blocking( } matches.sort_by(compare_indexed_entries); let matches = matches.into_iter().map(|indexed| indexed.entry).collect(); - let (entries, truncated) = cap_snapshot_entries(matches, query, truncated); - Ok(CapturedLogs { entries, truncated }) + let available_hosts = super::bounded_available_hosts(available_hosts); + let available_statuses = available_statuses.into_iter().collect::>(); + let (entries, truncated) = cap_snapshot_entries( + matches, + query, + truncated, + &available_hosts, + &available_statuses, + ); + Ok(CapturedLogs { + entries, + truncated, + available_hosts, + available_statuses, + }) } fn compare_indexed_entries(left: &IndexedEntry, right: &IndexedEntry) -> Ordering { diff --git a/controller/src/runtime/access_logs/tests.rs b/controller/src/runtime/access_logs/tests.rs index 29c41aa..b61497d 100644 --- a/controller/src/runtime/access_logs/tests.rs +++ b/controller/src/runtime/access_logs/tests.rs @@ -164,6 +164,32 @@ fn filters_and_pagination_report_filtered_indexes() { assert_eq!(response.entries[0].path, "/two"); } +#[test] +fn search_matches_partial_host_without_returning_unrelated_rows() { + let state = TempState::new(true); + let now = OffsetDateTime::now_utc().unix_timestamp() as f64; + state.write( + "access.log", + &format!( + "{}\n{}\n", + line(now, "ts-laser.example", "/laser", 200, "laser"), + line(now, "other.example", "/other", 200, "other") + ), + ); + let mut filtered = query(20, 0); + filtered.search = Some("ts-laser".into()); + + let response = read_blocking(state.path(), &filtered).unwrap(); + + assert_eq!(response.total, 1); + assert_eq!(response.entries[0].host, "ts-laser.example"); + assert_eq!( + response.available_hosts, + vec!["other.example", "ts-laser.example"] + ); + assert_eq!(response.available_statuses, vec![200]); +} + #[test] fn malformed_and_partial_lines_are_ignored() { let state = TempState::new(true); @@ -336,6 +362,8 @@ fn snapshot_cache_expiry_is_fixed_and_evicts_lru_entries() { CapturedLogs { entries: Vec::new(), truncated: false, + available_hosts: Vec::new(), + available_statuses: Vec::new(), }, false, now, @@ -374,6 +402,8 @@ fn snapshot_cache_expiry_is_fixed_and_evicts_lru_entries() { CapturedLogs { entries: Vec::new(), truncated: false, + available_hosts: Vec::new(), + available_statuses: Vec::new(), }, false, now, @@ -392,6 +422,8 @@ fn snapshot_cache_expiry_is_fixed_and_evicts_lru_entries() { CapturedLogs { entries: Vec::new(), truncated: false, + available_hosts: Vec::new(), + available_statuses: Vec::new(), }, false, now, diff --git a/package.json b/package.json index 2710232..17dbf40 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,8 @@ "@tanstack/react-start": "1.168.52", "@tanstack/react-table": "9.2.4", "drizzle-orm": "0.45.2", + "flag-icons": "7.5.0", + "geoip-country": "5.0.202609120131", "i18next": "26.4.2", "i18next-resources-to-backend": "1.2.3", "lucide-react": "1.45.0", diff --git a/web/src/features/Admin/ProxyAccessLogs/Components/ClientIpCountry.tsx b/web/src/features/Admin/ProxyAccessLogs/Components/ClientIpCountry.tsx new file mode 100644 index 0000000..c28417d --- /dev/null +++ b/web/src/features/Admin/ProxyAccessLogs/Components/ClientIpCountry.tsx @@ -0,0 +1,25 @@ +import useTranslationStore from '../../../../language/useTranslationStore' +import { Tooltip } from '../../../../shared/Tooltip' +import type { ProxyAccessLogEntry } from '../../../../shared/Types/proxy-access-logs.types' + +export default function ClientIpCountry({ entry }: { readonly entry: ProxyAccessLogEntry }) { + const { locale } = useTranslationStore() + const code = entry.countryCode + const country = + code && /^[A-Z]{2}$/u.test(code) + ? new Intl.DisplayNames([locale], { type: 'region' }).of(code) + : undefined + + return ( + + {entry.clientIp} + {country && code ? ( + + + {country} + + + ) : null} + + ) +} diff --git a/web/src/features/Admin/ProxyAccessLogs/Components/ProxyAccessLogsHostFilter.tsx b/web/src/features/Admin/ProxyAccessLogs/Components/ProxyAccessLogsHostFilter.tsx new file mode 100644 index 0000000..69d95c9 --- /dev/null +++ b/web/src/features/Admin/ProxyAccessLogs/Components/ProxyAccessLogsHostFilter.tsx @@ -0,0 +1,152 @@ +import { ChevronDown } from 'lucide-react' + +import useProxyAccessLogsHostFilter from '../Hooks/useProxyAccessLogsHostFilter' + +interface ProxyAccessLogsHostFilterProps { + readonly allHostsLabel: string + readonly ariaDescribedBy?: string | undefined + readonly invalid: boolean + readonly label: string + readonly noResultsLabel: string + readonly onChange: (value: string) => void + readonly options: readonly string[] + readonly placeholder: string + readonly searchPlaceholder: string + readonly value: string +} + +const controlClassName = + 'box-border inline-flex h-12 w-full min-w-0 items-center justify-between gap-2 rounded-xl border border-input-border bg-surface-raised px-3 text-left text-sm text-ink outline-hidden transition-[border-color,box-shadow,background-color] hover:border-border-strong focus:border-brand-600 focus:ring-[3px] focus:ring-brand-500/20 aria-invalid:border-red-500' +const optionClassName = + 'flex min-h-10 w-full cursor-pointer items-center rounded-lg px-3 py-2 text-left text-sm font-bold text-ink-soft transition-colors hover:bg-surface-hover focus:bg-surface-hover focus:outline-hidden' + +export default function ProxyAccessLogsHostFilter({ + allHostsLabel, + ariaDescribedBy, + invalid, + label, + noResultsLabel, + onChange, + options, + placeholder, + searchPlaceholder, + value, +}: ProxyAccessLogsHostFilterProps) { + const { + activeIndex, + filteredOptions, + handleOptionKeyDown, + handleSearchChange, + handleSearchKeyDown, + handleTriggerKeyDown, + listboxId, + open, + search, + setOptionRef, + setRootRef, + setSearchRef, + setTriggerRef, + selectOption, + toggle, + } = useProxyAccessLogsHostFilter({ onChange, options }) + + return ( +
+ + {open ? ( +
+
+ handleSearchChange(event.target.value)} + onKeyDown={handleSearchKeyDown} + className="box-border h-10 w-full rounded-lg border border-input-border bg-surface px-3 text-sm text-ink outline-hidden placeholder:text-muted-soft focus:border-brand-600 focus:ring-[3px] focus:ring-brand-500/20" + /> +
+
+ + {filteredOptions.length > 0 ? ( + filteredOptions.map((option, optionIndex) => { + const index = optionIndex + 1 + return ( + + ) + }) + ) : ( +
+ {noResultsLabel} +
+ )} +
+
+ ) : null} +
+ ) +} diff --git a/web/src/features/Admin/ProxyAccessLogs/Components/ProxyAccessLogsPageView.tsx b/web/src/features/Admin/ProxyAccessLogs/Components/ProxyAccessLogsPageView.tsx index 82f3f66..697b6a6 100644 --- a/web/src/features/Admin/ProxyAccessLogs/Components/ProxyAccessLogsPageView.tsx +++ b/web/src/features/Admin/ProxyAccessLogs/Components/ProxyAccessLogsPageView.tsx @@ -43,6 +43,8 @@ export default function ProxyAccessLogsPageView({ ) : (
{ event.preventDefault() onApplyFilters() }} > -