a cache for slack profile pictures and emojis

Compare changes

Choose any two refs to compare.

Changed files
+1066 -801
.github
workflows
src
+56 -5
.github/workflows/deploy.yaml
···
+
name: Deploy Cachet
+
on:
push:
branches:
- main
workflow_dispatch:
+
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
+
- name: Setup Tailscale
uses: tailscale/github-action@v3
with:
···
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:ci
use-cache: "true"
+
- name: Configure SSH
run: |
mkdir -p ~/.ssh
echo "StrictHostKeyChecking no" >> ~/.ssh/config
-
- name: file commands
+
+
- name: Deploy to server
run: |
-
ssh kierank@ember << 'EOF'
-
cd ~/cachet
+
ssh cachet@terebithia << 'EOF'
+
cd /var/lib/cachet/app
git fetch --all
git reset --hard origin/main
-
~/.bun/bin/bun install
-
sudo /usr/bin/systemctl restart cachet.service
+
bun install
+
sudo /run/current-system/sw/bin/systemctl restart cachet.service
EOF
+
+
- name: Wait for service to start
+
run: sleep 10
+
+
- name: Health check
+
run: |
+
HEALTH_URL="https://cachet.dunkirk.sh/health?detailed=true"
+
MAX_RETRIES=6
+
RETRY_DELAY=5
+
+
for i in $(seq 1 $MAX_RETRIES); do
+
echo "Health check attempt $i/$MAX_RETRIES..."
+
+
RESPONSE=$(curl -s -w "\n%{http_code}" "$HEALTH_URL" || echo "000")
+
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
+
BODY=$(echo "$RESPONSE" | sed '$d')
+
+
if [ "$HTTP_CODE" = "200" ]; then
+
STATUS=$(echo "$BODY" | jq -r '.status // "unknown"')
+
+
if [ "$STATUS" = "healthy" ]; then
+
echo "โœ… Service is healthy"
+
echo "$BODY" | jq '.'
+
exit 0
+
elif [ "$STATUS" = "degraded" ]; then
+
echo "โš ๏ธ Service is degraded but responding"
+
echo "$BODY" | jq '.'
+
exit 0
+
else
+
echo "โš ๏ธ Service returned HTTP 200 but status is: $STATUS"
+
echo "$BODY" | jq '.'
+
fi
+
fi
+
+
echo "โŒ Health check failed with HTTP $HTTP_CODE"
+
echo "$BODY"
+
+
if [ $i -lt $MAX_RETRIES ]; then
+
echo "Retrying in ${RETRY_DELAY}s..."
+
sleep $RETRY_DELAY
+
fi
+
done
+
+
echo "โŒ Health check failed after $MAX_RETRIES attempts"
+
exit 1
+20 -24
bun.lock
···
{
"lockfileVersion": 1,
+
"configVersion": 1,
"workspaces": {
"": {
"name": "cachet",
"dependencies": {
-
"@sentry/bun": "^9.40.0",
-
"@tqman/nice-logger": "^1.0.7",
+
"@sentry/bun": "^9.47.1",
+
"@tqman/nice-logger": "^1.1.1",
"@types/node-cron": "^3.0.11",
"bottleneck": "^2.19.5",
-
"elysia": "1.1.26",
"node-cron": "^3.0.3",
"sentry": "^0.1.2",
},
···
"@types/bun": "latest",
},
"peerDependencies": {
-
"typescript": "^5",
+
"typescript": "^5.9.3",
},
},
},
···
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg=="],
-
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.36.0", "", {}, "sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ=="],
+
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.38.0", "", {}, "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg=="],
"@opentelemetry/sql-common": ["@opentelemetry/sql-common@0.40.1", "", { "dependencies": { "@opentelemetry/core": "^1.1.0" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0" } }, "sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg=="],
"@prisma/instrumentation": ["@prisma/instrumentation@6.11.1", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-mrZOev24EDhnefmnZX7WVVT7v+r9LttPRqf54ONvj6re4XMF7wFTpK2tLJi4XHB7fFp/6xhYbgRel8YV7gQiyA=="],
-
"@sentry/bun": ["@sentry/bun@9.40.0", "", { "dependencies": { "@sentry/core": "9.40.0", "@sentry/node": "9.40.0" } }, "sha512-QozLfHUp3aFhhW9qbPbuk3hqiE1fI48/gl6FsYvGixtDRdCLt/7/3VujnVRTuhgn/Yv20y8vdPtqIPuVyPH09Q=="],
+
"@sentry/bun": ["@sentry/bun@9.47.1", "", { "dependencies": { "@sentry/core": "9.47.1", "@sentry/node": "9.47.1" } }, "sha512-E6EuHL+P/nXe1ON+CJuG5nZ/T5r9hjqcYQfBp/yodXUqvAV6Kv/n3K6P0pdad9LObO5PlfEhsoi0HOtbTu9z9Q=="],
-
"@sentry/core": ["@sentry/core@9.40.0", "", {}, "sha512-cZkuz6BDna6VXSqvlWnrRsaDx4QBKq1PcfQrqhVz8ljs0M7Gcl+Mtj8dCzUxx12fkYM62hQXG72DEGNlAQpH/Q=="],
+
"@sentry/core": ["@sentry/core@9.47.1", "", {}, "sha512-KX62+qIt4xgy8eHKHiikfhz2p5fOciXd0Cl+dNzhgPFq8klq4MGMNaf148GB3M/vBqP4nw/eFvRMAayFCgdRQw=="],
-
"@sentry/node": ["@sentry/node@9.40.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1", "@opentelemetry/core": "^1.30.1", "@opentelemetry/instrumentation": "^0.57.2", "@opentelemetry/instrumentation-amqplib": "^0.46.1", "@opentelemetry/instrumentation-connect": "0.43.1", "@opentelemetry/instrumentation-dataloader": "0.16.1", "@opentelemetry/instrumentation-express": "0.47.1", "@opentelemetry/instrumentation-fs": "0.19.1", "@opentelemetry/instrumentation-generic-pool": "0.43.1", "@opentelemetry/instrumentation-graphql": "0.47.1", "@opentelemetry/instrumentation-hapi": "0.45.2", "@opentelemetry/instrumentation-http": "0.57.2", "@opentelemetry/instrumentation-ioredis": "0.47.1", "@opentelemetry/instrumentation-kafkajs": "0.7.1", "@opentelemetry/instrumentation-knex": "0.44.1", "@opentelemetry/instrumentation-koa": "0.47.1", "@opentelemetry/instrumentation-lru-memoizer": "0.44.1", "@opentelemetry/instrumentation-mongodb": "0.52.0", "@opentelemetry/instrumentation-mongoose": "0.46.1", "@opentelemetry/instrumentation-mysql": "0.45.1", "@opentelemetry/instrumentation-mysql2": "0.45.2", "@opentelemetry/instrumentation-pg": "0.51.1", "@opentelemetry/instrumentation-redis-4": "0.46.1", "@opentelemetry/instrumentation-tedious": "0.18.1", "@opentelemetry/instrumentation-undici": "0.10.1", "@opentelemetry/resources": "^1.30.1", "@opentelemetry/sdk-trace-base": "^1.30.1", "@opentelemetry/semantic-conventions": "^1.34.0", "@prisma/instrumentation": "6.11.1", "@sentry/core": "9.40.0", "@sentry/node-core": "9.40.0", "@sentry/opentelemetry": "9.40.0", "import-in-the-middle": "^1.14.2", "minimatch": "^9.0.0" } }, "sha512-8bVWChXzGH4QmbVw+H/yiJ6zxqPDhnx11fEAP+vpL1UBm1cAV67CoB4eS7OqQdPC8gF/BQb2sqF0TvY/12NPpA=="],
+
"@sentry/node": ["@sentry/node@9.47.1", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1", "@opentelemetry/core": "^1.30.1", "@opentelemetry/instrumentation": "^0.57.2", "@opentelemetry/instrumentation-amqplib": "^0.46.1", "@opentelemetry/instrumentation-connect": "0.43.1", "@opentelemetry/instrumentation-dataloader": "0.16.1", "@opentelemetry/instrumentation-express": "0.47.1", "@opentelemetry/instrumentation-fs": "0.19.1", "@opentelemetry/instrumentation-generic-pool": "0.43.1", "@opentelemetry/instrumentation-graphql": "0.47.1", "@opentelemetry/instrumentation-hapi": "0.45.2", "@opentelemetry/instrumentation-http": "0.57.2", "@opentelemetry/instrumentation-ioredis": "0.47.1", "@opentelemetry/instrumentation-kafkajs": "0.7.1", "@opentelemetry/instrumentation-knex": "0.44.1", "@opentelemetry/instrumentation-koa": "0.47.1", "@opentelemetry/instrumentation-lru-memoizer": "0.44.1", "@opentelemetry/instrumentation-mongodb": "0.52.0", "@opentelemetry/instrumentation-mongoose": "0.46.1", "@opentelemetry/instrumentation-mysql": "0.45.1", "@opentelemetry/instrumentation-mysql2": "0.45.2", "@opentelemetry/instrumentation-pg": "0.51.1", "@opentelemetry/instrumentation-redis-4": "0.46.1", "@opentelemetry/instrumentation-tedious": "0.18.1", "@opentelemetry/instrumentation-undici": "0.10.1", "@opentelemetry/resources": "^1.30.1", "@opentelemetry/sdk-trace-base": "^1.30.1", "@opentelemetry/semantic-conventions": "^1.34.0", "@prisma/instrumentation": "6.11.1", "@sentry/core": "9.47.1", "@sentry/node-core": "9.47.1", "@sentry/opentelemetry": "9.47.1", "import-in-the-middle": "^1.14.2", "minimatch": "^9.0.0" } }, "sha512-CDbkasBz3fnWRKSFs6mmaRepM2pa+tbZkrqhPWifFfIkJDidtVW40p6OnquTvPXyPAszCnDZRnZT14xyvNmKPQ=="],
-
"@sentry/node-core": ["@sentry/node-core@9.40.0", "", { "dependencies": { "@sentry/core": "9.40.0", "@sentry/opentelemetry": "9.40.0", "import-in-the-middle": "^1.14.2" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", "@opentelemetry/core": "^1.30.1 || ^2.0.0", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/resources": "^1.30.1 || ^2.0.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" } }, "sha512-97JONDa8NxItX0Cz5WQPMd1gQjzodt38qQ0OzZNFvYg2Cpvxob8rxwsNA08Liu7B97rlvsvqMt+Wbgw8SAMfgQ=="],
+
"@sentry/node-core": ["@sentry/node-core@9.47.1", "", { "dependencies": { "@sentry/core": "9.47.1", "@sentry/opentelemetry": "9.47.1", "import-in-the-middle": "^1.14.2" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", "@opentelemetry/core": "^1.30.1 || ^2.0.0", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/resources": "^1.30.1 || ^2.0.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" } }, "sha512-7TEOiCGkyShJ8CKtsri9lbgMCbB+qNts2Xq37itiMPN2m+lIukK3OX//L8DC5nfKYZlgikrefS63/vJtm669hQ=="],
-
"@sentry/opentelemetry": ["@sentry/opentelemetry@9.40.0", "", { "dependencies": { "@sentry/core": "9.40.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", "@opentelemetry/core": "^1.30.1 || ^2.0.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" } }, "sha512-POQ/ZFmBbi15z3EO9gmTExpxCfW0Ug+WooA8QZPJaizo24gcF5AMOgwuGFwT2YLw/2HdPWjPUPujNNGdCWM6hw=="],
+
"@sentry/opentelemetry": ["@sentry/opentelemetry@9.47.1", "", { "dependencies": { "@sentry/core": "9.47.1" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", "@opentelemetry/core": "^1.30.1 || ^2.0.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" } }, "sha512-STtFpjF7lwzeoedDJV+5XA6P89BfmFwFftmHSGSe3UTI8z8IoiR5yB6X2vCjSPvXlfeOs13qCNNCEZyznxM8Xw=="],
"@sinclair/typebox": ["@sinclair/typebox@0.32.34", "", {}, "sha512-a3Z3ytYl6R/+7ldxx04PO1semkwWlX/8pTqxsPw4quIcIXDFPZhOc1Wx8azWmkU26ccK3mHwcWenn0avNgAKQg=="],
"@tqman/nice-logger": ["@tqman/nice-logger@1.1.1", "", { "dependencies": { "picocolors": "^1.1.1" }, "peerDependencies": { "elysia": ">=1.2.0" } }, "sha512-/0PWFNXaUE3Lx9IQ8O0aurH+PW7x9GrCK/WyY4vVVWPz+fShijmt9vPCHLI226AZvwVfvU4BOIrQdCqzsBgeVg=="],
-
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
+
"@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/mysql": ["@types/mysql@2.15.26", "", { "dependencies": { "@types/node": "*" } }, "sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ=="],
-
"@types/node": ["@types/node@24.0.15", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-oaeTSbCef7U/z7rDeJA138xpG3NuKc64/rZ2qmUFkFJmnMsAPaluIifqyWd8hSSMxyP9oie3dLAqYPblag9KgA=="],
+
"@types/node": ["@types/node@24.10.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA=="],
"@types/node-cron": ["@types/node-cron@3.0.11", "", {}, "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg=="],
"@types/pg": ["@types/pg@8.6.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w=="],
"@types/pg-pool": ["@types/pg-pool@2.0.6", "", { "dependencies": { "@types/pg": "*" } }, "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ=="],
-
-
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
···
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
-
"bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="],
+
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
"cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
"coffee-script": ["coffee-script@1.12.7", "", { "bin": { "coffee": "./bin/coffee", "cake": "./bin/cake" } }, "sha512-fLeEhqwymYat/MpTPUjSKHVYYl0ec2mOyALEMLmzr5i1isuG+6jfI2j2d5oBO3VIzgUXgBVIcOT9uH1TFxBckw=="],
-
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
+
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
-
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
-
-
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
+
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"elysia": ["elysia@1.1.26", "", { "dependencies": { "@sinclair/typebox": "0.32.34", "cookie": "^1.0.1", "fast-decode-uri-component": "^1.0.1", "openapi-types": "^12.1.3" }, "peerDependencies": { "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-wtHa46hP4cMzzgq+WlqVMz9th56FlEdS1UDJgtAJD3rQkjovidySKxFfdKEr16pulaleBo5OC1BQL35XhGaW0Q=="],
···
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
-
"import-in-the-middle": ["import-in-the-middle@1.14.2", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-5tCuY9BV8ujfOpwtAGgsTx9CGUapcFMEEyByLv1B+v2+6DhAcw+Zr0nhQT7uwaZ7DiourxFEscghOR8e1aPLQw=="],
+
"import-in-the-middle": ["import-in-the-middle@1.15.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
···
"require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="],
-
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
+
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
-
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
+
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"sentry": ["sentry@0.1.2", "", { "dependencies": { "coffee-script": "*", "file": "*", "underscore": "*" } }, "sha512-WVMYvjMCqdMwfins7AkwBhCINvw0EfXj0Aeo/YUQYyD6VOhAQPDM7uxnPDBzd2N50NR8w+yb3irHxSbd9Wy9OQ=="],
···
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
-
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"underscore": ["underscore@1.13.7", "", {}, "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g=="],
-
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
+
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
+3 -4
package.json
···
"build": "bun build --compile --outfile dist/cachet --production ./src/index.ts"
},
"dependencies": {
-
"@sentry/bun": "^9.40.0",
-
"@tqman/nice-logger": "^1.0.7",
+
"@sentry/bun": "^9.47.1",
+
"@tqman/nice-logger": "^1.1.1",
"@types/node-cron": "^3.0.11",
"bottleneck": "^2.19.5",
-
"elysia": "1.1.26",
"node-cron": "^3.0.3",
"sentry": "^0.1.2"
},
···
},
"private": true,
"peerDependencies": {
-
"typescript": "^5"
+
"typescript": "^5.9.3"
}
}
+145 -25
src/cache.ts
···
/**
* Discriminated union for all analytics cache data types
*/
-
type AnalyticsCacheData =
-
| { type: 'analytics'; data: FullAnalyticsData }
-
| { type: 'essential'; data: EssentialStatsData }
-
| { type: 'charts'; data: ChartData }
-
| { type: 'useragents'; data: UserAgentData };
+
type AnalyticsCacheData =
+
| { type: "analytics"; data: FullAnalyticsData }
+
| { type: "essential"; data: EssentialStatsData }
+
| { type: "charts"; data: ChartData }
+
| { type: "useragents"; data: UserAgentData };
/**
* Type-safe analytics cache entry
···
/**
* Type guard functions for cache data
*/
-
function isAnalyticsData(data: AnalyticsCacheData): data is { type: 'analytics'; data: FullAnalyticsData } {
-
return data.type === 'analytics';
+
function isAnalyticsData(
+
data: AnalyticsCacheData,
+
): data is { type: "analytics"; data: FullAnalyticsData } {
+
return data.type === "analytics";
}
-
function isEssentialStatsData(data: AnalyticsCacheData): data is { type: 'essential'; data: EssentialStatsData } {
-
return data.type === 'essential';
+
function isEssentialStatsData(
+
data: AnalyticsCacheData,
+
): data is { type: "essential"; data: EssentialStatsData } {
+
return data.type === "essential";
}
-
function isChartData(data: AnalyticsCacheData): data is { type: 'charts'; data: ChartData } {
-
return data.type === 'charts';
+
function isChartData(
+
data: AnalyticsCacheData,
+
): data is { type: "charts"; data: ChartData } {
+
return data.type === "charts";
}
-
function isUserAgentData(data: AnalyticsCacheData): data is { type: 'useragents'; data: UserAgentData } {
-
return data.type === 'useragents';
+
function isUserAgentData(
+
data: AnalyticsCacheData,
+
): data is { type: "useragents"; data: UserAgentData } {
+
return data.type === "useragents";
}
/**
···
getAnalyticsData(key: string): FullAnalyticsData | null {
const cached = this.cache.get(key);
const now = Date.now();
-
-
if (cached && now - cached.timestamp < this.cacheTTL && isAnalyticsData(cached.data)) {
+
+
if (
+
cached &&
+
now - cached.timestamp < this.cacheTTL &&
+
isAnalyticsData(cached.data)
+
) {
return cached.data.data;
}
return null;
···
getEssentialStatsData(key: string): EssentialStatsData | null {
const cached = this.cache.get(key);
const now = Date.now();
-
-
if (cached && now - cached.timestamp < this.cacheTTL && isEssentialStatsData(cached.data)) {
+
+
if (
+
cached &&
+
now - cached.timestamp < this.cacheTTL &&
+
isEssentialStatsData(cached.data)
+
) {
return cached.data.data;
}
return null;
···
getChartData(key: string): ChartData | null {
const cached = this.cache.get(key);
const now = Date.now();
-
-
if (cached && now - cached.timestamp < this.cacheTTL && isChartData(cached.data)) {
+
+
if (
+
cached &&
+
now - cached.timestamp < this.cacheTTL &&
+
isChartData(cached.data)
+
) {
return cached.data.data;
}
return null;
···
getUserAgentData(key: string): UserAgentData | null {
const cached = this.cache.get(key);
const now = Date.now();
-
-
if (cached && now - cached.timestamp < this.cacheTTL && isUserAgentData(cached.data)) {
+
+
if (
+
cached &&
+
now - cached.timestamp < this.cacheTTL &&
+
isUserAgentData(cached.data)
+
) {
return cached.data.data;
}
return null;
···
* Set analytics data in cache with type safety
*/
setAnalyticsData(key: string, data: FullAnalyticsData): void {
-
this.setCacheEntry(key, { type: 'analytics', data });
+
this.setCacheEntry(key, { type: "analytics", data });
}
/**
* Set essential stats data in cache with type safety
*/
setEssentialStatsData(key: string, data: EssentialStatsData): void {
-
this.setCacheEntry(key, { type: 'essential', data });
+
this.setCacheEntry(key, { type: "essential", data });
}
/**
* Set chart data in cache with type safety
*/
setChartData(key: string, data: ChartData): void {
-
this.setCacheEntry(key, { type: 'charts', data });
+
this.setCacheEntry(key, { type: "charts", data });
}
/**
* Set user agent data in cache with type safety
*/
setUserAgentData(key: string, data: UserAgentData): void {
-
this.setCacheEntry(key, { type: 'useragents', data });
+
this.setCacheEntry(key, { type: "useragents", data });
}
/**
···
console.error("Cache health check failed:", error);
return false;
}
+
}
+
+
/**
+
* Detailed health check with component status
+
* @returns Object with detailed health information
+
*/
+
async detailedHealthCheck(): Promise<{
+
status: "healthy" | "degraded" | "unhealthy";
+
checks: {
+
database: { status: boolean; latency?: number };
+
slackApi: { status: boolean; error?: string };
+
queueDepth: number;
+
memoryUsage: {
+
heapUsed: number;
+
heapTotal: number;
+
percentage: number;
+
};
+
};
+
uptime: number;
+
}> {
+
const checks = {
+
database: { status: false, latency: 0 },
+
slackApi: { status: false },
+
queueDepth: this.userUpdateQueue.size,
+
memoryUsage: {
+
heapUsed: 0,
+
heapTotal: 0,
+
percentage: 0,
+
},
+
};
+
+
// Check database
+
try {
+
const start = Date.now();
+
this.db.query("SELECT 1").get();
+
checks.database = { status: true, latency: Date.now() - start };
+
} catch (error) {
+
console.error("Database health check failed:", error);
+
}
+
+
// Check Slack API if wrapper is available
+
if (this.slackWrapper) {
+
try {
+
await this.slackWrapper.getUserInfo("U062UG485EE"); // Use a known test user
+
checks.slackApi = { status: true };
+
} catch (error) {
+
checks.slackApi = {
+
status: false,
+
error: error instanceof Error ? error.message : "Unknown error",
+
};
+
}
+
} else {
+
checks.slackApi = { status: true }; // No wrapper means not critical
+
}
+
+
// Check memory usage
+
const memUsage = process.memoryUsage();
+
const bytesToMiB = (bytes: number) => bytes / 1024 / 1024;
+
+
const heapUsedMiB = bytesToMiB(memUsage.heapUsed);
+
const heapTotalMiB = bytesToMiB(memUsage.heapTotal);
+
const heapPercent =
+
heapTotalMiB > 0 ? (heapUsedMiB / heapTotalMiB) * 100 : 0;
+
const rssMiB = bytesToMiB(memUsage.rss);
+
const externalMiB = bytesToMiB(memUsage.external || 0);
+
const arrayBuffersMiB = bytesToMiB(memUsage.arrayBuffers || 0);
+
+
checks.memoryUsage = {
+
heapUsed: Math.round(heapUsedMiB),
+
heapTotal: Math.round(heapTotalMiB),
+
percentage: Math.round(heapPercent),
+
details: {
+
heapUsedMiB: Number(heapUsedMiB.toFixed(2)),
+
heapTotalMiB: Number(heapTotalMiB.toFixed(2)),
+
heapPercent: Number(heapPercent.toFixed(2)),
+
rssMiB: Number(rssMiB.toFixed(2)),
+
externalMiB: Number(externalMiB.toFixed(2)),
+
arrayBuffersMiB: Number(arrayBuffersMiB.toFixed(2)),
+
},
+
};
+
+
// Determine overall status
+
let status: "healthy" | "degraded" | "unhealthy" = "healthy";
+
if (!checks.database.status) {
+
status = "unhealthy";
+
} else if (!checks.slackApi.status || checks.queueDepth > 100) {
+
status = "degraded";
+
} else if (checks.memoryUsage.percentage >= 120) {
+
status = "degraded";
+
}
+
+
return {
+
status,
+
checks,
+
uptime: process.uptime(),
+
};
}
/**
+747 -740
src/dashboard.html
···
<!doctype html>
<html lang="en">
-
<head>
-
<meta charset="UTF-8" />
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
-
<title>Cachet Analytics Dashboard</title>
-
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
-
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
-
<style>
-
* {
-
margin: 0;
-
padding: 0;
-
box-sizing: border-box;
-
}
-
body {
-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
-
sans-serif;
-
background: #f9fafb;
-
color: #111827;
-
line-height: 1.6;
-
}
+
<head>
+
<meta charset="UTF-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
<title>Cachet Analytics Dashboard</title>
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
+
<script
+
src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
+
<style>
+
* {
+
margin: 0;
+
padding: 0;
+
box-sizing: border-box;
+
}
-
.header {
-
background: #fff;
-
padding: 1.5rem 2rem;
-
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
-
margin-bottom: 2rem;
-
border-bottom: 1px solid #e5e7eb;
-
}
+
body {
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
+
sans-serif;
+
background: #f9fafb;
+
color: #111827;
+
line-height: 1.6;
+
}
-
.header h1 {
-
color: #111827;
-
font-size: 1.875rem;
-
font-weight: 700;
-
margin-bottom: 0.5rem;
-
}
+
.header {
+
background: #fff;
+
padding: 1.5rem 2rem;
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+
margin-bottom: 2rem;
+
border-bottom: 1px solid #e5e7eb;
+
}
-
.header-links {
-
display: flex;
-
gap: 1.5rem;
-
}
+
.header h1 {
+
color: #111827;
+
font-size: 1.875rem;
+
font-weight: 700;
+
margin-bottom: 0.5rem;
+
}
-
.header-links a {
-
color: #6366f1;
-
text-decoration: none;
-
font-weight: 500;
-
}
+
.header-links {
+
display: flex;
+
gap: 1.5rem;
+
}
-
.header-links a:hover {
-
color: #4f46e5;
-
text-decoration: underline;
-
}
+
.header-links a {
+
color: #6366f1;
+
text-decoration: none;
+
font-weight: 500;
+
}
-
.controls {
-
margin-bottom: 2rem;
-
display: flex;
-
justify-content: center;
-
align-items: center;
-
gap: 1rem;
-
flex-wrap: wrap;
-
}
+
.header-links a:hover {
+
color: #4f46e5;
+
text-decoration: underline;
+
}
-
.controls select,
-
.controls button {
-
padding: 0.75rem 1.25rem;
-
border: 1px solid #d1d5db;
-
border-radius: 8px;
-
background: white;
-
cursor: pointer;
-
font-size: 0.875rem;
-
font-weight: 500;
-
transition: all 0.2s ease;
-
}
+
.controls {
+
margin-bottom: 2rem;
+
display: flex;
+
justify-content: center;
+
align-items: center;
+
gap: 1rem;
+
flex-wrap: wrap;
+
}
-
.controls select:hover,
-
.controls select:focus {
-
border-color: #6366f1;
-
outline: none;
-
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
-
}
+
.controls select,
+
.controls button {
+
padding: 0.75rem 1.25rem;
+
border: 1px solid #d1d5db;
+
border-radius: 8px;
+
background: white;
+
cursor: pointer;
+
font-size: 0.875rem;
+
font-weight: 500;
+
transition: all 0.2s ease;
+
}
-
.controls button {
-
background: #6366f1;
-
color: white;
-
border: none;
-
}
+
.controls select:hover,
+
.controls select:focus {
+
border-color: #6366f1;
+
outline: none;
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
+
}
-
.controls button:hover {
-
background: #4f46e5;
-
}
+
.controls button {
+
background: #6366f1;
+
color: white;
+
border: none;
+
}
-
.controls button:disabled {
-
background: #9ca3af;
-
cursor: not-allowed;
-
}
+
.controls button:hover {
+
background: #4f46e5;
+
}
-
.dashboard {
-
max-width: 1200px;
-
margin: 0 auto;
-
padding: 0 2rem;
-
}
+
.controls button:disabled {
+
background: #9ca3af;
+
cursor: not-allowed;
+
}
-
.stats-grid {
-
display: grid;
-
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
-
gap: 1.5rem;
-
margin-bottom: 3rem;
-
}
+
.dashboard {
+
max-width: 1200px;
+
margin: 0 auto;
+
padding: 0 2rem;
+
}
-
.stat-card {
-
background: white;
-
padding: 2rem;
-
border-radius: 12px;
-
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
-
text-align: center;
-
border: 1px solid #e5e7eb;
-
transition: all 0.2s ease;
-
min-height: 140px;
-
display: flex;
-
flex-direction: column;
-
justify-content: center;
-
}
+
.stats-grid {
+
display: grid;
+
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+
gap: 1.5rem;
+
margin-bottom: 3rem;
+
}
+
+
.stat-card {
+
background: white;
+
padding: 2rem;
+
border-radius: 12px;
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+
text-align: center;
+
border: 1px solid #e5e7eb;
+
transition: all 0.2s ease;
+
min-height: 140px;
+
display: flex;
+
flex-direction: column;
+
justify-content: center;
+
}
+
+
.stat-card:hover {
+
transform: translateY(-2px);
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+
}
+
+
.stat-number {
+
font-weight: 800;
+
color: #111827;
+
margin-bottom: 0.5rem;
+
font-size: 2.5rem;
+
line-height: 1;
+
}
-
.stat-card:hover {
-
transform: translateY(-2px);
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
-
}
+
.stat-label {
+
color: #6b7280;
+
font-weight: 600;
+
font-size: 0.875rem;
+
text-transform: uppercase;
+
letter-spacing: 0.05em;
+
}
-
.stat-number {
-
font-weight: 800;
-
color: #111827;
-
margin-bottom: 0.5rem;
-
font-size: 2.5rem;
-
line-height: 1;
-
}
+
.charts-grid {
+
display: grid;
+
grid-template-columns: 1fr;
+
gap: 2rem;
+
margin-bottom: 3rem;
+
}
-
.stat-label {
-
color: #6b7280;
-
font-weight: 600;
-
font-size: 0.875rem;
-
text-transform: uppercase;
-
letter-spacing: 0.05em;
-
}
+
.charts-row {
+
display: grid;
+
grid-template-columns: 1fr 1fr;
+
gap: 2rem;
+
}
-
.charts-grid {
-
display: grid;
+
@media (max-width: 768px) {
+
.charts-row {
grid-template-columns: 1fr;
-
gap: 2rem;
-
margin-bottom: 3rem;
}
-
.charts-row {
-
display: grid;
-
grid-template-columns: 1fr 1fr;
-
gap: 2rem;
+
.stats-grid {
+
grid-template-columns: 1fr;
}
-
@media (max-width: 768px) {
-
.charts-row {
-
grid-template-columns: 1fr;
-
}
+
.dashboard {
+
padding: 0 1rem;
+
}
-
.stats-grid {
-
grid-template-columns: 1fr;
-
}
+
.stat-number {
+
font-size: 2rem;
+
}
+
}
-
.dashboard {
-
padding: 0 1rem;
-
}
+
.chart-container {
+
background: white;
+
padding: 1.5rem;
+
border-radius: 12px;
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+
border: 1px solid #e5e7eb;
+
height: 25rem;
+
padding-bottom: 5rem;
+
}
-
.stat-number {
-
font-size: 2rem;
-
}
-
}
+
.chart-title {
+
font-size: 1.25rem;
+
margin-bottom: 1.5rem;
+
color: #111827;
+
font-weight: 700;
+
}
-
.chart-container {
-
background: white;
-
padding: 1.5rem;
-
border-radius: 12px;
-
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
-
border: 1px solid #e5e7eb;
-
height: 25rem;
-
padding-bottom: 5rem;
-
}
+
.user-agents-table {
+
background: white;
+
padding: 2rem;
+
border-radius: 12px;
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+
border: 1px solid #e5e7eb;
+
}
-
.chart-title {
-
font-size: 1.25rem;
-
margin-bottom: 1.5rem;
-
color: #111827;
-
font-weight: 700;
-
}
+
.search-container {
+
margin-bottom: 1.5rem;
+
position: relative;
+
}
-
.user-agents-table {
-
background: white;
-
padding: 2rem;
-
border-radius: 12px;
-
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
-
border: 1px solid #e5e7eb;
-
}
+
.search-input {
+
width: 100%;
+
padding: 0.75rem 1rem;
+
border: 1px solid #d1d5db;
+
border-radius: 8px;
+
font-size: 0.875rem;
+
background: #f9fafb;
+
transition: border-color 0.2s ease;
+
}
-
.search-container {
-
margin-bottom: 1.5rem;
-
position: relative;
-
}
+
.search-input:focus {
+
outline: none;
+
border-color: #6366f1;
+
background: white;
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
+
}
-
.search-input {
-
width: 100%;
-
padding: 0.75rem 1rem;
-
border: 1px solid #d1d5db;
-
border-radius: 8px;
-
font-size: 0.875rem;
-
background: #f9fafb;
-
transition: border-color 0.2s ease;
-
}
+
.ua-table {
+
width: 100%;
+
border-collapse: collapse;
+
font-size: 0.875rem;
+
}
-
.search-input:focus {
-
outline: none;
-
border-color: #6366f1;
-
background: white;
-
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
-
}
+
.ua-table th {
+
text-align: left;
+
padding: 0.75rem 1rem;
+
background: #f9fafb;
+
border-bottom: 2px solid #e5e7eb;
+
font-weight: 600;
+
color: #374151;
+
position: sticky;
+
top: 0;
+
}
-
.ua-table {
-
width: 100%;
-
border-collapse: collapse;
-
font-size: 0.875rem;
-
}
+
.ua-table td {
+
padding: 0.75rem 1rem;
+
border-bottom: 1px solid #f3f4f6;
+
vertical-align: top;
+
}
-
.ua-table th {
-
text-align: left;
-
padding: 0.75rem 1rem;
-
background: #f9fafb;
-
border-bottom: 2px solid #e5e7eb;
-
font-weight: 600;
-
color: #374151;
-
position: sticky;
-
top: 0;
-
}
+
.ua-table tbody tr:hover {
+
background: #f9fafb;
+
}
-
.ua-table td {
-
padding: 0.75rem 1rem;
-
border-bottom: 1px solid #f3f4f6;
-
vertical-align: top;
-
}
+
.ua-name {
+
font-weight: 500;
+
color: #111827;
+
line-height: 1.4;
+
max-width: 400px;
+
word-break: break-word;
+
}
-
.ua-table tbody tr:hover {
-
background: #f9fafb;
-
}
+
.ua-raw {
+
font-family: monospace;
+
font-size: 0.75rem;
+
color: #6b7280;
+
margin-top: 0.25rem;
+
max-width: 400px;
+
word-break: break-all;
+
line-height: 1.3;
+
}
-
.ua-name {
-
font-weight: 500;
-
color: #111827;
-
line-height: 1.4;
-
max-width: 400px;
-
word-break: break-word;
-
}
+
.ua-count {
+
font-weight: 600;
+
color: #111827;
+
text-align: right;
+
white-space: nowrap;
+
}
-
.ua-raw {
-
font-family: monospace;
-
font-size: 0.75rem;
-
color: #6b7280;
-
margin-top: 0.25rem;
-
max-width: 400px;
-
word-break: break-all;
-
line-height: 1.3;
-
}
+
.ua-percentage {
+
color: #6b7280;
+
text-align: right;
+
font-size: 0.75rem;
+
}
-
.ua-count {
-
font-weight: 600;
-
color: #111827;
-
text-align: right;
-
white-space: nowrap;
-
}
+
.no-results {
+
text-align: center;
+
padding: 2rem;
+
color: #6b7280;
+
font-style: italic;
+
}
-
.ua-percentage {
-
color: #6b7280;
-
text-align: right;
-
font-size: 0.75rem;
-
}
+
.loading {
+
text-align: center;
+
padding: 3rem;
+
color: #6b7280;
+
}
-
.no-results {
-
text-align: center;
-
padding: 2rem;
-
color: #6b7280;
-
font-style: italic;
-
}
+
.loading-spinner {
+
display: inline-block;
+
width: 2rem;
+
height: 2rem;
+
border: 3px solid #e5e7eb;
+
border-radius: 50%;
+
border-top-color: #6366f1;
+
animation: spin 1s ease-in-out infinite;
+
margin-bottom: 1rem;
+
}
-
.loading {
-
text-align: center;
-
padding: 3rem;
-
color: #6b7280;
+
@keyframes spin {
+
to {
+
transform: rotate(360deg);
}
+
}
-
.loading-spinner {
-
display: inline-block;
-
width: 2rem;
-
height: 2rem;
-
border: 3px solid #e5e7eb;
-
border-radius: 50%;
-
border-top-color: #6366f1;
-
animation: spin 1s ease-in-out infinite;
-
margin-bottom: 1rem;
-
}
+
.error {
+
background: #fef2f2;
+
color: #dc2626;
+
padding: 1rem;
+
border-radius: 8px;
+
margin: 1rem 0;
+
border: 1px solid #fecaca;
+
}
-
@keyframes spin {
-
to { transform: rotate(360deg); }
-
}
+
.auto-refresh {
+
display: flex;
+
align-items: center;
+
gap: 0.5rem;
+
font-size: 0.875rem;
+
color: #6b7280;
+
}
-
.error {
-
background: #fef2f2;
-
color: #dc2626;
-
padding: 1rem;
-
border-radius: 8px;
-
margin: 1rem 0;
-
border: 1px solid #fecaca;
-
}
+
.auto-refresh input[type="checkbox"] {
+
transform: scale(1.1);
+
accent-color: #6366f1;
+
}
+
</style>
+
</head>
-
.auto-refresh {
-
display: flex;
-
align-items: center;
-
gap: 0.5rem;
-
font-size: 0.875rem;
-
color: #6b7280;
-
}
+
<body>
+
<div class="header">
+
<h1>๐Ÿ“Š Cachet Analytics Dashboard</h1>
+
<div class="header-links">
+
<a href="https://github.com/taciturnaxolotl/cachet">Github</a>
+
<a href="/swagger">API Docs</a>
+
<a href="/stats">Raw Stats</a>
+
<a href="https://status.dunkirk.sh/status/cachet">Status</a>
+
</div>
+
</div>
-
.auto-refresh input[type="checkbox"] {
-
transform: scale(1.1);
-
accent-color: #6366f1;
-
}
-
</style>
-
</head>
-
<body>
-
<div class="header">
-
<h1>๐Ÿ“Š Cachet Analytics Dashboard</h1>
-
<div class="header-links">
-
<a href="https://github.com/taciturnaxolotl/cachet">Github</a>
-
<a href="/swagger">API Docs</a>
-
<a href="/stats">Raw Stats</a>
+
<div class="dashboard">
+
<div class="controls">
+
<select id="daysSelect">
+
<option value="1">Last 24 hours</option>
+
<option value="7" selected>Last 7 days</option>
+
<option value="30">Last 30 days</option>
+
</select>
+
<button id="refreshBtn" onclick="loadData()">Refresh</button>
+
<div class="auto-refresh">
+
<input type="checkbox" id="autoRefresh" />
+
<label for="autoRefresh">Auto-refresh (30s)</label>
</div>
</div>
-
<div class="dashboard">
-
<div class="controls">
-
<select id="daysSelect">
-
<option value="1">Last 24 hours</option>
-
<option value="7" selected>Last 7 days</option>
-
<option value="30">Last 30 days</option>
-
</select>
-
<button id="refreshBtn" onclick="loadData()">Refresh</button>
-
<div class="auto-refresh">
-
<input type="checkbox" id="autoRefresh" />
-
<label for="autoRefresh">Auto-refresh (30s)</label>
+
<div id="loading" class="loading">
+
<div class="loading-spinner"></div>
+
Loading analytics data...
+
</div>
+
<div id="error" class="error" style="display: none"></div>
+
+
<div id="content" style="display: none">
+
<!-- Key Metrics -->
+
<div class="stats-grid">
+
<div class="stat-card">
+
<div class="stat-number" id="totalRequests">-</div>
+
<div class="stat-label">Total Requests</div>
+
</div>
+
<div class="stat-card">
+
<div class="stat-number" id="uptime">-</div>
+
<div class="stat-label">Uptime</div>
</div>
-
</div>
-
-
<div id="loading" class="loading">
-
<div class="loading-spinner"></div>
-
Loading analytics data...
+
<div class="stat-card">
+
<div class="stat-number" id="avgResponseTime">-</div>
+
<div class="stat-label">Avg Response Time</div>
+
</div>
</div>
-
<div id="error" class="error" style="display: none"></div>
-
<div id="content" style="display: none">
-
<!-- Key Metrics -->
-
<div class="stats-grid">
-
<div class="stat-card">
-
<div class="stat-number" id="totalRequests">-</div>
-
<div class="stat-label">Total Requests</div>
+
<!-- Main Charts -->
+
<div class="charts-grid">
+
<div class="charts-row">
+
<div class="chart-container">
+
<div class="chart-title">Requests Over Time</div>
+
<canvas id="requestsChart"></canvas>
</div>
-
<div class="stat-card">
-
<div class="stat-number" id="uptime">-</div>
-
<div class="stat-label">Uptime</div>
-
</div>
-
<div class="stat-card">
-
<div class="stat-number" id="avgResponseTime">-</div>
-
<div class="stat-label">Avg Response Time</div>
+
<div class="chart-container">
+
<div class="chart-title">Latency Over Time</div>
+
<canvas id="latencyChart"></canvas>
</div>
</div>
+
</div>
-
<!-- Main Charts -->
-
<div class="charts-grid">
-
<div class="charts-row">
-
<div class="chart-container">
-
<div class="chart-title">Requests Over Time</div>
-
<canvas id="requestsChart"></canvas>
-
</div>
-
<div class="chart-container">
-
<div class="chart-title">Latency Over Time</div>
-
<canvas id="latencyChart"></canvas>
-
</div>
-
</div>
+
<!-- User Agents Table -->
+
<div class="user-agents-table">
+
<div class="chart-title">User Agents</div>
+
<div class="search-container">
+
<input type="text" id="userAgentSearch" class="search-input" placeholder="Search user agents...">
</div>
-
-
<!-- User Agents Table -->
-
<div class="user-agents-table">
-
<div class="chart-title">User Agents</div>
-
<div class="search-container">
-
<input type="text" id="userAgentSearch" class="search-input" placeholder="Search user agents...">
-
</div>
-
<div id="userAgentsTable">
-
<div class="loading">Loading user agents...</div>
-
</div>
+
<div id="userAgentsTable">
+
<div class="loading">Loading user agents...</div>
</div>
</div>
</div>
+
</div>
-
<script>
-
const charts = {};
-
let autoRefreshInterval;
-
const _currentData = null;
-
let _isLoading = false;
-
let currentRequestId = 0;
-
let abortController = null;
+
<script>
+
const charts = {};
+
let autoRefreshInterval;
+
const _currentData = null;
+
let _isLoading = false;
+
let currentRequestId = 0;
+
let abortController = null;
-
// Debounced resize handler for charts
-
let resizeTimeout;
-
function handleResize() {
-
clearTimeout(resizeTimeout);
-
resizeTimeout = setTimeout(() => {
-
Object.values(charts).forEach(chart => {
-
if (chart && typeof chart.resize === 'function') {
-
chart.resize();
-
}
-
});
-
}, 250);
-
}
+
// Debounced resize handler for charts
+
let resizeTimeout;
+
function handleResize() {
+
clearTimeout(resizeTimeout);
+
resizeTimeout = setTimeout(() => {
+
Object.values(charts).forEach(chart => {
+
if (chart && typeof chart.resize === 'function') {
+
chart.resize();
+
}
+
});
+
}, 250);
+
}
-
window.addEventListener('resize', handleResize);
+
window.addEventListener('resize', handleResize);
-
async function loadData() {
-
// Cancel any existing requests
-
if (abortController) {
-
abortController.abort();
-
}
+
async function loadData() {
+
// Cancel any existing requests
+
if (abortController) {
+
abortController.abort();
+
}
-
// Create new abort controller for this request
-
abortController = new AbortController();
-
const requestId = ++currentRequestId;
-
const signal = abortController.signal;
+
// Create new abort controller for this request
+
abortController = new AbortController();
+
const requestId = ++currentRequestId;
+
const signal = abortController.signal;
-
_isLoading = true;
-
const startTime = Date.now();
+
_isLoading = true;
+
const startTime = Date.now();
-
// Capture the days value at the start to ensure consistency
-
const days = document.getElementById("daysSelect").value;
-
const loading = document.getElementById("loading");
-
const error = document.getElementById("error");
-
const content = document.getElementById("content");
-
const refreshBtn = document.getElementById("refreshBtn");
+
// Capture the days value at the start to ensure consistency
+
const days = document.getElementById("daysSelect").value;
+
const loading = document.getElementById("loading");
+
const error = document.getElementById("error");
+
const content = document.getElementById("content");
+
const refreshBtn = document.getElementById("refreshBtn");
-
console.log(`Starting request ${requestId} for ${days} days`);
+
console.log(`Starting request ${requestId} for ${days} days`);
-
// Update UI state
-
loading.style.display = "block";
-
error.style.display = "none";
-
content.style.display = "none";
-
refreshBtn.disabled = true;
-
refreshBtn.textContent = "Loading...";
+
// Update UI state
+
loading.style.display = "block";
+
error.style.display = "none";
+
content.style.display = "none";
+
refreshBtn.disabled = true;
+
refreshBtn.textContent = "Loading...";
-
try {
-
// Step 1: Load essential stats first (fastest)
-
console.log(`[${requestId}] Loading essential stats...`);
-
const essentialResponse = await fetch(`/api/stats/essential?days=${days}`, { signal });
+
try {
+
// Step 1: Load essential stats first (fastest)
+
console.log(`[${requestId}] Loading essential stats...`);
+
const essentialResponse = await fetch(`/api/stats/essential?days=${days}`, {signal});
-
// Check if this request is still current
-
if (requestId !== currentRequestId) {
-
console.log(`[${requestId}] Request cancelled (essential stats)`);
-
return;
-
}
+
// Check if this request is still current
+
if (requestId !== currentRequestId) {
+
console.log(`[${requestId}] Request cancelled (essential stats)`);
+
return;
+
}
-
if (!essentialResponse.ok) throw new Error(`HTTP ${essentialResponse.status}`);
+
if (!essentialResponse.ok) throw new Error(`HTTP ${essentialResponse.status}`);
-
const essentialData = await essentialResponse.json();
+
const essentialData = await essentialResponse.json();
-
// Double-check we're still the current request
-
if (requestId !== currentRequestId) {
-
console.log(`[${requestId}] Request cancelled (essential stats after response)`);
-
return;
-
}
+
// Double-check we're still the current request
+
if (requestId !== currentRequestId) {
+
console.log(`[${requestId}] Request cancelled (essential stats after response)`);
+
return;
+
}
-
updateEssentialStats(essentialData);
+
updateEssentialStats(essentialData);
-
// Show content immediately with essential stats
-
loading.style.display = "none";
-
content.style.display = "block";
-
refreshBtn.textContent = "Loading Charts...";
+
// Show content immediately with essential stats
+
loading.style.display = "none";
+
content.style.display = "block";
+
refreshBtn.textContent = "Loading Charts...";
-
console.log(`[${requestId}] Essential stats loaded in ${Date.now() - startTime}ms`);
+
console.log(`[${requestId}] Essential stats loaded in ${Date.now() - startTime}ms`);
-
// Step 2: Load chart data (medium speed)
-
console.log(`[${requestId}] Loading chart data...`);
-
const chartResponse = await fetch(`/api/stats/charts?days=${days}`, { signal });
+
// Step 2: Load chart data (medium speed)
+
console.log(`[${requestId}] Loading chart data...`);
+
const chartResponse = await fetch(`/api/stats/charts?days=${days}`, {signal});
-
if (requestId !== currentRequestId) {
-
console.log(`[${requestId}] Request cancelled (chart data)`);
-
return;
-
}
+
if (requestId !== currentRequestId) {
+
console.log(`[${requestId}] Request cancelled (chart data)`);
+
return;
+
}
-
if (!chartResponse.ok) throw new Error(`HTTP ${chartResponse.status}`);
+
if (!chartResponse.ok) throw new Error(`HTTP ${chartResponse.status}`);
-
const chartData = await chartResponse.json();
+
const chartData = await chartResponse.json();
-
if (requestId !== currentRequestId) {
-
console.log(`[${requestId}] Request cancelled (chart data after response)`);
-
return;
-
}
+
if (requestId !== currentRequestId) {
+
console.log(`[${requestId}] Request cancelled (chart data after response)`);
+
return;
+
}
-
updateCharts(chartData, parseInt(days, 10));
-
refreshBtn.textContent = "Loading User Agents...";
+
updateCharts(chartData, parseInt(days, 10));
+
refreshBtn.textContent = "Loading User Agents...";
-
console.log(`[${requestId}] Charts loaded in ${Date.now() - startTime}ms`);
+
console.log(`[${requestId}] Charts loaded in ${Date.now() - startTime}ms`);
-
// Step 3: Load user agents last (slowest)
-
console.log(`[${requestId}] Loading user agents...`);
-
const userAgentsResponse = await fetch(`/api/stats/useragents?days=${days}`, { signal });
+
// Step 3: Load user agents last (slowest)
+
console.log(`[${requestId}] Loading user agents...`);
+
const userAgentsResponse = await fetch(`/api/stats/useragents?days=${days}`, {signal});
-
if (requestId !== currentRequestId) {
-
console.log(`[${requestId}] Request cancelled (user agents)`);
-
return;
-
}
+
if (requestId !== currentRequestId) {
+
console.log(`[${requestId}] Request cancelled (user agents)`);
+
return;
+
}
-
if (!userAgentsResponse.ok) throw new Error(`HTTP ${userAgentsResponse.status}`);
+
if (!userAgentsResponse.ok) throw new Error(`HTTP ${userAgentsResponse.status}`);
-
const userAgentsData = await userAgentsResponse.json();
+
const userAgentsData = await userAgentsResponse.json();
-
if (requestId !== currentRequestId) {
-
console.log(`[${requestId}] Request cancelled (user agents after response)`);
-
return;
-
}
+
if (requestId !== currentRequestId) {
+
console.log(`[${requestId}] Request cancelled (user agents after response)`);
+
return;
+
}
-
updateUserAgentsTable(userAgentsData);
+
updateUserAgentsTable(userAgentsData);
-
const totalTime = Date.now() - startTime;
-
console.log(`[${requestId}] All data loaded in ${totalTime}ms`);
-
} catch (err) {
-
// Only show error if this is still the current request
-
if (requestId === currentRequestId) {
-
if (err.name === 'AbortError') {
-
console.log(`[${requestId}] Request aborted`);
-
} else {
-
loading.style.display = "none";
-
error.style.display = "block";
-
error.textContent = `Failed to load data: ${err.message}`;
-
console.error(`[${requestId}] Error: ${err.message}`);
-
}
-
}
-
} finally {
-
// Only update UI if this is still the current request
-
if (requestId === currentRequestId) {
-
_isLoading = false;
-
refreshBtn.disabled = false;
-
refreshBtn.textContent = "Refresh";
-
abortController = null;
+
const totalTime = Date.now() - startTime;
+
console.log(`[${requestId}] All data loaded in ${totalTime}ms`);
+
} catch (err) {
+
// Only show error if this is still the current request
+
if (requestId === currentRequestId) {
+
if (err.name === 'AbortError') {
+
console.log(`[${requestId}] Request aborted`);
+
} else {
+
loading.style.display = "none";
+
error.style.display = "block";
+
error.textContent = `Failed to load data: ${err.message}`;
+
console.error(`[${requestId}] Error: ${err.message}`);
}
+
}
+
} finally {
+
// Only update UI if this is still the current request
+
if (requestId === currentRequestId) {
+
_isLoading = false;
+
refreshBtn.disabled = false;
+
refreshBtn.textContent = "Refresh";
+
abortController = null;
}
}
+
}
-
// Update just the essential stats (fast)
-
function updateEssentialStats(data) {
-
document.getElementById("totalRequests").textContent = data.totalRequests.toLocaleString();
-
document.getElementById("uptime").textContent = `${data.uptime.toFixed(1)}%`;
-
document.getElementById("avgResponseTime").textContent =
-
data.averageResponseTime ? `${Math.round(data.averageResponseTime)}ms` : "N/A";
-
}
+
// Update just the essential stats (fast)
+
function updateEssentialStats(data) {
+
document.getElementById("totalRequests").textContent = data.totalRequests.toLocaleString();
+
document.getElementById("uptime").textContent = `${data.uptime.toFixed(1)}%`;
+
document.getElementById("avgResponseTime").textContent =
+
data.averageResponseTime ? `${Math.round(data.averageResponseTime)}ms` : "N/A";
+
}
-
// Update charts (medium speed)
-
function updateCharts(data, days) {
-
updateRequestsChart(data.requestsByDay, days === 1);
-
updateLatencyChart(data.latencyOverTime, days === 1);
-
}
+
// Update charts (medium speed)
+
function updateCharts(data, days) {
+
updateRequestsChart(data.requestsByDay, days === 1);
+
updateLatencyChart(data.latencyOverTime, days === 1);
+
}
-
// Requests Over Time Chart
-
function updateRequestsChart(data, _isHourly) {
-
const ctx = document.getElementById("requestsChart").getContext("2d");
-
const days = parseInt(document.getElementById("daysSelect").value, 10);
+
// Requests Over Time Chart
+
function updateRequestsChart(data, _isHourly) {
+
const ctx = document.getElementById("requestsChart").getContext("2d");
+
const days = parseInt(document.getElementById("daysSelect").value, 10);
-
if (charts.requests) charts.requests.destroy();
+
if (charts.requests) charts.requests.destroy();
-
// Format labels based on granularity
-
const labels = data.map((d) => {
-
if (days === 1) {
-
// 15-minute intervals: show just time
-
return d.date.split(" ")[1] || d.date;
-
} else if (days <= 7) {
-
// Hourly: show date + hour
-
const parts = d.date.split(" ");
-
const date = parts[0].split("-")[2]; // Get day
-
const hour = parts[1] || "00:00";
-
return `${date} ${hour}`;
-
} else {
-
// 4-hour intervals: show abbreviated
-
return d.date.split(" ")[0];
-
}
-
});
+
// Format labels based on granularity
+
const labels = data.map((d) => {
+
if (days === 1) {
+
// 15-minute intervals: show just time
+
return d.date.split(" ")[1] || d.date;
+
} else if (days <= 7) {
+
// Hourly: show date + hour
+
const parts = d.date.split(" ");
+
const date = parts[0].split("-")[2]; // Get day
+
const hour = parts[1] || "00:00";
+
return `${date} ${hour}`;
+
} else {
+
// 4-hour intervals: show abbreviated
+
return d.date.split(" ")[0];
+
}
+
});
-
charts.requests = new Chart(ctx, {
-
type: "line",
-
data: {
-
labels: labels,
-
datasets: [{
-
label: "Requests",
-
data: data.map((d) => d.count),
-
borderColor: "#6366f1",
-
backgroundColor: "rgba(99, 102, 241, 0.1)",
-
tension: 0.4,
-
fill: true,
-
borderWidth: 1.5,
-
pointRadius: 1,
-
pointBackgroundColor: "#6366f1",
-
}],
-
},
-
options: {
-
responsive: true,
-
maintainAspectRatio: false,
-
plugins: {
-
legend: { display: false },
-
tooltip: {
-
callbacks: {
-
title: (context) => {
-
const original = data[context[0].dataIndex];
-
if (days === 1) return `Time: ${original.date}`;
-
if (days <= 7) return `DateTime: ${original.date}`;
-
return `Interval: ${original.date}`;
-
},
-
label: (context) => `Requests: ${context.parsed.y.toLocaleString()}`
-
}
-
}
-
},
-
scales: {
-
x: {
-
title: {
-
display: true,
-
text: days === 1 ? 'Time (15min intervals)' : days <= 7 ? 'Time (hourly)' : 'Time (4hr intervals)'
+
charts.requests = new Chart(ctx, {
+
type: "line",
+
data: {
+
labels: labels,
+
datasets: [{
+
label: "Requests",
+
data: data.map((d) => d.count),
+
borderColor: "#6366f1",
+
backgroundColor: "rgba(99, 102, 241, 0.1)",
+
tension: 0.4,
+
fill: true,
+
borderWidth: 1.5,
+
pointRadius: 1,
+
pointBackgroundColor: "#6366f1",
+
}],
+
},
+
options: {
+
responsive: true,
+
maintainAspectRatio: false,
+
plugins: {
+
legend: {display: false},
+
tooltip: {
+
callbacks: {
+
title: (context) => {
+
const original = data[context[0].dataIndex];
+
if (days === 1) return `Time: ${original.date}`;
+
if (days <= 7) return `DateTime: ${original.date}`;
+
return `Interval: ${original.date}`;
},
-
grid: { color: 'rgba(0, 0, 0, 0.05)' },
-
ticks: {
-
maxTicksLimit: days === 1 ? 12 : 20,
-
maxRotation: 0,
-
minRotation: 0
-
}
+
label: (context) => `Requests: ${context.parsed.y.toLocaleString()}`
+
}
+
}
+
},
+
scales: {
+
x: {
+
title: {
+
display: true,
+
text: days === 1 ? 'Time (15min intervals)' : days <= 7 ? 'Time (hourly)' : 'Time (4hr intervals)'
},
-
y: {
-
title: { display: true, text: 'Requests' },
-
beginAtZero: true,
-
grid: { color: 'rgba(0, 0, 0, 0.05)' }
+
grid: {color: 'rgba(0, 0, 0, 0.05)'},
+
ticks: {
+
maxTicksLimit: days === 1 ? 12 : 20,
+
maxRotation: 0,
+
minRotation: 0
}
+
},
+
y: {
+
title: {display: true, text: 'Requests'},
+
beginAtZero: true,
+
grid: {color: 'rgba(0, 0, 0, 0.05)'}
}
}
-
});
-
}
+
}
+
});
+
}
-
// Latency Over Time Chart
-
function updateLatencyChart(data, _isHourly) {
-
const ctx = document.getElementById("latencyChart").getContext("2d");
-
const days = parseInt(document.getElementById("daysSelect").value, 10);
+
// Latency Over Time Chart
+
function updateLatencyChart(data, _isHourly) {
+
const ctx = document.getElementById("latencyChart").getContext("2d");
+
const days = parseInt(document.getElementById("daysSelect").value, 10);
-
if (charts.latency) charts.latency.destroy();
+
if (charts.latency) charts.latency.destroy();
-
// Format labels based on granularity
-
const labels = data.map((d) => {
-
if (days === 1) {
-
// 15-minute intervals: show just time
-
return d.time.split(" ")[1] || d.time;
-
} else if (days <= 7) {
-
// Hourly: show date + hour
-
const parts = d.time.split(" ");
-
const date = parts[0].split("-")[2]; // Get day
-
const hour = parts[1] || "00:00";
-
return `${date} ${hour}`;
-
} else {
-
// 4-hour intervals: show abbreviated
-
return d.time.split(" ")[0];
-
}
-
});
+
// Format labels based on granularity
+
const labels = data.map((d) => {
+
if (days === 1) {
+
// 15-minute intervals: show just time
+
return d.time.split(" ")[1] || d.time;
+
} else if (days <= 7) {
+
// Hourly: show date + hour
+
const parts = d.time.split(" ");
+
const date = parts[0].split("-")[2]; // Get day
+
const hour = parts[1] || "00:00";
+
return `${date} ${hour}`;
+
} else {
+
// 4-hour intervals: show abbreviated
+
return d.time.split(" ")[0];
+
}
+
});
-
// Calculate dynamic max for logarithmic scale
-
const responseTimes = data.map((d) => d.averageResponseTime);
-
const maxResponseTime = Math.max(...responseTimes);
+
// Calculate dynamic max for logarithmic scale
+
const responseTimes = data.map((d) => d.averageResponseTime);
+
const maxResponseTime = Math.max(...responseTimes);
-
// Calculate appropriate max for log scale (next power of 10)
-
const logMax = 10 ** Math.ceil(Math.log10(maxResponseTime));
+
// Calculate appropriate max for log scale (next power of 10)
+
const logMax = 10 ** Math.ceil(Math.log10(maxResponseTime));
-
// Generate dynamic tick values based on the data range
-
const generateLogTicks = (_min, max) => {
-
const ticks = [];
-
let current = 1;
-
while (current <= max) {
-
ticks.push(current);
-
current *= 10;
-
}
-
return ticks;
-
};
+
// Generate dynamic tick values based on the data range
+
const generateLogTicks = (_min, max) => {
+
const ticks = [];
+
let current = 1;
+
while (current <= max) {
+
ticks.push(current);
+
current *= 10;
+
}
+
return ticks;
+
};
-
const dynamicTicks = generateLogTicks(1, logMax);
+
const dynamicTicks = generateLogTicks(1, logMax);
-
charts.latency = new Chart(ctx, {
-
type: "line",
-
data: {
-
labels: labels,
-
datasets: [{
-
label: "Average Response Time",
-
data: responseTimes,
-
borderColor: "#10b981",
-
backgroundColor: "rgba(16, 185, 129, 0.1)",
-
tension: 0.4,
-
fill: true,
-
borderWidth: 1.5,
-
pointRadius: 1,
-
pointBackgroundColor: "#10b981",
-
}],
-
},
-
options: {
-
responsive: true,
-
maintainAspectRatio: false,
-
plugins: {
-
legend: { display: false },
-
tooltip: {
-
callbacks: {
-
title: (context) => {
-
const original = data[context[0].dataIndex];
-
if (days === 1) return `Time: ${original.time}`;
-
if (days <= 7) return `DateTime: ${original.time}`;
-
return `Interval: ${original.time}`;
-
},
-
label: (context) => {
-
const point = data[context.dataIndex];
-
return [
-
`Response Time: ${Math.round(context.parsed.y)}ms`,
-
`Request Count: ${point.count.toLocaleString()}`
-
];
-
}
-
}
-
}
-
},
-
scales: {
-
x: {
-
title: {
-
display: true,
-
text: days === 1 ? 'Time (15min intervals)' : days <= 7 ? 'Time (hourly)' : 'Time (4hr intervals)'
+
charts.latency = new Chart(ctx, {
+
type: "line",
+
data: {
+
labels: labels,
+
datasets: [{
+
label: "Average Response Time",
+
data: responseTimes,
+
borderColor: "#10b981",
+
backgroundColor: "rgba(16, 185, 129, 0.1)",
+
tension: 0.4,
+
fill: true,
+
borderWidth: 1.5,
+
pointRadius: 1,
+
pointBackgroundColor: "#10b981",
+
}],
+
},
+
options: {
+
responsive: true,
+
maintainAspectRatio: false,
+
plugins: {
+
legend: {display: false},
+
tooltip: {
+
callbacks: {
+
title: (context) => {
+
const original = data[context[0].dataIndex];
+
if (days === 1) return `Time: ${original.time}`;
+
if (days <= 7) return `DateTime: ${original.time}`;
+
return `Interval: ${original.time}`;
},
-
grid: { color: 'rgba(0, 0, 0, 0.05)' },
-
ticks: {
-
maxTicksLimit: days === 1 ? 12 : 20,
-
maxRotation: 0,
-
minRotation: 0
+
label: (context) => {
+
const point = data[context.dataIndex];
+
return [
+
`Response Time: ${Math.round(context.parsed.y)}ms`,
+
`Request Count: ${point.count.toLocaleString()}`
+
];
}
+
}
+
}
+
},
+
scales: {
+
x: {
+
title: {
+
display: true,
+
text: days === 1 ? 'Time (15min intervals)' : days <= 7 ? 'Time (hourly)' : 'Time (4hr intervals)'
},
-
y: {
-
type: 'logarithmic',
-
title: { display: true, text: 'Response Time (ms, log scale)' },
-
min: 1,
-
max: logMax,
-
grid: { color: 'rgba(0, 0, 0, 0.05)' },
-
ticks: {
-
callback: (value) => {
-
// Show clean numbers based on dynamic range
-
if (dynamicTicks.includes(value)) {
-
return `${value}ms`;
-
}
-
return '';
+
grid: {color: 'rgba(0, 0, 0, 0.05)'},
+
ticks: {
+
maxTicksLimit: days === 1 ? 12 : 20,
+
maxRotation: 0,
+
minRotation: 0
+
}
+
},
+
y: {
+
type: 'logarithmic',
+
title: {display: true, text: 'Response Time (ms, log scale)'},
+
min: 1,
+
max: logMax,
+
grid: {color: 'rgba(0, 0, 0, 0.05)'},
+
ticks: {
+
callback: (value) => {
+
// Show clean numbers based on dynamic range
+
if (dynamicTicks.includes(value)) {
+
return `${value}ms`;
}
+
return '';
}
}
}
}
-
});
-
}
+
}
+
});
+
}
+
+
// User Agents Table
+
let allUserAgents = [];
-
// User Agents Table
-
let allUserAgents = [];
+
function updateUserAgentsTable(userAgents) {
+
allUserAgents = userAgents;
+
renderUserAgentsTable(userAgents);
+
setupUserAgentSearch();
+
}
-
function updateUserAgentsTable(userAgents) {
-
allUserAgents = userAgents;
-
renderUserAgentsTable(userAgents);
-
setupUserAgentSearch();
+
function parseUserAgent(ua) {
+
// Keep strange/unique ones as-is
+
if (ua.length < 50 ||
+
!ua.includes('Mozilla/') ||
+
ua.includes('bot') ||
+
ua.includes('crawler') ||
+
ua.includes('spider') ||
+
!ua.includes('AppleWebKit') ||
+
ua.includes('Shiba-Arcade') ||
+
ua === 'node' ||
+
ua.includes('curl') ||
+
ua.includes('python') ||
+
ua.includes('PostmanRuntime')) {
+
return ua;
}
-
function parseUserAgent(ua) {
-
// Keep strange/unique ones as-is
-
if (ua.length < 50 ||
-
!ua.includes('Mozilla/') ||
-
ua.includes('bot') ||
-
ua.includes('crawler') ||
-
ua.includes('spider') ||
-
!ua.includes('AppleWebKit') ||
-
ua.includes('Shiba-Arcade') ||
-
ua === 'node' ||
-
ua.includes('curl') ||
-
ua.includes('python') ||
-
ua.includes('PostmanRuntime')) {
-
return ua;
-
}
+
// Parse common browsers
+
const os = ua.includes('Macintosh') ? 'macOS' :
+
ua.includes('Windows NT 10.0') ? 'Windows 10' :
+
ua.includes('Windows NT') ? 'Windows' :
+
ua.includes('X11; Linux') ? 'Linux' :
+
ua.includes('iPhone') ? 'iOS' :
+
ua.includes('Android') ? 'Android' : 'Unknown OS';
+
+
// Detect browser and version
+
let browser = 'Unknown Browser';
-
// Parse common browsers
-
const os = ua.includes('Macintosh') ? 'macOS' :
-
ua.includes('Windows NT 10.0') ? 'Windows 10' :
-
ua.includes('Windows NT') ? 'Windows' :
-
ua.includes('X11; Linux') ? 'Linux' :
-
ua.includes('iPhone') ? 'iOS' :
-
ua.includes('Android') ? 'Android' : 'Unknown OS';
+
if (ua.includes('Edg/')) {
+
const match = ua.match(/Edg\/(\d+\.\d+)/);
+
const version = match ? match[1] : '';
+
browser = `Edge ${version}`;
+
} else if (ua.includes('Chrome/')) {
+
const match = ua.match(/Chrome\/(\d+\.\d+)/);
+
const version = match ? match[1] : '';
+
browser = `Chrome ${version}`;
+
} else if (ua.includes('Firefox/')) {
+
const match = ua.match(/Firefox\/(\d+\.\d+)/);
+
const version = match ? match[1] : '';
+
browser = `Firefox ${version}`;
+
} else if (ua.includes('Safari/') && !ua.includes('Chrome')) {
+
browser = 'Safari';
+
}
-
// Detect browser and version
-
let browser = 'Unknown Browser';
+
return `${browser} (${os})`;
+
}
-
if (ua.includes('Edg/')) {
-
const match = ua.match(/Edg\/(\d+\.\d+)/);
-
const version = match ? match[1] : '';
-
browser = `Edge ${version}`;
-
} else if (ua.includes('Chrome/')) {
-
const match = ua.match(/Chrome\/(\d+\.\d+)/);
-
const version = match ? match[1] : '';
-
browser = `Chrome ${version}`;
-
} else if (ua.includes('Firefox/')) {
-
const match = ua.match(/Firefox\/(\d+\.\d+)/);
-
const version = match ? match[1] : '';
-
browser = `Firefox ${version}`;
-
} else if (ua.includes('Safari/') && !ua.includes('Chrome')) {
-
browser = 'Safari';
-
}
+
function renderUserAgentsTable(userAgents) {
+
const container = document.getElementById("userAgentsTable");
-
return `${browser} (${os})`;
+
if (userAgents.length === 0) {
+
container.innerHTML = '<div class="no-results">No user agents found</div>';
+
return;
}
-
function renderUserAgentsTable(userAgents) {
-
const container = document.getElementById("userAgentsTable");
+
const totalRequests = userAgents.reduce((sum, ua) => sum + ua.count, 0);
-
if (userAgents.length === 0) {
-
container.innerHTML = '<div class="no-results">No user agents found</div>';
-
return;
-
}
-
-
const totalRequests = userAgents.reduce((sum, ua) => sum + ua.count, 0);
-
-
const tableHTML = `
+
const tableHTML = `
<table class="ua-table">
<thead>
<tr>
···
</thead>
<tbody>
${userAgents.map(ua => {
-
const displayName = parseUserAgent(ua.userAgent);
-
const percentage = ((ua.count / totalRequests) * 100).toFixed(1);
+
const displayName = parseUserAgent(ua.userAgent);
+
const percentage = ((ua.count / totalRequests) * 100).toFixed(1);
-
return `
+
return `
<tr>
<td>
<div class="ua-name">${displayName}</div>
···
<td class="ua-percentage">${percentage}%</td>
</tr>
`;
-
}).join('')}
+
}).join('')}
</tbody>
</table>
`;
-
container.innerHTML = tableHTML;
-
}
+
container.innerHTML = tableHTML;
+
}
-
function setupUserAgentSearch() {
-
const searchInput = document.getElementById('userAgentSearch');
-
-
searchInput.addEventListener('input', function() {
-
const searchTerm = this.value.toLowerCase().trim();
+
function setupUserAgentSearch() {
+
const searchInput = document.getElementById('userAgentSearch');
-
if (searchTerm === '') {
-
renderUserAgentsTable(allUserAgents);
-
return;
-
}
+
searchInput.addEventListener('input', function () {
+
const searchTerm = this.value.toLowerCase().trim();
-
const filtered = allUserAgents.filter(ua => {
-
const displayName = parseUserAgent(ua.userAgent).toLowerCase();
-
const rawUA = ua.userAgent.toLowerCase();
-
return displayName.includes(searchTerm) || rawUA.includes(searchTerm);
-
});
+
if (searchTerm === '') {
+
renderUserAgentsTable(allUserAgents);
+
return;
+
}
-
renderUserAgentsTable(filtered);
+
const filtered = allUserAgents.filter(ua => {
+
const displayName = parseUserAgent(ua.userAgent).toLowerCase();
+
const rawUA = ua.userAgent.toLowerCase();
+
return displayName.includes(searchTerm) || rawUA.includes(searchTerm);
});
-
}
-
// Event Handlers
-
document.getElementById("autoRefresh").addEventListener("change", function () {
-
if (this.checked) {
-
autoRefreshInterval = setInterval(loadData, 30000);
-
} else {
-
clearInterval(autoRefreshInterval);
-
}
+
renderUserAgentsTable(filtered);
});
+
}
-
document.getElementById("daysSelect").addEventListener("change", loadData);
+
// Event Handlers
+
document.getElementById("autoRefresh").addEventListener("change", function () {
+
if (this.checked) {
+
autoRefreshInterval = setInterval(loadData, 30000);
+
} else {
+
clearInterval(autoRefreshInterval);
+
}
+
});
-
// Initialize dashboard
-
document.addEventListener('DOMContentLoaded', loadData);
+
document.getElementById("daysSelect").addEventListener("change", loadData);
-
// Cleanup on page unload
-
window.addEventListener('beforeunload', () => {
-
clearInterval(autoRefreshInterval);
-
Object.values(charts).forEach(chart => {
-
if (chart && typeof chart.destroy === 'function') {
-
chart.destroy();
-
}
-
});
+
// Initialize dashboard
+
document.addEventListener('DOMContentLoaded', loadData);
+
+
// Cleanup on page unload
+
window.addEventListener('beforeunload', () => {
+
clearInterval(autoRefreshInterval);
+
Object.values(charts).forEach(chart => {
+
if (chart && typeof chart.destroy === 'function') {
+
chart.destroy();
+
}
});
-
</script>
-
</body>
-
</html>
+
});
+
</script>
+
</body>
+
+
</html>
+16 -1
src/handlers/index.ts
···
}
export const handleHealthCheck: RouteHandlerWithAnalytics = async (
-
_request,
+
request,
recordAnalytics,
) => {
+
const url = new URL(request.url);
+
const detailed = url.searchParams.get("detailed") === "true";
+
+
if (detailed) {
+
const health = await cache.detailedHealthCheck();
+
const statusCode =
+
health.status === "unhealthy"
+
? 503
+
: health.status === "degraded"
+
? 200
+
: 200;
+
await recordAnalytics(statusCode);
+
return Response.json(health, { status: statusCode });
+
}
+
const isHealthy = await cache.healthCheck();
if (isHealthy) {
await recordAnalytics(200);
+79 -2
src/routes/api-routes.ts
···
withAnalytics("/health", "GET", handlers.handleHealthCheck),
{
summary: "Health check",
-
description: "Check if the service is healthy and operational",
+
description:
+
"Check if the service is healthy and operational. Add ?detailed=true for comprehensive health information including Slack API status, queue depth, and memory usage.",
tags: ["Health"],
+
parameters: {
+
query: [
+
queryParam(
+
"detailed",
+
"boolean",
+
"Return detailed health check information",
+
false,
+
false,
+
),
+
],
+
},
responses: Object.fromEntries([
apiResponse(200, "Service is healthy", {
type: "object",
properties: {
-
status: { type: "string", example: "healthy" },
+
status: {
+
type: "string",
+
example: "healthy",
+
enum: ["healthy", "degraded", "unhealthy"],
+
},
cache: { type: "boolean", example: true },
uptime: { type: "number", example: 123456 },
+
checks: {
+
type: "object",
+
description: "Detailed checks (only with ?detailed=true)",
+
properties: {
+
database: {
+
type: "object",
+
properties: {
+
status: { type: "boolean" },
+
latency: { type: "number", description: "ms" },
+
},
+
},
+
slackApi: {
+
type: "object",
+
properties: {
+
status: { type: "boolean" },
+
error: { type: "string" },
+
},
+
},
+
queueDepth: {
+
type: "number",
+
description: "Number of users queued for update",
+
},
+
memoryUsage: {
+
type: "object",
+
properties: {
+
heapUsed: { type: "number", description: "MB" },
+
heapTotal: { type: "number", description: "MB" },
+
percentage: { type: "number" },
+
details: {
+
type: "object",
+
properties: {
+
heapUsedMiB: {
+
type: "number",
+
description: "Precise heap used in MiB",
+
},
+
heapTotalMiB: {
+
type: "number",
+
description: "Precise heap total in MiB",
+
},
+
heapPercent: {
+
type: "number",
+
description: "Precise heap percentage",
+
},
+
rssMiB: {
+
type: "number",
+
description: "Resident Set Size in MiB",
+
},
+
externalMiB: {
+
type: "number",
+
description: "External memory in MiB",
+
},
+
arrayBuffersMiB: {
+
type: "number",
+
description: "Array buffers in MiB",
+
},
+
},
+
},
+
},
+
},
+
},
+
},
},
}),
apiResponse(503, "Service is unhealthy"),