Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aca7669243 | ||
|
|
b8f9926e9b | ||
|
|
78179de2bc | ||
|
|
48f74e8658 | ||
|
|
075ab899c5 | ||
|
|
03974d1f13 | ||
|
|
0b61d38419 | ||
|
|
bd783093a9 | ||
|
|
4788140c2c | ||
|
|
0c7dad36ea | ||
|
|
2e3d272e26 | ||
|
|
55385634f2 | ||
|
|
a83766efb4 | ||
|
|
5c980ea681 | ||
|
|
483d12c34d | ||
|
|
7c534b53a4 | ||
|
|
918fb99b87 | ||
|
|
74f7876955 | ||
|
|
274edccf8a | ||
|
|
21b6fa9788 | ||
|
|
942335156f | ||
|
|
c0750ea2c1 | ||
|
|
34464a91af | ||
|
|
f35325a597 | ||
|
|
92e460a90b | ||
|
|
cfdd89377f | ||
|
|
be2f5eb5a5 | ||
|
|
b8ed2095ba | ||
|
|
685626f13f | ||
|
|
32b378f539 | ||
|
|
811e3046cf | ||
|
|
0acea3b95f | ||
|
|
2740dc5fd3 | ||
|
|
b4280dc630 | ||
|
|
12ffdd45f5 | ||
|
|
d227bce0a8 | ||
|
|
47a600eeb5 | ||
|
|
e30ff4d694 | ||
|
|
081bad1279 | ||
|
|
cbf5d219fc | ||
|
|
02bddc0baf | ||
|
|
ef562ef59a | ||
|
|
dc59e5c1de | ||
|
|
69200d8ac3 | ||
|
|
7ecce5da8f | ||
|
|
06f7afe25d | ||
|
|
083885c874 | ||
|
|
05d23e2dfc | ||
|
|
940840d69a | ||
|
|
f5a75d204f | ||
|
|
40cb6cef31 | ||
|
|
9752c6fd89 | ||
|
|
911f5801fc | ||
|
|
8768c6773f | ||
|
|
9e4fa13fdb | ||
|
|
b96ab1fb24 | ||
|
|
04bc2e734f | ||
|
|
b639f92da6 | ||
|
|
e99ef3b942 | ||
|
|
7cf0eec2db | ||
|
|
20b2fab994 | ||
|
|
c7c2340321 | ||
|
|
460d36c9b2 | ||
|
|
5bd4c60238 | ||
|
|
795020b55c | ||
|
|
a13d9a2c66 | ||
|
|
c2b6610e6d | ||
|
|
7d21b4290a | ||
|
|
7f990933df | ||
|
|
d121bd21f6 | ||
|
|
91eea2c5ff | ||
|
|
3666241aac | ||
|
|
567164ef7d | ||
|
|
e59ee04b03 | ||
|
|
1a3e884186 | ||
|
|
6e15a62378 | ||
|
|
05d2164dce | ||
|
|
c160647f0b | ||
|
|
d735e3d9db | ||
|
|
5a607c8c8b | ||
|
|
8e37120289 | ||
|
|
35315c2e81 | ||
|
|
845d121fb2 | ||
|
|
517ec996a9 | ||
|
|
7437b4fa7b | ||
|
|
6992790104 | ||
|
|
5c97b40237 | ||
|
|
326f6529f3 | ||
|
|
0f2ece4f88 | ||
|
|
e36b20d06e | ||
|
|
0cbea6d993 | ||
|
|
1eda8894d5 | ||
|
|
35aded99fe | ||
|
|
1a97e82ec8 | ||
|
|
bc66fec77a | ||
|
|
d9b54187ee | ||
|
|
3e4055c5dc | ||
|
|
8a56c920db | ||
|
|
22a8a3f92d | ||
|
|
3ee303244e | ||
|
|
71454946e5 | ||
|
|
5e8201fd22 | ||
|
|
2f67708fcf | ||
|
|
e23f44ad67 | ||
|
|
62ddb8b8b6 | ||
|
|
291b329067 |
78
cloudflare-workers/servers-api/README.md
Normal file
78
cloudflare-workers/servers-api/README.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Servers API - Cloudflare Worker
|
||||||
|
|
||||||
|
**Purpose:** Proxies Pterodactyl Panel API to provide live server status for firefrostgaming.com
|
||||||
|
|
||||||
|
**Deployed URL:** https://servers-api.firefrostgaming.workers.dev
|
||||||
|
**Created:** April 3, 2026 by Chronicler #56 (The Velocity)
|
||||||
|
**Retrieved from Cloudflare:** April 8, 2026 by Chronicler #68
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
1. Receives request from website (firefrostgaming.com or pages.dev preview)
|
||||||
|
2. Fetches server list from Pterodactyl Panel API
|
||||||
|
3. Fetches live resource stats for each server
|
||||||
|
4. Returns JSON with server name, status (Online/Offline), player count, description
|
||||||
|
5. Caches response for 60 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Configure these in Cloudflare Workers dashboard:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `PANEL_URL` | Pterodactyl panel URL (https://panel.firefrostgaming.com) |
|
||||||
|
| `CLIENT_API_KEY` | Pterodactyl client API key for webuser_api account |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CORS Configuration
|
||||||
|
|
||||||
|
Allowed origins:
|
||||||
|
- `https://firefrostgaming.com`
|
||||||
|
- `https://firefrost-website.pages.dev`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"id": "abc123",
|
||||||
|
"name": "All The Mods 10",
|
||||||
|
"status": "Online",
|
||||||
|
"players": 3,
|
||||||
|
"description": "ATM10 modpack server"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
This Worker is deployed via Cloudflare dashboard. To update:
|
||||||
|
|
||||||
|
1. Edit code in Cloudflare Workers dashboard, OR
|
||||||
|
2. Use Wrangler CLI: `wrangler deploy`
|
||||||
|
|
||||||
|
**Note:** This file is the source of truth. If editing in dashboard, sync changes back here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
|
| Date | Change | By |
|
||||||
|
|------|--------|-----|
|
||||||
|
| 2026-04-03 | Initial creation | Chronicler #56 (The Velocity) |
|
||||||
|
| 2026-04-08 | Added to git (was dashboard-only) | Chronicler #68 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Fire + Frost + Foundation = Where Love Builds Legacy** 💙🔥❄️
|
||||||
103
cloudflare-workers/servers-api/index.js
Normal file
103
cloudflare-workers/servers-api/index.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Firefrost Gaming - Servers API Worker
|
||||||
|
*
|
||||||
|
* Cloudflare Worker that proxies Pterodactyl Panel API
|
||||||
|
* to provide live server status for the website.
|
||||||
|
*
|
||||||
|
* Deployed: https://servers-api.firefrostgaming.workers.dev
|
||||||
|
* Created: April 3, 2026 (Chronicler #56 - The Velocity)
|
||||||
|
*
|
||||||
|
* Environment Variables Required:
|
||||||
|
* PANEL_URL - Pterodactyl panel URL (https://panel.firefrostgaming.com)
|
||||||
|
* CLIENT_API_KEY - Pterodactyl client API key
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request, env) {
|
||||||
|
// Determine allowed origin
|
||||||
|
const origin = request.headers.get('Origin');
|
||||||
|
const allowedOrigins = [
|
||||||
|
'https://firefrostgaming.com',
|
||||||
|
'https://firefrost-website.pages.dev'
|
||||||
|
];
|
||||||
|
const allowedOrigin = allowedOrigins.includes(origin)
|
||||||
|
? origin
|
||||||
|
: 'https://firefrostgaming.com';
|
||||||
|
|
||||||
|
// Handle CORS preflight
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': allowedOrigin,
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const PANEL_URL = env.PANEL_URL;
|
||||||
|
const API_KEY = env.CLIENT_API_KEY;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch server list
|
||||||
|
const listResponse = await fetch(`${PANEL_URL}/api/client`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${API_KEY}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const listData = await listResponse.json();
|
||||||
|
|
||||||
|
if (!listData.data) throw new Error("Failed to fetch server list");
|
||||||
|
|
||||||
|
// Fetch live stats for all servers
|
||||||
|
const serverPromises = listData.data.map(async (server) => {
|
||||||
|
const id = server.attributes.identifier;
|
||||||
|
|
||||||
|
const statsResponse = await fetch(
|
||||||
|
`${PANEL_URL}/api/client/servers/${id}/resources`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${API_KEY}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const stats = await statsResponse.json();
|
||||||
|
const isRunning = stats.attributes?.current_state === 'running';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
name: server.attributes.name,
|
||||||
|
status: isRunning ? 'Online' : 'Offline',
|
||||||
|
players: isRunning ? (stats.attributes?.resources?.players || 0) : 0,
|
||||||
|
description: server.attributes.description
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalServers = await Promise.all(serverPromises);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ servers: finalServers }), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Access-Control-Allow-Origin': allowedOrigin,
|
||||||
|
'Cache-Control': 'public, s-maxage=60, max-age=60'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: "Servers temporarily unreachable",
|
||||||
|
servers: []
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Access-Control-Allow-Origin': allowedOrigin
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
scripts/.tiktok-tokens
Normal file
2
scripts/.tiktok-tokens
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ACCESS_TOKEN="act.6olZAg0uSnTVR6hO2jTTEpak8FPmtEEdS8uzTPmlTtVl06vZ8hswkOHGLyAV!6393.u1"
|
||||||
|
REFRESH_TOKEN="rft.v0FuyZmcsNbeZNZSKo9Pojwq1LYUxp5YeTPXkqb3wxfP0FGb53zY7YcxHyUM!6431.u1"
|
||||||
111
scripts/sync-tiktok.sh
Normal file
111
scripts/sync-tiktok.sh
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# /opt/scripts/sync-tiktok.sh
|
||||||
|
# TikTok analytics sync for Firefrost Gaming
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
TOKEN_FILE="/opt/scripts/.tiktok-tokens"
|
||||||
|
ARBITER_URL="https://discord-bot.firefrostgaming.com/api/internal/social"
|
||||||
|
ARBITER_TOKEN="6fYF1akCRW6pM2F8n3S3RxeIod4YgRniUJNEQurvBP4="
|
||||||
|
|
||||||
|
CLIENT_KEY="sbawse6t5serp8xdqp"
|
||||||
|
CLIENT_SECRET="Ib24OixwLnjZbB8KXAUcYn6ewM60KKDp"
|
||||||
|
|
||||||
|
if [ -f "$TOKEN_FILE" ]; then
|
||||||
|
source "$TOKEN_FILE"
|
||||||
|
else
|
||||||
|
echo "ERROR: Token file not found at $TOKEN_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
refresh_token() {
|
||||||
|
echo "Refreshing TikTok access token..."
|
||||||
|
|
||||||
|
RESPONSE=$(curl -s -X POST 'https://open.tiktokapis.com/v2/oauth/token/' \
|
||||||
|
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||||
|
-d "client_key=$CLIENT_KEY" \
|
||||||
|
-d "client_secret=$CLIENT_SECRET" \
|
||||||
|
-d "refresh_token=$REFRESH_TOKEN" \
|
||||||
|
-d 'grant_type=refresh_token')
|
||||||
|
|
||||||
|
NEW_ACCESS=$(echo "$RESPONSE" | jq -r '.access_token // empty')
|
||||||
|
NEW_REFRESH=$(echo "$RESPONSE" | jq -r '.refresh_token // empty')
|
||||||
|
|
||||||
|
if [ -z "$NEW_ACCESS" ]; then
|
||||||
|
echo "ERROR: Token refresh failed"
|
||||||
|
echo "$RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ACCESS_TOKEN="$NEW_ACCESS"
|
||||||
|
[ -n "$NEW_REFRESH" ] && REFRESH_TOKEN="$NEW_REFRESH"
|
||||||
|
|
||||||
|
cat > "$TOKEN_FILE" << TOKENS
|
||||||
|
ACCESS_TOKEN="$ACCESS_TOKEN"
|
||||||
|
REFRESH_TOKEN="$REFRESH_TOKEN"
|
||||||
|
TOKENS
|
||||||
|
chmod 600 "$TOKEN_FILE"
|
||||||
|
echo " Token refreshed"
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_account_stats() {
|
||||||
|
echo "Syncing account stats..."
|
||||||
|
|
||||||
|
RESPONSE=$(curl -s -X GET \
|
||||||
|
'https://open.tiktokapis.com/v2/user/info/?fields=follower_count,following_count,likes_count,video_count' \
|
||||||
|
-H "Authorization: Bearer $ACCESS_TOKEN")
|
||||||
|
|
||||||
|
FOLLOWERS=$(echo "$RESPONSE" | jq -r '.data.user.follower_count // 0')
|
||||||
|
FOLLOWING=$(echo "$RESPONSE" | jq -r '.data.user.following_count // 0')
|
||||||
|
LIKES=$(echo "$RESPONSE" | jq -r '.data.user.likes_count // 0')
|
||||||
|
VIDEOS=$(echo "$RESPONSE" | jq -r '.data.user.video_count // 0')
|
||||||
|
|
||||||
|
echo " Followers: $FOLLOWERS, Likes: $LIKES, Videos: $VIDEOS"
|
||||||
|
|
||||||
|
curl -s -X POST "$ARBITER_URL/snapshot" \
|
||||||
|
-H "Authorization: Bearer $ARBITER_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"platform\":\"tiktok\",\"total_followers\":$FOLLOWERS}" > /dev/null
|
||||||
|
|
||||||
|
echo " Account snapshot synced"
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_videos() {
|
||||||
|
echo "Syncing video stats..."
|
||||||
|
|
||||||
|
RESPONSE=$(curl -s -X POST \
|
||||||
|
'https://open.tiktokapis.com/v2/video/list/?fields=id,title,view_count,like_count,comment_count,share_count' \
|
||||||
|
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"max_count": 20}')
|
||||||
|
|
||||||
|
VIDEO_COUNT=$(echo "$RESPONSE" | jq -r '.data.videos | length')
|
||||||
|
echo " Found $VIDEO_COUNT videos"
|
||||||
|
|
||||||
|
echo "$RESPONSE" | jq -c '.data.videos[]' | while read -r video; do
|
||||||
|
VID=$(echo "$video" | jq -r '.id')
|
||||||
|
# Get first 80 chars of title, escape quotes for JSON
|
||||||
|
TITLE=$(echo "$video" | jq -r '.title // "Untitled"' | head -c 80 | sed 's/"/\\"/g')
|
||||||
|
VIEWS=$(echo "$video" | jq -r '.view_count // 0')
|
||||||
|
LIKES=$(echo "$video" | jq -r '.like_count // 0')
|
||||||
|
COMMENTS=$(echo "$video" | jq -r '.comment_count // 0')
|
||||||
|
SHARES=$(echo "$video" | jq -r '.share_count // 0')
|
||||||
|
|
||||||
|
POST_URL="https://www.tiktok.com/@playfirefrost/video/$VID"
|
||||||
|
|
||||||
|
curl -s -X POST "$ARBITER_URL/sync" \
|
||||||
|
-H "Authorization: Bearer $ARBITER_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"platform\":\"tiktok\",\"platform_post_id\":\"$VID\",\"post_title\":\"$TITLE\",\"post_url\":\"$POST_URL\",\"metrics\":{\"views\":$VIEWS,\"likes\":$LIKES,\"comments\":$COMMENTS,\"shares\":$SHARES}}" > /dev/null
|
||||||
|
|
||||||
|
echo " $VID: $VIEWS views, $LIKES likes"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo " Videos synced"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== TikTok Sync $(date) ==="
|
||||||
|
refresh_token
|
||||||
|
sync_account_stats
|
||||||
|
sync_videos
|
||||||
|
echo "=== Complete ==="
|
||||||
43
services/_archived/README.md
Normal file
43
services/_archived/README.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Archived Services
|
||||||
|
|
||||||
|
These services have been superseded and are kept for historical reference only.
|
||||||
|
|
||||||
|
**DO NOT DEPLOY THESE** — they are not maintained and may have security issues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## arbiter/ (v2.0.0)
|
||||||
|
|
||||||
|
**Superseded by:** `arbiter-3.0/`
|
||||||
|
**Archived:** April 11, 2026
|
||||||
|
**Reason:** Complete rewrite for Trinity Console integration
|
||||||
|
|
||||||
|
Arbiter 2.0 was the original Discord role management bot. It has been replaced by Arbiter 3.0+ which includes:
|
||||||
|
- Trinity Console admin dashboard
|
||||||
|
- Modular architecture
|
||||||
|
- Direct Stripe integration
|
||||||
|
- Social analytics
|
||||||
|
- Internal API system
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## whitelist-manager/
|
||||||
|
|
||||||
|
**Superseded by:** Trinity Console (Arbiter 3.0)
|
||||||
|
**Archived:** April 11, 2026
|
||||||
|
**Reason:** Functionality merged into Trinity Console
|
||||||
|
|
||||||
|
The standalone whitelist manager is now part of the Trinity Console's subscriber management module.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## If You Need Something From Here
|
||||||
|
|
||||||
|
1. Check if the functionality exists in `arbiter-3.0/` first
|
||||||
|
2. Reference the old code for patterns if needed
|
||||||
|
3. Do NOT copy code without updating for current architecture
|
||||||
|
4. Ask Michael before resurrecting anything
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Fire + Frost + Foundation = Where Love Builds Legacy** 💙🔥❄️
|
||||||
148
services/arbiter-3.0/DEPLOYMENT.md
Normal file
148
services/arbiter-3.0/DEPLOYMENT.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# Arbiter 3.0 Deployment Guide
|
||||||
|
|
||||||
|
**Location:** Command Center (63.143.34.217)
|
||||||
|
**Service:** `arbiter-3` (systemd)
|
||||||
|
**Directory:** `/opt/arbiter-3.0`
|
||||||
|
**Dashboard:** https://discord-bot.firefrostgaming.com/admin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 One-Click Deploy (Trinity Console)
|
||||||
|
|
||||||
|
**For Holly, Meg, and Michael:**
|
||||||
|
|
||||||
|
1. Push your code changes to `firefrost-services` repo
|
||||||
|
2. Open Trinity Console: https://discord-bot.firefrostgaming.com/admin
|
||||||
|
3. Click the **"🚀 Deploy Arbiter"** button in the sidebar
|
||||||
|
4. Wait for success confirmation
|
||||||
|
|
||||||
|
That's it! The button pulls latest code from Gitea and restarts the service.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ IMPORTANT: Arbiter is NOT a Git Repo
|
||||||
|
|
||||||
|
`/opt/arbiter-3.0` on Command Center is **not** a git repository. It's a deployment target.
|
||||||
|
|
||||||
|
**Source of truth:** `firefrost-services` repo → `services/arbiter-3.0/`
|
||||||
|
|
||||||
|
**Why?** Production servers shouldn't have git credentials or .git history. Deploy by copying files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual Deploy (SSH Required)
|
||||||
|
|
||||||
|
**On Command Center:**
|
||||||
|
```bash
|
||||||
|
bash /opt/arbiter-3.0/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Or remote curl:**
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.firefrostgaming.com/firefrost-gaming/firefrost-services/raw/branch/main/services/arbiter-3.0/deploy.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First-Time Server Setup
|
||||||
|
|
||||||
|
If setting up deploy button for the first time, Michael needs to run these on Command Center:
|
||||||
|
|
||||||
|
**1. Copy deploy script to /opt/scripts:**
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/scripts
|
||||||
|
sudo cp /opt/arbiter-3.0/deploy.sh /opt/scripts/deploy-arbiter.sh
|
||||||
|
sudo chmod +x /opt/scripts/deploy-arbiter.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Configure sudoers (allow Arbiter to run deploy script):**
|
||||||
|
```bash
|
||||||
|
sudo visudo
|
||||||
|
```
|
||||||
|
Add this line:
|
||||||
|
```
|
||||||
|
architect ALL=(ALL) NOPASSWD: /opt/scripts/deploy-arbiter.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Create log file:**
|
||||||
|
```bash
|
||||||
|
sudo touch /var/log/trinity-deployments.log
|
||||||
|
sudo chown architect:architect /var/log/trinity-deployments.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verify Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check service status
|
||||||
|
systemctl status arbiter-3
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
journalctl -u arbiter-3 -n 50
|
||||||
|
|
||||||
|
# Check deployment log
|
||||||
|
tail -20 /var/log/trinity-deployments.log
|
||||||
|
|
||||||
|
# Test dashboard
|
||||||
|
curl -s https://discord-bot.firefrostgaming.com/admin | head -5
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### "Deployment already in progress"
|
||||||
|
**Cause:** Previous deploy didn't finish or crashed
|
||||||
|
**Fix:** `rm -rf /tmp/arbiter_deploy.lock` then try again
|
||||||
|
|
||||||
|
### "fatal: not a git repository"
|
||||||
|
**Cause:** Someone tried to `git pull` in `/opt/arbiter-3.0`
|
||||||
|
**Fix:** Use the deploy script or manual copy method above
|
||||||
|
|
||||||
|
### "/tmp/firefrost-services already exists"
|
||||||
|
**Cause:** Previous deploy didn't clean up
|
||||||
|
**Fix:** `rm -rf /tmp/firefrost-services /tmp/firefrost-services-deploy-*` then try again
|
||||||
|
|
||||||
|
### Service fails to start
|
||||||
|
**Check:** `journalctl -u arbiter-3 -n 50`
|
||||||
|
**Common causes:**
|
||||||
|
- Missing .env file
|
||||||
|
- Database connection failed
|
||||||
|
- Port already in use
|
||||||
|
- Syntax error in code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Required in `/opt/arbiter-3.0/.env`:
|
||||||
|
```
|
||||||
|
DATABASE_URL=postgresql://...
|
||||||
|
DISCORD_TOKEN=...
|
||||||
|
DISCORD_CLIENT_ID=...
|
||||||
|
DISCORD_CLIENT_SECRET=...
|
||||||
|
PANEL_URL=https://panel.firefrostgaming.com
|
||||||
|
PANEL_APPLICATION_KEY=...
|
||||||
|
PANEL_CLIENT_KEY=...
|
||||||
|
STRIPE_SECRET_KEY=...
|
||||||
|
STRIPE_WEBHOOK_SECRET=...
|
||||||
|
SESSION_SECRET=...
|
||||||
|
MINECRAFT_NEST_IDS=1,5
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For Chroniclers & Catalysts
|
||||||
|
|
||||||
|
**You cannot SSH to Command Center** (port 22 blocked from Claude sandbox).
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
1. Make changes to `firefrost-services/services/arbiter-3.0/`
|
||||||
|
2. Commit and push to Gitea
|
||||||
|
3. Tell your human to click "🚀 Deploy Arbiter" in Trinity Console
|
||||||
|
4. Verify via dashboard or ask them to check logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Fire + Frost + Foundation = Where Love Builds Legacy** 🔥❄️
|
||||||
77
services/arbiter-3.0/deploy.sh
Normal file
77
services/arbiter-3.0/deploy.sh
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Arbiter 3.0 Deployment Script
|
||||||
|
# Run on Command Center: bash /opt/arbiter-3.0/deploy.sh
|
||||||
|
# Or via Trinity Console Deploy button
|
||||||
|
#
|
||||||
|
# Usage: deploy.sh [username]
|
||||||
|
# username: Optional - who triggered the deploy (for logging)
|
||||||
|
|
||||||
|
set -e # Exit on any error
|
||||||
|
|
||||||
|
REPO_URL="https://git.firefrostgaming.com/firefrost-gaming/firefrost-services.git"
|
||||||
|
TEMP_DIR="/tmp/firefrost-services-deploy-$$"
|
||||||
|
ARBITER_DIR="/opt/arbiter-3.0"
|
||||||
|
SERVICE_NAME="arbiter-3"
|
||||||
|
LOCKDIR="/tmp/arbiter_deploy.lock"
|
||||||
|
LOG_FILE="/var/log/trinity-deployments.log"
|
||||||
|
DEPLOY_USER="${1:-manual}"
|
||||||
|
|
||||||
|
# Logging function
|
||||||
|
log() {
|
||||||
|
echo "$1"
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Prevent concurrent deployments with a lock directory
|
||||||
|
if ! mkdir "$LOCKDIR" 2>/dev/null; then
|
||||||
|
echo "ERROR: Deployment already in progress." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Ensure lock is removed when script exits (success or failure)
|
||||||
|
trap 'rm -rf "$LOCKDIR"' EXIT
|
||||||
|
|
||||||
|
log "🔥❄️ Arbiter deployment started by: $DEPLOY_USER"
|
||||||
|
|
||||||
|
# Cleanup any old temp directories
|
||||||
|
rm -rf /tmp/firefrost-services /tmp/firefrost-services-deploy-*
|
||||||
|
|
||||||
|
# Clone fresh
|
||||||
|
log "📥 Cloning firefrost-services..."
|
||||||
|
git clone --depth 1 "$REPO_URL" "$TEMP_DIR"
|
||||||
|
|
||||||
|
# Get commit info for logging
|
||||||
|
COMMIT_HASH=$(cd "$TEMP_DIR" && git log -1 --format="%h - %s")
|
||||||
|
log "📌 Deploying commit: $COMMIT_HASH"
|
||||||
|
|
||||||
|
# Copy arbiter files
|
||||||
|
log "📋 Copying Arbiter files..."
|
||||||
|
cp -r "$TEMP_DIR/services/arbiter-3.0/src/"* "$ARBITER_DIR/src/"
|
||||||
|
cp -r "$TEMP_DIR/services/arbiter-3.0/migrations/"* "$ARBITER_DIR/migrations/" 2>/dev/null || true
|
||||||
|
cp "$TEMP_DIR/services/arbiter-3.0/package.json" "$ARBITER_DIR/package.json" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Check if package.json changed (need npm install)
|
||||||
|
if ! cmp -s "$TEMP_DIR/services/arbiter-3.0/package.json" "$ARBITER_DIR/package.json.bak" 2>/dev/null; then
|
||||||
|
log "📦 Dependencies changed, running npm install..."
|
||||||
|
cd "$ARBITER_DIR"
|
||||||
|
npm install --production --ignore-scripts
|
||||||
|
cp "$ARBITER_DIR/package.json" "$ARBITER_DIR/package.json.bak"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup temp files BEFORE restart
|
||||||
|
log "🧹 Cleaning up temp files..."
|
||||||
|
rm -rf "$TEMP_DIR"
|
||||||
|
|
||||||
|
# Restart service
|
||||||
|
log "🔄 Restarting $SERVICE_NAME..."
|
||||||
|
systemctl restart "$SERVICE_NAME"
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
sleep 2
|
||||||
|
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||||
|
log "✅ Arbiter deployed successfully! Commit: $COMMIT_HASH"
|
||||||
|
echo "SUCCESS: Deployed commit $COMMIT_HASH"
|
||||||
|
else
|
||||||
|
log "❌ Service failed to start after deploy"
|
||||||
|
echo "ERROR: Service failed to start. Check: journalctl -u $SERVICE_NAME -n 50" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- Task #94: Global Restart Scheduler
|
||||||
|
-- Migration for arbiter_db
|
||||||
|
-- Run: psql -U arbiter -d arbiter_db -f 094_global_restart_scheduler.sql
|
||||||
|
|
||||||
|
-- 1. Configuration for Node-wide Stagger Logic
|
||||||
|
CREATE TABLE IF NOT EXISTS global_restart_config (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
node VARCHAR(10) UNIQUE NOT NULL, -- 'TX1', 'NC1'
|
||||||
|
base_time TIME NOT NULL, -- e.g., '04:00:00' (UTC)
|
||||||
|
interval_minutes INT DEFAULT 5, -- Stagger gap
|
||||||
|
is_enabled BOOLEAN DEFAULT true, -- Global master switch per node
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by VARCHAR(50) -- Discord Username
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Individual Server Execution State
|
||||||
|
CREATE TABLE IF NOT EXISTS server_restart_schedules (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
server_id VARCHAR(50) UNIQUE NOT NULL, -- Pterodactyl 8-char short ID
|
||||||
|
server_name VARCHAR(100) NOT NULL,
|
||||||
|
node VARCHAR(10) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0, -- Manual boot order
|
||||||
|
effective_time TIME, -- Calculated: base + (sort * interval)
|
||||||
|
ptero_schedule_id INT DEFAULT NULL, -- ID of schedule on Pterodactyl
|
||||||
|
skip_restart BOOLEAN DEFAULT false, -- Individual "Maintenance Mode"
|
||||||
|
sync_status VARCHAR(20) DEFAULT 'PENDING', -- 'SUCCESS', 'PENDING', 'FAILED'
|
||||||
|
last_error TEXT DEFAULT NULL, -- API error capture
|
||||||
|
last_synced_at TIMESTAMP NULL,
|
||||||
|
|
||||||
|
CONSTRAINT fk_node_config
|
||||||
|
FOREIGN KEY (node)
|
||||||
|
REFERENCES global_restart_config(node)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. Audit Trail for Sync Operations
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_logs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
server_id VARCHAR(50) NOT NULL,
|
||||||
|
action VARCHAR(255) NOT NULL, -- e.g., 'Deleted Rogue Schedule', 'Created Schedule'
|
||||||
|
status VARCHAR(20) NOT NULL, -- 'SUCCESS', 'FAILED'
|
||||||
|
error_message TEXT DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. Performance Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_server_node_order ON server_restart_schedules (node, sort_order);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_status ON server_restart_schedules (sync_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_logs_server ON sync_logs (server_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_logs_created ON sync_logs (created_at);
|
||||||
|
|
||||||
|
-- 5. Initial Seed Data
|
||||||
|
INSERT INTO global_restart_config (node, base_time, interval_minutes, updated_by)
|
||||||
|
VALUES
|
||||||
|
('TX1', '04:00:00', 5, 'The Wizard'),
|
||||||
|
('NC1', '04:30:00', 5, 'The Wizard')
|
||||||
|
ON CONFLICT (node) DO NOTHING;
|
||||||
76
services/arbiter-3.0/migrations/108_social_analytics.sql
Normal file
76
services/arbiter-3.0/migrations/108_social_analytics.sql
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
-- Social Analytics Tables
|
||||||
|
-- Task #108: TikTok Analytics Dashboard (Phase 1 - Manual Entry)
|
||||||
|
-- Created by Chronicler #76
|
||||||
|
|
||||||
|
-- Platform enum for future expansion
|
||||||
|
CREATE TYPE social_platform AS ENUM ('tiktok', 'facebook', 'instagram', 'x', 'bluesky');
|
||||||
|
|
||||||
|
-- Individual post tracking
|
||||||
|
CREATE TABLE social_posts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
platform social_platform NOT NULL DEFAULT 'tiktok',
|
||||||
|
post_title VARCHAR(255) NOT NULL,
|
||||||
|
post_url VARCHAR(500),
|
||||||
|
posted_at TIMESTAMP NOT NULL,
|
||||||
|
video_length_seconds INTEGER,
|
||||||
|
|
||||||
|
-- Core engagement metrics
|
||||||
|
views INTEGER DEFAULT 0,
|
||||||
|
likes INTEGER DEFAULT 0,
|
||||||
|
comments INTEGER DEFAULT 0,
|
||||||
|
shares INTEGER DEFAULT 0,
|
||||||
|
saves INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Watch metrics
|
||||||
|
total_play_time_seconds INTEGER DEFAULT 0,
|
||||||
|
avg_watch_time_seconds DECIMAL(10,2) DEFAULT 0,
|
||||||
|
watched_full_pct DECIMAL(5,2) DEFAULT 0,
|
||||||
|
drop_off_seconds INTEGER,
|
||||||
|
|
||||||
|
-- Growth metrics
|
||||||
|
new_followers INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Traffic source (store top source)
|
||||||
|
top_traffic_source VARCHAR(50),
|
||||||
|
top_traffic_pct DECIMAL(5,2),
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
-- Platform native ID (for Phase 2 API matching)
|
||||||
|
platform_post_id VARCHAR(255)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Account-level snapshots (weekly or on-demand)
|
||||||
|
CREATE TABLE social_account_snapshots (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
platform social_platform NOT NULL DEFAULT 'tiktok',
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
|
||||||
|
-- Account metrics
|
||||||
|
total_followers INTEGER DEFAULT 0,
|
||||||
|
profile_views INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Top search queries (JSON array)
|
||||||
|
search_queries JSONB,
|
||||||
|
|
||||||
|
-- Audience demographics (JSON)
|
||||||
|
demographics JSONB,
|
||||||
|
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- One snapshot per platform per day
|
||||||
|
UNIQUE(platform, snapshot_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for common queries
|
||||||
|
CREATE INDEX idx_social_posts_platform ON social_posts(platform);
|
||||||
|
CREATE INDEX idx_social_posts_posted_at ON social_posts(posted_at DESC);
|
||||||
|
CREATE INDEX idx_social_posts_platform_post_id ON social_posts(platform, platform_post_id);
|
||||||
|
CREATE INDEX idx_social_snapshots_platform_date ON social_account_snapshots(platform, snapshot_date DESC);
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE social_posts IS 'Individual social media post analytics (manual entry for now, API later)';
|
||||||
|
COMMENT ON TABLE social_account_snapshots IS 'Account-level metrics snapshots for trend tracking';
|
||||||
41
services/arbiter-3.0/migrations/seed-stripe-products.sql
Normal file
41
services/arbiter-3.0/migrations/seed-stripe-products.sql
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- STRIPE PRODUCTS SEED DATA
|
||||||
|
-- ============================================================================
|
||||||
|
-- Date: April 10, 2026
|
||||||
|
-- Purpose: Populate stripe_products table with actual Stripe price/product IDs
|
||||||
|
-- Run: PGPASSWORD='FireFrost2026!Arbiter' psql -U arbiter -h 127.0.0.1 -d arbiter_db -f seed-stripe-products.sql
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Clear existing data (if re-running)
|
||||||
|
TRUNCATE TABLE stripe_products;
|
||||||
|
|
||||||
|
-- Insert all tiers with actual Stripe IDs
|
||||||
|
INSERT INTO stripe_products (tier_level, tier_name, fire_or_frost, price_monthly, stripe_product_id, stripe_price_id, billing_type) VALUES
|
||||||
|
-- Tier 1: Awakened (one-time $1)
|
||||||
|
(1, 'Awakened', 'both', 1.00, 'prod_UGp8bRpm0C72tV', 'price_1TIHUDHaQd1A6XDNju7L7kTQ', 'one-time'),
|
||||||
|
|
||||||
|
-- Tier 2-3: Elemental ($5/mo)
|
||||||
|
(2, 'Elemental (Fire)', 'fire', 5.00, 'prod_UGp85wvlABvjOM', 'price_1TIHUEHaQd1A6XDNJmNgSvfE', 'recurring'),
|
||||||
|
(3, 'Elemental (Frost)', 'frost', 5.00, 'prod_UGp838XQJUHKjL', 'price_1TIHUEHaQd1A6XDNmEszQSZ7', 'recurring'),
|
||||||
|
|
||||||
|
-- Tier 4-5: Knight ($10/mo)
|
||||||
|
(4, 'Knight (Fire)', 'fire', 10.00, 'prod_UGp8H8Yo4aUszs', 'price_1TIHUEHaQd1A6XDN8lWTR999', 'recurring'),
|
||||||
|
(5, 'Knight (Frost)', 'frost', 10.00, 'prod_UGp8lGymToNpFC', 'price_1TIHUFHaQd1A6XDNukxZq8UI', 'recurring'),
|
||||||
|
|
||||||
|
-- Tier 6-7: Master ($15/mo)
|
||||||
|
(6, 'Master (Fire)', 'fire', 15.00, 'prod_UGp8zI5Rl6Z5Fq', 'price_1TIHUFHaQd1A6XDNvBVFQg5y', 'recurring'),
|
||||||
|
(7, 'Master (Frost)', 'frost', 15.00, 'prod_UGp8Wt7t0f9iXW', 'price_1TIHUFHaQd1A6XDNR89F1ZHf', 'recurring'),
|
||||||
|
|
||||||
|
-- Tier 8-9: Legend ($20/mo)
|
||||||
|
(8, 'Legend (Fire)', 'fire', 20.00, 'prod_UGp8AKGcicXLAP', 'price_1TIHUGHaQd1A6XDNzpsgORld', 'recurring'),
|
||||||
|
(9, 'Legend (Frost)', 'frost', 20.00, 'prod_UGp8gWzTLWCkis', 'price_1TIHUGHaQd1A6XDN8nm1pUrB', 'recurring'),
|
||||||
|
|
||||||
|
-- Tier 10: Sovereign (one-time $499)
|
||||||
|
(10, 'Sovereign', 'both', 499.00, 'prod_UGp8KSzb72RaCr', 'price_1TIHUHHaQd1A6XDNKEaFSDrE', 'one-time');
|
||||||
|
|
||||||
|
-- Verify
|
||||||
|
SELECT tier_level, tier_name, fire_or_frost, price_monthly, billing_type FROM stripe_products ORDER BY tier_level;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- END OF SEED
|
||||||
|
-- ============================================================================
|
||||||
@@ -8,10 +8,13 @@
|
|||||||
"dev": "node --watch src/index.js"
|
"dev": "node --watch src/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.14.0",
|
||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
|
"connect-pg-simple": "^10.0.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"csurf": "^1.11.0",
|
"csurf": "^1.11.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"discord.js": "^14.14.1",
|
"discord.js": "^14.14.1",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"ejs": "^3.1.9",
|
"ejs": "^3.1.9",
|
||||||
|
|||||||
73
services/arbiter-3.0/scripts/add-category-emojis.js
Normal file
73
services/arbiter-3.0/scripts/add-category-emojis.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Add Emoji Prefixes to Categories
|
||||||
|
* Adds consistent emoji prefixes to non-server categories
|
||||||
|
*
|
||||||
|
* Created: April 8, 2026
|
||||||
|
* Chronicler: #71
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||||
|
const { Client, GatewayIntentBits, ChannelType } = require('discord.js');
|
||||||
|
|
||||||
|
const RENAMES = [
|
||||||
|
{ from: 'Welcome & Info', to: '📢 Welcome & Info' },
|
||||||
|
{ from: 'Community Hub', to: '💬 Community Hub' },
|
||||||
|
{ from: 'Voice Channels', to: '🔊 Voice Channels' },
|
||||||
|
{ from: 'Support', to: '📞 Support' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🏷️ Add Emoji Prefixes to Categories');
|
||||||
|
console.log('=====================================');
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
intents: [GatewayIntentBits.Guilds]
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||||
|
await new Promise(resolve => {
|
||||||
|
if (client.isReady()) resolve();
|
||||||
|
else client.once('ready', resolve);
|
||||||
|
});
|
||||||
|
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||||
|
|
||||||
|
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||||
|
await guild.channels.fetch();
|
||||||
|
console.log(`✅ Found guild: ${guild.name}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
let renamed = 0;
|
||||||
|
|
||||||
|
for (const rename of RENAMES) {
|
||||||
|
const category = guild.channels.cache.find(
|
||||||
|
ch => ch.type === ChannelType.GuildCategory && ch.name === rename.from
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
console.log(`⚠️ Not found: ${rename.from}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await category.setName(rename.to, 'Adding emoji prefix - Chronicler #71');
|
||||||
|
console.log(`✅ ${rename.from} → ${rename.to}`);
|
||||||
|
renamed++;
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(`✅ Renamed ${renamed} categories`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ ERROR:', error.message);
|
||||||
|
} finally {
|
||||||
|
client.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
109
services/arbiter-3.0/scripts/add-delserver-docs.js
Normal file
109
services/arbiter-3.0/scripts/add-delserver-docs.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Add /delserver documentation to #staff-commands
|
||||||
|
*
|
||||||
|
* Created: April 8, 2026
|
||||||
|
* Chronicler: #71
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||||
|
const { Client, GatewayIntentBits, ChannelType, EmbedBuilder } = require('discord.js');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('📋 Adding /delserver to #staff-commands');
|
||||||
|
console.log('=======================================');
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
intents: [GatewayIntentBits.Guilds]
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||||
|
await new Promise(resolve => {
|
||||||
|
if (client.isReady()) resolve();
|
||||||
|
else client.once('ready', resolve);
|
||||||
|
});
|
||||||
|
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||||
|
|
||||||
|
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||||
|
await guild.channels.fetch();
|
||||||
|
|
||||||
|
// Find #staff-commands
|
||||||
|
const channel = guild.channels.cache.find(
|
||||||
|
ch => ch.name === 'staff-commands'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
console.log('❌ #staff-commands not found!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`✅ Found: #${channel.name}`);
|
||||||
|
|
||||||
|
// /delserver command embed
|
||||||
|
const delServerEmbed = new EmbedBuilder()
|
||||||
|
.setColor(0xDC3545) // Red for danger/delete
|
||||||
|
.setTitle('🗑️ /delserver')
|
||||||
|
.setDescription('Permanently deletes a server setup including all channels and the role.')
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name: '👥 Who Can Use',
|
||||||
|
value: 'Staff, Moderators, Trinity only',
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '📍 Where to Use',
|
||||||
|
value: 'Any channel',
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '📝 Usage',
|
||||||
|
value: '```/delserver name:Server Name confirm:True```',
|
||||||
|
inline: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '⚠️ Preview Mode',
|
||||||
|
value: `Running without \`confirm:True\` shows a preview:
|
||||||
|
\`\`\`/delserver name:Server Name\`\`\`
|
||||||
|
This lists what would be deleted without actually deleting anything.`,
|
||||||
|
inline: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '🗑️ What It Deletes',
|
||||||
|
value: `• All channels in the category (chat, in-game, forum, voice)
|
||||||
|
• The category itself
|
||||||
|
• The server role`,
|
||||||
|
inline: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '📋 After Running',
|
||||||
|
value: `Don't forget to:
|
||||||
|
|
||||||
|
1. Remove the reaction emoji from <#1403980899464384572>
|
||||||
|
2. Remove the role mapping from Carl-bot`,
|
||||||
|
inline: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '💡 Notes',
|
||||||
|
value: `• Always preview first before confirming
|
||||||
|
• Give players at least 7 days notice before deleting
|
||||||
|
• This is permanent — there is no undo
|
||||||
|
• Forum posts and messages are lost forever`,
|
||||||
|
inline: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.setFooter({ text: 'The Arbiter • Firefrost Gaming' });
|
||||||
|
|
||||||
|
await channel.send({ embeds: [delServerEmbed] });
|
||||||
|
console.log('✅ Posted /delserver documentation');
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('✅ Done! Don\'t forget to pin it.');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ ERROR:', error.message);
|
||||||
|
} finally {
|
||||||
|
client.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
97
services/arbiter-3.0/scripts/archive-welcome-posts.js
Normal file
97
services/arbiter-3.0/scripts/archive-welcome-posts.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Archive Welcome Posts
|
||||||
|
* Archives all welcome threads in forum channels so they don't
|
||||||
|
* keep forums "active" when categories are collapsed
|
||||||
|
*
|
||||||
|
* Created: April 8, 2026
|
||||||
|
* Chronicler: #71
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||||
|
const { Client, GatewayIntentBits, ChannelType } = require('discord.js');
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('📦 Archive Welcome Posts');
|
||||||
|
console.log('========================');
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
intents: [GatewayIntentBits.Guilds]
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||||
|
await new Promise(resolve => {
|
||||||
|
if (client.isReady()) resolve();
|
||||||
|
else client.once('ready', resolve);
|
||||||
|
});
|
||||||
|
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||||
|
|
||||||
|
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||||
|
await guild.channels.fetch();
|
||||||
|
console.log(`✅ Found guild: ${guild.name}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Find all forum channels
|
||||||
|
const forums = guild.channels.cache.filter(ch => ch.type === ChannelType.GuildForum);
|
||||||
|
console.log(`Found ${forums.size} forum channels`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
let archived = 0;
|
||||||
|
let alreadyArchived = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
for (const [id, forum] of forums) {
|
||||||
|
// Skip if not a server forum (check for 🎮 parent or server-related name)
|
||||||
|
const parent = forum.parent;
|
||||||
|
if (!parent || !parent.name.includes('🎮')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📁 ${forum.name}`);
|
||||||
|
|
||||||
|
// Fetch active threads
|
||||||
|
const activeThreads = await forum.threads.fetchActive();
|
||||||
|
|
||||||
|
for (const [threadId, thread] of activeThreads.threads) {
|
||||||
|
// Look for welcome posts
|
||||||
|
if (thread.name.toLowerCase().includes('welcome')) {
|
||||||
|
if (thread.archived) {
|
||||||
|
console.log(` ⏭️ Already archived: ${thread.name}`);
|
||||||
|
alreadyArchived++;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await thread.setArchived(true, 'Archiving welcome post - Chronicler #71');
|
||||||
|
console.log(` ✅ Archived: ${thread.name}`);
|
||||||
|
archived++;
|
||||||
|
await sleep(300);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ❌ Failed to archive: ${thread.name} - ${err.message}`);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('📊 SUMMARY');
|
||||||
|
console.log('==========');
|
||||||
|
console.log(`Archived: ${archived}`);
|
||||||
|
console.log(`Already archived: ${alreadyArchived}`);
|
||||||
|
console.log(`Errors: ${errors}`);
|
||||||
|
console.log('');
|
||||||
|
console.log('✅ Done! Categories should now collapse cleanly.');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ ERROR:', error.message);
|
||||||
|
} finally {
|
||||||
|
client.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
211
services/arbiter-3.0/scripts/create-staff-commands-channel.js
Normal file
211
services/arbiter-3.0/scripts/create-staff-commands-channel.js
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Create #staff-commands channel with command documentation
|
||||||
|
*
|
||||||
|
* Created: April 8, 2026
|
||||||
|
* Chronicler: #71
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||||
|
const { Client, GatewayIntentBits, ChannelType, EmbedBuilder } = require('discord.js');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('📋 Creating #staff-commands channel');
|
||||||
|
console.log('====================================');
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
intents: [GatewayIntentBits.Guilds]
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||||
|
await new Promise(resolve => {
|
||||||
|
if (client.isReady()) resolve();
|
||||||
|
else client.once('ready', resolve);
|
||||||
|
});
|
||||||
|
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||||
|
|
||||||
|
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||||
|
await guild.channels.fetch();
|
||||||
|
console.log(`✅ Found guild: ${guild.name}`);
|
||||||
|
|
||||||
|
// Find Staff Area category
|
||||||
|
const staffCategory = guild.channels.cache.find(
|
||||||
|
ch => ch.type === ChannelType.GuildCategory && ch.name.includes('Staff')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!staffCategory) {
|
||||||
|
console.log('❌ Staff Area category not found!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`✅ Found category: ${staffCategory.name}`);
|
||||||
|
|
||||||
|
// Check if channel already exists
|
||||||
|
const existingChannel = guild.channels.cache.find(
|
||||||
|
ch => ch.name === 'staff-commands' && ch.parentId === staffCategory.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingChannel) {
|
||||||
|
console.log('⚠️ #staff-commands already exists, posting documentation...');
|
||||||
|
await postDocumentation(existingChannel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the channel
|
||||||
|
const channel = await guild.channels.create({
|
||||||
|
name: 'staff-commands',
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
parent: staffCategory.id,
|
||||||
|
topic: 'Arbiter bot command reference for staff',
|
||||||
|
reason: 'Staff command documentation - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(`✅ Created: #${channel.name}`);
|
||||||
|
|
||||||
|
// Post documentation
|
||||||
|
await postDocumentation(channel);
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('✅ Done! Check #staff-commands in Discord.');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ ERROR:', error.message);
|
||||||
|
} finally {
|
||||||
|
client.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postDocumentation(channel) {
|
||||||
|
// Header message
|
||||||
|
await channel.send({
|
||||||
|
content: `# 🤖 Arbiter Bot Commands
|
||||||
|
|
||||||
|
This channel documents all slash commands available through The Arbiter. Commands are organized by who can use them.
|
||||||
|
|
||||||
|
---`
|
||||||
|
});
|
||||||
|
|
||||||
|
// /link command embed
|
||||||
|
const linkEmbed = new EmbedBuilder()
|
||||||
|
.setColor(0x4ECDC4) // Frost color
|
||||||
|
.setTitle('📎 /link')
|
||||||
|
.setDescription('Links a Discord account to a Minecraft username for automatic whitelist management.')
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name: '👥 Who Can Use',
|
||||||
|
value: 'Everyone (all server members)',
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '📍 Where to Use',
|
||||||
|
value: 'Any channel',
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '📝 Usage',
|
||||||
|
value: '```/link username:YourMinecraftName```',
|
||||||
|
inline: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '⚙️ What It Does',
|
||||||
|
value: `1. Validates the Minecraft username exists via Mojang API
|
||||||
|
2. Stores the Discord ↔ Minecraft link in the database
|
||||||
|
3. Triggers an immediate whitelist sync across all servers
|
||||||
|
4. Player is automatically whitelisted on servers they have access to`,
|
||||||
|
inline: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '💡 Notes',
|
||||||
|
value: `• Username is case-sensitive (uses Mojang's official casing)
|
||||||
|
• Each Discord account can only link one Minecraft account
|
||||||
|
• Re-running the command updates the linked account
|
||||||
|
• Response is ephemeral (only visible to the user)`,
|
||||||
|
inline: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.setFooter({ text: 'The Arbiter • Firefrost Gaming' });
|
||||||
|
|
||||||
|
await channel.send({ embeds: [linkEmbed] });
|
||||||
|
|
||||||
|
// /createserver command embed
|
||||||
|
const createServerEmbed = new EmbedBuilder()
|
||||||
|
.setColor(0xFF6B35) // Fire color
|
||||||
|
.setTitle('🎮 /createserver')
|
||||||
|
.setDescription('Creates a complete Discord server setup for a new Minecraft server with one command.')
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name: '👥 Who Can Use',
|
||||||
|
value: 'Staff, Moderators, Trinity only',
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '📍 Where to Use',
|
||||||
|
value: 'Any channel',
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '📝 Usage',
|
||||||
|
value: '```/createserver name:Server Name```',
|
||||||
|
inline: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '⚙️ What It Creates',
|
||||||
|
value: `**Role:** \`Server Name\`
|
||||||
|
|
||||||
|
**Category:** \`🎮 Server Name\`
|
||||||
|
|
||||||
|
**Channels:**
|
||||||
|
• \`server-name-chat\` — General text chat
|
||||||
|
• \`server-name-in-game\` — In-game chat bridge
|
||||||
|
• \`server-name-forum\` — Discussion forum with tags
|
||||||
|
• \`Server Name\` — Voice channel
|
||||||
|
|
||||||
|
**Forum Tags:** Builds, Help, Suggestion, Bug Report, Achievement, Guide
|
||||||
|
|
||||||
|
**Welcome Post:** Auto-generated and archived`,
|
||||||
|
inline: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '🔐 Permissions Applied',
|
||||||
|
value: `• **@everyone** — Cannot see
|
||||||
|
• **Wanderer** — Can see, cannot interact (window shopping)
|
||||||
|
• **Server Role** — Full access
|
||||||
|
• **Staff/Mods/Trinity** — Full access`,
|
||||||
|
inline: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '📋 After Running',
|
||||||
|
value: `The bot will suggest an unused emoji. To complete setup:
|
||||||
|
|
||||||
|
1. Go to <#1403980899464384572>
|
||||||
|
2. Add the suggested emoji as a reaction
|
||||||
|
3. Configure Carl-bot to assign the new role when reacted`,
|
||||||
|
inline: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '💡 Notes',
|
||||||
|
value: `• Server name max 50 characters
|
||||||
|
• Cannot create if role or category already exists
|
||||||
|
• Channels are auto-positioned in the new category
|
||||||
|
• Welcome post is archived so forums collapse cleanly`,
|
||||||
|
inline: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.setFooter({ text: 'The Arbiter • Firefrost Gaming' });
|
||||||
|
|
||||||
|
await channel.send({ embeds: [createServerEmbed] });
|
||||||
|
|
||||||
|
// Future commands placeholder
|
||||||
|
await channel.send({
|
||||||
|
content: `---
|
||||||
|
|
||||||
|
## 🔮 Future Commands
|
||||||
|
|
||||||
|
More commands coming soon! Check back here for updates.
|
||||||
|
|
||||||
|
*Last updated: April 8, 2026*`
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Posted command documentation');
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
130
services/arbiter-3.0/scripts/deploy-arbiter.sh
Normal file
130
services/arbiter-3.0/scripts/deploy-arbiter.sh
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# deploy-arbiter.sh - Deploy Arbiter from Gitea with automatic migrations
|
||||||
|
# Location: /opt/scripts/deploy-arbiter.sh
|
||||||
|
#
|
||||||
|
# Features:
|
||||||
|
# - Pulls latest code from firefrost-services repo
|
||||||
|
# - Runs any new database migrations
|
||||||
|
# - Restarts Arbiter service
|
||||||
|
# - Logs all actions for debugging
|
||||||
|
#
|
||||||
|
# Usage: sudo /opt/scripts/deploy-arbiter.sh [username]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
DEPLOY_USER="${1:-system}"
|
||||||
|
GITEA_TOKEN="e0e330cba1749b01ab505093a160e4423ebbbe36"
|
||||||
|
REPO_URL="https://${GITEA_TOKEN}@git.firefrostgaming.com/firefrost-gaming/firefrost-services.git"
|
||||||
|
ARBITER_DIR="/opt/arbiter-3.0"
|
||||||
|
TEMP_DIR="/tmp/arbiter-deploy-$$"
|
||||||
|
LOG_FILE="/var/log/arbiter-deploy.log"
|
||||||
|
MIGRATION_TRACKER="${ARBITER_DIR}/.migrations-applied"
|
||||||
|
|
||||||
|
# Database credentials
|
||||||
|
DB_USER="arbiter"
|
||||||
|
DB_HOST="127.0.0.1"
|
||||||
|
DB_NAME="arbiter_db"
|
||||||
|
DB_PASS="FireFrost2026!Arbiter"
|
||||||
|
|
||||||
|
# Logging function
|
||||||
|
log() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
log "=========================================="
|
||||||
|
log "DEPLOY STARTED by ${DEPLOY_USER}"
|
||||||
|
log "=========================================="
|
||||||
|
|
||||||
|
# Create temp directory
|
||||||
|
mkdir -p "$TEMP_DIR"
|
||||||
|
cd "$TEMP_DIR"
|
||||||
|
|
||||||
|
# Clone the repo
|
||||||
|
log "Cloning firefrost-services..."
|
||||||
|
git clone --depth 1 "$REPO_URL" repo 2>&1 | tee -a "$LOG_FILE"
|
||||||
|
|
||||||
|
# Check if clone succeeded
|
||||||
|
if [ ! -d "repo/services/arbiter-3.0" ]; then
|
||||||
|
log "ERROR: Clone failed or arbiter-3.0 not found"
|
||||||
|
rm -rf "$TEMP_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create migration tracker if it doesn't exist
|
||||||
|
touch "$MIGRATION_TRACKER"
|
||||||
|
|
||||||
|
# Run new migrations
|
||||||
|
log "Checking for new migrations..."
|
||||||
|
MIGRATIONS_DIR="repo/services/arbiter-3.0/migrations"
|
||||||
|
|
||||||
|
if [ -d "$MIGRATIONS_DIR" ]; then
|
||||||
|
for migration in "$MIGRATIONS_DIR"/*.sql; do
|
||||||
|
if [ -f "$migration" ]; then
|
||||||
|
migration_name=$(basename "$migration")
|
||||||
|
|
||||||
|
# Check if already applied
|
||||||
|
if grep -q "^${migration_name}$" "$MIGRATION_TRACKER" 2>/dev/null; then
|
||||||
|
log "SKIP: $migration_name (already applied)"
|
||||||
|
else
|
||||||
|
log "APPLYING: $migration_name"
|
||||||
|
|
||||||
|
# Run the migration
|
||||||
|
if PGPASSWORD="$DB_PASS" psql -U "$DB_USER" -h "$DB_HOST" -d "$DB_NAME" -f "$migration" 2>&1 | tee -a "$LOG_FILE"; then
|
||||||
|
# Mark as applied
|
||||||
|
echo "$migration_name" >> "$MIGRATION_TRACKER"
|
||||||
|
log "SUCCESS: $migration_name applied"
|
||||||
|
else
|
||||||
|
log "WARNING: $migration_name may have failed (continuing anyway)"
|
||||||
|
# Still mark it to avoid re-running on next deploy
|
||||||
|
echo "$migration_name" >> "$MIGRATION_TRACKER"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
log "No migrations directory found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy new files
|
||||||
|
log "Copying updated files..."
|
||||||
|
|
||||||
|
# Copy source files
|
||||||
|
cp -r repo/services/arbiter-3.0/src/* "$ARBITER_DIR/src/" 2>&1 | tee -a "$LOG_FILE"
|
||||||
|
|
||||||
|
# Copy migrations (for reference)
|
||||||
|
cp -r repo/services/arbiter-3.0/migrations/* "$ARBITER_DIR/migrations/" 2>&1 | tee -a "$LOG_FILE"
|
||||||
|
|
||||||
|
# Copy package.json if changed
|
||||||
|
cp repo/services/arbiter-3.0/package.json "$ARBITER_DIR/package.json" 2>&1 | tee -a "$LOG_FILE"
|
||||||
|
|
||||||
|
# Check if package.json changed and run npm install
|
||||||
|
cd "$ARBITER_DIR"
|
||||||
|
if ! cmp -s "$TEMP_DIR/repo/services/arbiter-3.0/package.json" "$ARBITER_DIR/package.json.bak" 2>/dev/null; then
|
||||||
|
log "package.json changed, running npm install..."
|
||||||
|
cp "$ARBITER_DIR/package.json" "$ARBITER_DIR/package.json.bak"
|
||||||
|
npm install --production 2>&1 | tee -a "$LOG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup temp directory
|
||||||
|
rm -rf "$TEMP_DIR"
|
||||||
|
|
||||||
|
# Restart Arbiter
|
||||||
|
log "Restarting Arbiter service..."
|
||||||
|
systemctl restart arbiter-3 2>&1 | tee -a "$LOG_FILE"
|
||||||
|
|
||||||
|
# Wait and check status
|
||||||
|
sleep 3
|
||||||
|
if systemctl is-active --quiet arbiter-3; then
|
||||||
|
log "SUCCESS: Arbiter is running"
|
||||||
|
else
|
||||||
|
log "ERROR: Arbiter failed to start"
|
||||||
|
systemctl status arbiter-3 2>&1 | tee -a "$LOG_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "=========================================="
|
||||||
|
log "DEPLOY COMPLETED by ${DEPLOY_USER}"
|
||||||
|
log "=========================================="
|
||||||
|
|
||||||
|
exit 0
|
||||||
851
services/arbiter-3.0/scripts/discord-channel-full-setup.js
Normal file
851
services/arbiter-3.0/scripts/discord-channel-full-setup.js
Normal file
@@ -0,0 +1,851 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Discord Channel Full Setup Script
|
||||||
|
* Task #98: Create 46 channels for 15 Minecraft servers
|
||||||
|
*
|
||||||
|
* Creates:
|
||||||
|
* - 10 new categories (for servers without channels)
|
||||||
|
* - 15 forum channels (all servers)
|
||||||
|
* - 10 text channels (chat)
|
||||||
|
* - 10 text channels (in-game-chat)
|
||||||
|
* - 10 voice channels
|
||||||
|
* - 1 Archive category
|
||||||
|
* - 15 welcome posts
|
||||||
|
* - Permission overwrites on all categories
|
||||||
|
*
|
||||||
|
* Created: April 8, 2026
|
||||||
|
* Chronicler: #71
|
||||||
|
* Spec: docs/tasks/task-098-discord-channel-automation/forum-content-spec.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||||
|
const { Client, GatewayIntentBits, ChannelType, PermissionFlagsBits } = require('discord.js');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONFIGURATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const DRY_RUN = true; // SET TO false TO EXECUTE
|
||||||
|
|
||||||
|
// Standard forum tags for all server forums
|
||||||
|
const STANDARD_FORUM_TAGS = [
|
||||||
|
{ name: 'Builds', emoji: '🏗️' },
|
||||||
|
{ name: 'Help', emoji: '❓' },
|
||||||
|
{ name: 'Suggestion', emoji: '💡' },
|
||||||
|
{ name: 'Bug Report', emoji: '🐛' },
|
||||||
|
{ name: 'Achievement', emoji: '🎉' },
|
||||||
|
{ name: 'Guide', emoji: '📖' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVER DEFINITIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Servers that ALREADY have categories (just need forum added)
|
||||||
|
const EXISTING_SERVERS = [
|
||||||
|
{
|
||||||
|
name: 'Stoneblock 4',
|
||||||
|
categoryName: 'Stoneblock 4', // Will add 🎮 prefix
|
||||||
|
roleName: 'Stoneblock 4',
|
||||||
|
forumName: 'stoneblock-4-forum',
|
||||||
|
welcomeTitle: 'Welcome to Stoneblock 4!',
|
||||||
|
welcomeBody: `🪨 **The stone remembers.**
|
||||||
|
|
||||||
|
You've entered the void — an endless expanse of stone hiding ancient Vaults, mysterious Echoes, and the legendary World Engine. Dig deep, automate everything, and never forget: in Stoneblock, even chickens are sacred.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your underground empires
|
||||||
|
- ❓ Ask for help with progression
|
||||||
|
- 💡 Suggest server improvements
|
||||||
|
- 🎉 Celebrate your victories
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Show Us Your Starting Cave!**
|
||||||
|
|
||||||
|
Post a screenshot of your first base setup. Did you pick the Lush Cave? The Darkness? Show us where your journey began!
|
||||||
|
|
||||||
|
React with 🔥 for Fire Path or ❄️ for Frost Path!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Society: Sunlit Valley',
|
||||||
|
categoryName: 'Society: Sunlit Valley',
|
||||||
|
roleName: 'Society: Sunlit Valley',
|
||||||
|
forumName: 'sunlit-valley-forum',
|
||||||
|
welcomeTitle: 'Welcome to Sunlit Valley!',
|
||||||
|
welcomeBody: `🌻 **A Stardew Valley experience in Minecraft.**
|
||||||
|
|
||||||
|
Welcome to the valley, farmer! Grow seasonal crops, raise animals, dive into the Skull Cavern for Iridium, and build the coziest farm this side of the blocky world. Money makes the world go round — so get shipping!
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Show off your farm layouts
|
||||||
|
- ❓ Ask about crop rotations and bundles
|
||||||
|
- 💡 Suggest new farming features
|
||||||
|
- 🎉 Share your biggest hauls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Dream Farm Name!**
|
||||||
|
|
||||||
|
What did you name your farm? Post it along with a screenshot of your farmhouse! Bonus points for creative theming.
|
||||||
|
|
||||||
|
React with 🌾 for farming focus or ⛏️ for mining focus!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'All the Mods 10: To the Sky',
|
||||||
|
categoryName: 'All the Mods 10: To the Sky',
|
||||||
|
roleName: 'All The Mods: To the Sky',
|
||||||
|
forumName: 'atm10-sky-forum',
|
||||||
|
welcomeTitle: 'Welcome to ATM10: To the Sky!',
|
||||||
|
welcomeBody: `☁️ **Start with a tree. Build an empire in the void.**
|
||||||
|
|
||||||
|
You've got nothing but a single tree and infinite ambition. Sieve your way to resources, automate your way to power, and craft the legendary ATM Star. This is skyblock evolved — 500+ mods of pure vertical progression.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your sky islands
|
||||||
|
- ❓ Ask about automation setups
|
||||||
|
- 💡 Suggest efficiency improvements
|
||||||
|
- 🎉 Celebrate progression milestones
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Day One Screenshot!**
|
||||||
|
|
||||||
|
Show us your island after your first play session. Tiny platform? Sprawling network? We want to see your start!
|
||||||
|
|
||||||
|
React with 🔥 for Fire Path or ❄️ for Frost Path!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'All the Mons',
|
||||||
|
categoryName: 'All the Mons',
|
||||||
|
roleName: 'All The Mons',
|
||||||
|
forumName: 'all-the-mons-forum',
|
||||||
|
welcomeTitle: 'Welcome to All the Mons!',
|
||||||
|
welcomeBody: `🐾 **All the Mods meets Cobblemon. Gotta catch AND automate 'em all.**
|
||||||
|
|
||||||
|
The ultimate crossover — 500+ tech and magic mods collide with Pokémon. Build factories, cast spells, AND catch 'em all. Custom Pokéball recipes use modded materials, so your automation skills directly power your trainer journey.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Show off your bases and Pokémon pastures
|
||||||
|
- ❓ Ask about spawn locations and evolution
|
||||||
|
- 💡 Suggest Pokémon-related improvements
|
||||||
|
- 🎉 Share shiny catches and team builds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Starter Trio!**
|
||||||
|
|
||||||
|
Post your first three Pokémon! Did you go classic starters or catch wild ones? Show us your team!
|
||||||
|
|
||||||
|
React with 🔥 for Fire types or ❄️ for Ice/Water types!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mythcraft 5',
|
||||||
|
categoryName: 'Mythcraft 5',
|
||||||
|
roleName: 'Mythcraft 5',
|
||||||
|
forumName: 'mythcraft-5-forum',
|
||||||
|
welcomeTitle: 'Welcome to Mythcraft 5!',
|
||||||
|
welcomeBody: `⚔️ **Magic. Alchemy. Technology. Adventure.**
|
||||||
|
|
||||||
|
Over 1,000 structures await exploration. A custom questline guides your progression through dungeons, fortresses, and strange dimensions. Master weapons AND spells. Unlock skills. Become legend.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your bases and discoveries
|
||||||
|
- ❓ Ask for help with progression and bosses
|
||||||
|
- 💡 Suggest adventure improvements
|
||||||
|
- 🎉 Celebrate boss kills and rare loot
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your First Boss Kill!**
|
||||||
|
|
||||||
|
What was the first boss you took down? Share the screenshot and the story!
|
||||||
|
|
||||||
|
React with ⚔️ for combat focus or 🔮 for magic focus!`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Servers that need FULL setup (category + all channels)
|
||||||
|
const NEW_SERVERS = [
|
||||||
|
{
|
||||||
|
name: 'Beyond Depth',
|
||||||
|
categoryName: '🎮 Beyond Depth',
|
||||||
|
roleName: 'Beyond Depth',
|
||||||
|
chatName: 'beyond-depth-chat',
|
||||||
|
inGameName: 'beyond-depth-in-game',
|
||||||
|
voiceName: 'Beyond Depth',
|
||||||
|
forumName: 'beyond-depth-forum',
|
||||||
|
welcomeTitle: 'Welcome to Beyond!',
|
||||||
|
welcomeBody: `🐉 **Push the limits. Go beyond.**
|
||||||
|
|
||||||
|
Whether you're diving into the depths or ascending to new heights, the Beyond series challenges you to master progression and conquer the unknown.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your progress
|
||||||
|
- ❓ Ask for help with challenges
|
||||||
|
- 💡 Suggest improvements
|
||||||
|
- 🎉 Celebrate breakthroughs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Biggest Challenge So Far!**
|
||||||
|
|
||||||
|
What's been the hardest part? Share your struggles and triumphs!
|
||||||
|
|
||||||
|
React with ⬇️ for Depth or ⬆️ for Ascension!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Beyond Ascension',
|
||||||
|
categoryName: '🎮 Beyond Ascension',
|
||||||
|
roleName: 'Beyond Ascension',
|
||||||
|
chatName: 'beyond-ascension-chat',
|
||||||
|
inGameName: 'beyond-ascension-in-game',
|
||||||
|
voiceName: 'Beyond Ascension',
|
||||||
|
forumName: 'beyond-ascension-forum',
|
||||||
|
welcomeTitle: 'Welcome to Beyond Ascension!',
|
||||||
|
welcomeBody: `🐉 **Push the limits. Go beyond.**
|
||||||
|
|
||||||
|
Whether you're diving into the depths or ascending to new heights, the Beyond series challenges you to master progression and conquer the unknown.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your progress
|
||||||
|
- ❓ Ask for help with challenges
|
||||||
|
- 💡 Suggest improvements
|
||||||
|
- 🎉 Celebrate breakthroughs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Biggest Challenge So Far!**
|
||||||
|
|
||||||
|
What's been the hardest part? Share your struggles and triumphs!
|
||||||
|
|
||||||
|
React with ⬇️ for Depth or ⬆️ for Ascension!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Wold's Vaults",
|
||||||
|
categoryName: "🎮 Wold's Vaults",
|
||||||
|
roleName: "Wold's Vaults",
|
||||||
|
chatName: 'wolds-vaults-chat',
|
||||||
|
inGameName: 'wolds-vaults-in-game',
|
||||||
|
voiceName: "Wold's Vaults",
|
||||||
|
forumName: 'wolds-vaults-forum',
|
||||||
|
welcomeTitle: "Welcome to Wold's Vaults!",
|
||||||
|
welcomeBody: `🗄️ **Crack the vaults. Claim the treasure.**
|
||||||
|
|
||||||
|
A progression-focused pack centered around vault hunting. Gear up, dive in, and see what riches await those brave enough to face the challenges within.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your vault hauls
|
||||||
|
- ❓ Ask about vault strategies
|
||||||
|
- 💡 Suggest improvements
|
||||||
|
- 🎉 Celebrate legendary finds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Best Vault Haul!**
|
||||||
|
|
||||||
|
What's the best thing you've pulled from a vault? Show us!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Otherworld [D&D]',
|
||||||
|
categoryName: '🎮 Otherworld [D&D]',
|
||||||
|
roleName: 'Otherworld [Dungeons & Dragons]',
|
||||||
|
chatName: 'otherworld-chat',
|
||||||
|
inGameName: 'otherworld-in-game',
|
||||||
|
voiceName: 'Otherworld',
|
||||||
|
forumName: 'otherworld-forum',
|
||||||
|
welcomeTitle: 'Welcome to Otherworld!',
|
||||||
|
welcomeBody: `🎲 **Roll for initiative in Minecraft.**
|
||||||
|
|
||||||
|
D&D-inspired adventures await. Character classes, dungeon crawling, and tabletop vibes brought to life in block form.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your characters and builds
|
||||||
|
- ❓ Ask about classes and progression
|
||||||
|
- 💡 Suggest adventure improvements
|
||||||
|
- 🎉 Share epic moments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Introduce Your Character!**
|
||||||
|
|
||||||
|
Name, class, backstory. Let's hear it!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'DeceasedCraft',
|
||||||
|
categoryName: '🎮 DeceasedCraft',
|
||||||
|
roleName: 'DeceasedCraft',
|
||||||
|
chatName: 'deceasedcraft-chat',
|
||||||
|
inGameName: 'deceasedcraft-in-game',
|
||||||
|
voiceName: 'DeceasedCraft',
|
||||||
|
forumName: 'deceasedcraft-forum',
|
||||||
|
welcomeTitle: 'Welcome to DeceasedCraft!',
|
||||||
|
welcomeBody: `☠️ **Survive the apocalypse.**
|
||||||
|
|
||||||
|
The world has ended, but you haven't. Scavenge, survive, and maybe even thrive in a hostile world where death lurks around every corner.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your survival setups
|
||||||
|
- ❓ Ask about survival strategies
|
||||||
|
- 💡 Suggest improvements
|
||||||
|
- 🎉 Celebrate survival milestones
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Day 7 Screenshot!**
|
||||||
|
|
||||||
|
If you survived a week, show us your base!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Submerged 2',
|
||||||
|
categoryName: '🎮 Submerged 2',
|
||||||
|
roleName: 'Submerged 2',
|
||||||
|
chatName: 'submerged-2-chat',
|
||||||
|
inGameName: 'submerged-2-in-game',
|
||||||
|
voiceName: 'Submerged 2',
|
||||||
|
forumName: 'submerged-2-forum',
|
||||||
|
welcomeTitle: 'Welcome to Submerged 2!',
|
||||||
|
welcomeBody: `🌊 **The depths await.**
|
||||||
|
|
||||||
|
Dive into an underwater adventure where the ocean is your home. Build aquatic bases, explore sunken ruins, and survive the pressure of the deep.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your underwater bases
|
||||||
|
- ❓ Ask about aquatic survival
|
||||||
|
- 💡 Suggest ocean improvements
|
||||||
|
- 🎉 Celebrate deep sea discoveries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your First Underwater Base!**
|
||||||
|
|
||||||
|
Show us where you set up shop beneath the waves!
|
||||||
|
|
||||||
|
React with 🐠 for ocean life or 🏗️ for engineering focus!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sneak's Pirate Pack",
|
||||||
|
categoryName: "🎮 Sneak's Pirate Pack",
|
||||||
|
roleName: "Sneak's Pirate Pack",
|
||||||
|
chatName: 'sneaks-pirate-pack-chat',
|
||||||
|
inGameName: 'sneaks-pirate-pack-in-game',
|
||||||
|
voiceName: "Sneak's Pirate Pack",
|
||||||
|
forumName: 'sneaks-pirate-pack-forum',
|
||||||
|
welcomeTitle: "Ahoy, Welcome to Sneak's Pirate Pack!",
|
||||||
|
welcomeBody: `🏴☠️ **Set sail for adventure!**
|
||||||
|
|
||||||
|
A pirate's life for thee! Build ships, explore the seas, find treasure, and live the swashbuckling dream. Just watch out for sea monsters...
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your ships and ports
|
||||||
|
- ❓ Ask about naval adventures
|
||||||
|
- 💡 Suggest pirate improvements
|
||||||
|
- 🎉 Show off your treasure hoards
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Ship!**
|
||||||
|
|
||||||
|
Every pirate needs a vessel. Show us your pride and joy!
|
||||||
|
|
||||||
|
React with ⚓ for sailors or 💀 for scallywags!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cottage Witch',
|
||||||
|
categoryName: '🎮 Cottage Witch',
|
||||||
|
roleName: 'Cottage Witch',
|
||||||
|
chatName: 'cottage-witch-chat',
|
||||||
|
inGameName: 'cottage-witch-in-game',
|
||||||
|
voiceName: 'Cottage Witch',
|
||||||
|
forumName: 'cottage-witch-forum',
|
||||||
|
welcomeTitle: 'Welcome to Cottage Witch!',
|
||||||
|
welcomeBody: `🧙 **Cozy vibes. Domestic magic. Witchy aesthetics.**
|
||||||
|
|
||||||
|
Cottage Witch emphasizes the magic in everyday things — cooking, crafting, decorating. Build your perfect witch's cabin, brew potions, cast spells with Ars Nouveau and Hexerei, and let Create automate your cozy life.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your witchy builds
|
||||||
|
- ❓ Ask about magic systems
|
||||||
|
- 💡 Suggest cozy improvements
|
||||||
|
- 🎉 Show off your collections
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Witch's Corner!**
|
||||||
|
|
||||||
|
Every witch needs a cozy corner. Show us your cauldron setup, potion station, or spell crafting area!
|
||||||
|
|
||||||
|
React with 🌙 for dark witch or 🌻 for cottage witch!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Farm Crossing 5',
|
||||||
|
categoryName: '🎮 Farm Crossing 5',
|
||||||
|
roleName: 'Farm Crossing 5',
|
||||||
|
chatName: 'farm-crossing-5-chat',
|
||||||
|
inGameName: 'farm-crossing-5-in-game',
|
||||||
|
voiceName: 'Farm Crossing 5',
|
||||||
|
forumName: 'farm-crossing-5-forum',
|
||||||
|
welcomeTitle: 'Welcome to Farm Crossing 5!',
|
||||||
|
welcomeBody: `🌾 **The coziest crossover.**
|
||||||
|
|
||||||
|
Animal Crossing vibes meet Minecraft farming. Relax, decorate, farm, and make friends with your animal neighbors.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your island/farm layouts
|
||||||
|
- ❓ Ask about villagers and farming
|
||||||
|
- 💡 Suggest cozy additions
|
||||||
|
- 🎉 Show off your collections
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Favorite Corner!**
|
||||||
|
|
||||||
|
Show us your coziest spot!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Homestead',
|
||||||
|
categoryName: '🎮 Homestead',
|
||||||
|
roleName: 'Homestead',
|
||||||
|
chatName: 'homestead-chat',
|
||||||
|
inGameName: 'homestead-in-game',
|
||||||
|
voiceName: 'Homestead',
|
||||||
|
forumName: 'homestead-forum',
|
||||||
|
welcomeTitle: 'Welcome to Homestead!',
|
||||||
|
welcomeBody: `🏠 **Build your dream. Live your peace.**
|
||||||
|
|
||||||
|
Homestead is all about cozy survival — building, farming, and creating your perfect world without the pressure. Take your time, make it beautiful, and enjoy the journey.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your homestead builds
|
||||||
|
- ❓ Ask about building techniques
|
||||||
|
- 💡 Suggest cozy additions
|
||||||
|
- 🎉 Show off your finished projects
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Front Door!**
|
||||||
|
|
||||||
|
Post a screenshot standing at your front door looking out. What does home look like?
|
||||||
|
|
||||||
|
React with 🏡 for cottage vibes or 🏰 for grand builds!`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a role by name (case-insensitive)
|
||||||
|
*/
|
||||||
|
function findRole(guild, roleName) {
|
||||||
|
return guild.roles.cache.find(r => r.name.toLowerCase() === roleName.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a category by name (case-insensitive, ignores emoji prefix)
|
||||||
|
*/
|
||||||
|
function findCategory(guild, categoryName) {
|
||||||
|
const searchName = categoryName.replace(/^🎮\s*/, '').toLowerCase();
|
||||||
|
return guild.channels.cache.find(ch =>
|
||||||
|
ch.type === ChannelType.GuildCategory &&
|
||||||
|
ch.name.replace(/^🎮\s*/, '').toLowerCase() === searchName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build permission overwrites for a server category
|
||||||
|
*/
|
||||||
|
function buildPermissionOverwrites(guild, serverRole) {
|
||||||
|
const everyone = guild.roles.everyone;
|
||||||
|
const wanderer = findRole(guild, 'Wanderer');
|
||||||
|
const staff = findRole(guild, 'Staff');
|
||||||
|
const moderator = findRole(guild, '🛡️ Moderator');
|
||||||
|
const wizard = findRole(guild, '👑 The Wizard');
|
||||||
|
const emissary = findRole(guild, '💎 The Emissary');
|
||||||
|
const catalyst = findRole(guild, '✨ The Catalyst');
|
||||||
|
|
||||||
|
const overwrites = [
|
||||||
|
// @everyone - deny all
|
||||||
|
{
|
||||||
|
id: everyone.id,
|
||||||
|
deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect]
|
||||||
|
},
|
||||||
|
// Server role - allow all
|
||||||
|
{
|
||||||
|
id: serverRole.id,
|
||||||
|
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Wanderer - view only (window shopping)
|
||||||
|
if (wanderer) {
|
||||||
|
overwrites.push({
|
||||||
|
id: wanderer.id,
|
||||||
|
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ReadMessageHistory],
|
||||||
|
deny: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staff roles - allow all
|
||||||
|
const staffRoles = [staff, moderator, wizard, emissary, catalyst].filter(Boolean);
|
||||||
|
for (const role of staffRoles) {
|
||||||
|
overwrites.push({
|
||||||
|
id: role.id,
|
||||||
|
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return overwrites;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep helper for rate limiting
|
||||||
|
*/
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MAIN SCRIPT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔧 Discord Channel Full Setup');
|
||||||
|
console.log('=============================');
|
||||||
|
console.log(`Mode: ${DRY_RUN ? '🔍 DRY RUN (no changes)' : '⚡ LIVE (will create channels)'}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers]
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
categoriesCreated: 0,
|
||||||
|
categoriesRenamed: 0,
|
||||||
|
forumsCreated: 0,
|
||||||
|
textChannelsCreated: 0,
|
||||||
|
voiceChannelsCreated: 0,
|
||||||
|
welcomePostsCreated: 0,
|
||||||
|
permissionsApplied: 0,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Login
|
||||||
|
console.log('📡 Connecting to Discord...');
|
||||||
|
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||||
|
|
||||||
|
await new Promise(resolve => {
|
||||||
|
if (client.isReady()) resolve();
|
||||||
|
else client.once('ready', resolve);
|
||||||
|
});
|
||||||
|
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||||
|
|
||||||
|
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||||
|
if (!guild) throw new Error('Guild not found');
|
||||||
|
console.log(`✅ Found guild: ${guild.name}`);
|
||||||
|
|
||||||
|
// Fetch all data
|
||||||
|
await guild.channels.fetch();
|
||||||
|
await guild.roles.fetch();
|
||||||
|
console.log(`📊 Current: ${guild.channels.cache.size} channels, ${guild.roles.cache.size} roles`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PHASE 1: Add 🎮 prefix to existing 5 server categories
|
||||||
|
// ========================================================================
|
||||||
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
console.log('PHASE 1: Update existing server categories (add 🎮 prefix)');
|
||||||
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
|
||||||
|
for (const server of EXISTING_SERVERS) {
|
||||||
|
const category = findCategory(guild, server.categoryName);
|
||||||
|
if (!category) {
|
||||||
|
console.log(` ⚠️ Category not found: ${server.categoryName}`);
|
||||||
|
stats.errors.push(`Category not found: ${server.categoryName}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 🎮 prefix if not present
|
||||||
|
if (!category.name.startsWith('🎮')) {
|
||||||
|
const newName = `🎮 ${category.name}`;
|
||||||
|
console.log(` 📝 Renaming: "${category.name}" → "${newName}"`);
|
||||||
|
if (!DRY_RUN) {
|
||||||
|
await category.setName(newName, 'Task #98 - Add emoji prefix');
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
stats.categoriesRenamed++;
|
||||||
|
} else {
|
||||||
|
console.log(` ✓ Already has prefix: ${category.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PHASE 2: Add forums to existing 5 servers
|
||||||
|
// ========================================================================
|
||||||
|
console.log('');
|
||||||
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
console.log('PHASE 2: Add forum channels to existing 5 servers');
|
||||||
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
|
||||||
|
for (const server of EXISTING_SERVERS) {
|
||||||
|
console.log(`\n 📦 ${server.name}`);
|
||||||
|
|
||||||
|
const category = findCategory(guild, server.categoryName);
|
||||||
|
if (!category) {
|
||||||
|
console.log(` ⚠️ Category not found, skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverRole = findRole(guild, server.roleName);
|
||||||
|
if (!serverRole) {
|
||||||
|
console.log(` ⚠️ Role not found: ${server.roleName}`);
|
||||||
|
stats.errors.push(`Role not found: ${server.roleName}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if forum already exists
|
||||||
|
const existingForum = guild.channels.cache.find(
|
||||||
|
ch => ch.type === ChannelType.GuildForum && ch.parentId === category.id
|
||||||
|
);
|
||||||
|
if (existingForum) {
|
||||||
|
console.log(` ✓ Forum already exists: ${existingForum.name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create forum
|
||||||
|
console.log(` Creating forum: ${server.forumName}`);
|
||||||
|
if (!DRY_RUN) {
|
||||||
|
const forum = await guild.channels.create({
|
||||||
|
name: server.forumName,
|
||||||
|
type: ChannelType.GuildForum,
|
||||||
|
parent: category.id,
|
||||||
|
topic: `Discussion forum for ${server.name}`,
|
||||||
|
availableTags: STANDARD_FORUM_TAGS.map(tag => ({
|
||||||
|
name: tag.name,
|
||||||
|
emoji: tag.emoji ? { name: tag.emoji } : null
|
||||||
|
})),
|
||||||
|
permissionOverwrites: buildPermissionOverwrites(guild, serverRole),
|
||||||
|
reason: 'Task #98 - Discord channel automation'
|
||||||
|
});
|
||||||
|
stats.forumsCreated++;
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Create welcome post
|
||||||
|
console.log(` Creating welcome post...`);
|
||||||
|
await forum.threads.create({
|
||||||
|
name: server.welcomeTitle,
|
||||||
|
message: { content: server.welcomeBody },
|
||||||
|
reason: 'Task #98 - Server welcome post'
|
||||||
|
});
|
||||||
|
stats.welcomePostsCreated++;
|
||||||
|
await sleep(500);
|
||||||
|
} else {
|
||||||
|
stats.forumsCreated++;
|
||||||
|
stats.welcomePostsCreated++;
|
||||||
|
}
|
||||||
|
console.log(` ✅ Done`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PHASE 3: Create full setup for 10 new servers
|
||||||
|
// ========================================================================
|
||||||
|
console.log('');
|
||||||
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
console.log('PHASE 3: Create categories + channels for 10 new servers');
|
||||||
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
|
||||||
|
for (const server of NEW_SERVERS) {
|
||||||
|
console.log(`\n 📦 ${server.name}`);
|
||||||
|
|
||||||
|
const serverRole = findRole(guild, server.roleName);
|
||||||
|
if (!serverRole) {
|
||||||
|
console.log(` ⚠️ Role not found: ${server.roleName}, skipping`);
|
||||||
|
stats.errors.push(`Role not found: ${server.roleName}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if category already exists
|
||||||
|
let category = findCategory(guild, server.categoryName);
|
||||||
|
if (category) {
|
||||||
|
console.log(` ✓ Category already exists: ${category.name}`);
|
||||||
|
} else {
|
||||||
|
console.log(` Creating category: ${server.categoryName}`);
|
||||||
|
if (!DRY_RUN) {
|
||||||
|
category = await guild.channels.create({
|
||||||
|
name: server.categoryName,
|
||||||
|
type: ChannelType.GuildCategory,
|
||||||
|
permissionOverwrites: buildPermissionOverwrites(guild, serverRole),
|
||||||
|
reason: 'Task #98 - Discord channel automation'
|
||||||
|
});
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
stats.categoriesCreated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DRY_RUN && category) {
|
||||||
|
// Create chat channel
|
||||||
|
console.log(` Creating: ${server.chatName}`);
|
||||||
|
await guild.channels.create({
|
||||||
|
name: server.chatName,
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
parent: category.id,
|
||||||
|
topic: `General chat for ${server.name}`,
|
||||||
|
reason: 'Task #98 - Discord channel automation'
|
||||||
|
});
|
||||||
|
stats.textChannelsCreated++;
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Create in-game channel
|
||||||
|
console.log(` Creating: ${server.inGameName}`);
|
||||||
|
await guild.channels.create({
|
||||||
|
name: server.inGameName,
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
parent: category.id,
|
||||||
|
topic: `In-game chat bridge for ${server.name}`,
|
||||||
|
reason: 'Task #98 - Discord channel automation'
|
||||||
|
});
|
||||||
|
stats.textChannelsCreated++;
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Create voice channel
|
||||||
|
console.log(` Creating: ${server.voiceName}`);
|
||||||
|
await guild.channels.create({
|
||||||
|
name: server.voiceName,
|
||||||
|
type: ChannelType.GuildVoice,
|
||||||
|
parent: category.id,
|
||||||
|
reason: 'Task #98 - Discord channel automation'
|
||||||
|
});
|
||||||
|
stats.voiceChannelsCreated++;
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Create forum
|
||||||
|
console.log(` Creating: ${server.forumName}`);
|
||||||
|
const forum = await guild.channels.create({
|
||||||
|
name: server.forumName,
|
||||||
|
type: ChannelType.GuildForum,
|
||||||
|
parent: category.id,
|
||||||
|
topic: `Discussion forum for ${server.name}`,
|
||||||
|
availableTags: STANDARD_FORUM_TAGS.map(tag => ({
|
||||||
|
name: tag.name,
|
||||||
|
emoji: tag.emoji ? { name: tag.emoji } : null
|
||||||
|
})),
|
||||||
|
reason: 'Task #98 - Discord channel automation'
|
||||||
|
});
|
||||||
|
stats.forumsCreated++;
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Create welcome post
|
||||||
|
console.log(` Creating welcome post...`);
|
||||||
|
await forum.threads.create({
|
||||||
|
name: server.welcomeTitle,
|
||||||
|
message: { content: server.welcomeBody },
|
||||||
|
reason: 'Task #98 - Server welcome post'
|
||||||
|
});
|
||||||
|
stats.welcomePostsCreated++;
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
stats.permissionsApplied++;
|
||||||
|
} else if (DRY_RUN) {
|
||||||
|
stats.textChannelsCreated += 2;
|
||||||
|
stats.voiceChannelsCreated++;
|
||||||
|
stats.forumsCreated++;
|
||||||
|
stats.welcomePostsCreated++;
|
||||||
|
stats.permissionsApplied++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✅ Done`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PHASE 4: Create Archive category
|
||||||
|
// ========================================================================
|
||||||
|
console.log('');
|
||||||
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
console.log('PHASE 4: Create Archive category');
|
||||||
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
|
||||||
|
const existingArchive = guild.channels.cache.find(
|
||||||
|
ch => ch.type === ChannelType.GuildCategory && ch.name.includes('Archive')
|
||||||
|
);
|
||||||
|
if (existingArchive) {
|
||||||
|
console.log(` ✓ Archive category already exists: ${existingArchive.name}`);
|
||||||
|
} else {
|
||||||
|
console.log(' Creating: 📦 Archive');
|
||||||
|
if (!DRY_RUN) {
|
||||||
|
const staff = findRole(guild, 'Staff');
|
||||||
|
const moderator = findRole(guild, '🛡️ Moderator');
|
||||||
|
const wizard = findRole(guild, '👑 The Wizard');
|
||||||
|
const emissary = findRole(guild, '💎 The Emissary');
|
||||||
|
const catalyst = findRole(guild, '✨ The Catalyst');
|
||||||
|
|
||||||
|
const archiveOverwrites = [
|
||||||
|
{ id: guild.roles.everyone.id, deny: [PermissionFlagsBits.ViewChannel] }
|
||||||
|
];
|
||||||
|
|
||||||
|
[staff, moderator, wizard, emissary, catalyst].filter(Boolean).forEach(role => {
|
||||||
|
archiveOverwrites.push({
|
||||||
|
id: role.id,
|
||||||
|
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await guild.channels.create({
|
||||||
|
name: '📦 Archive',
|
||||||
|
type: ChannelType.GuildCategory,
|
||||||
|
permissionOverwrites: archiveOverwrites,
|
||||||
|
position: 999, // Put at bottom
|
||||||
|
reason: 'Task #98 - Archive category for retired servers'
|
||||||
|
});
|
||||||
|
stats.categoriesCreated++;
|
||||||
|
} else {
|
||||||
|
stats.categoriesCreated++;
|
||||||
|
}
|
||||||
|
console.log(' ✅ Done');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// SUMMARY
|
||||||
|
// ========================================================================
|
||||||
|
console.log('');
|
||||||
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
console.log('SUMMARY');
|
||||||
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
console.log(` Mode: ${DRY_RUN ? 'DRY RUN' : 'LIVE'}`);
|
||||||
|
console.log(` Categories created: ${stats.categoriesCreated}`);
|
||||||
|
console.log(` Categories renamed: ${stats.categoriesRenamed}`);
|
||||||
|
console.log(` Forums created: ${stats.forumsCreated}`);
|
||||||
|
console.log(` Text channels created: ${stats.textChannelsCreated}`);
|
||||||
|
console.log(` Voice channels created: ${stats.voiceChannelsCreated}`);
|
||||||
|
console.log(` Welcome posts created: ${stats.welcomePostsCreated}`);
|
||||||
|
console.log(` Permission sets applied: ${stats.permissionsApplied}`);
|
||||||
|
|
||||||
|
if (stats.errors.length > 0) {
|
||||||
|
console.log('');
|
||||||
|
console.log(' ⚠️ Errors:');
|
||||||
|
stats.errors.forEach(e => console.log(` - ${e}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
if (DRY_RUN) {
|
||||||
|
console.log('🔍 This was a DRY RUN. No changes were made.');
|
||||||
|
console.log(' Set DRY_RUN = false to execute for real.');
|
||||||
|
} else {
|
||||||
|
console.log('✅ All channels created successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('');
|
||||||
|
console.error('❌ FATAL ERROR:', error.message);
|
||||||
|
if (error.rawError) {
|
||||||
|
console.error(' Raw:', JSON.stringify(error.rawError, null, 2));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.destroy();
|
||||||
|
console.log('');
|
||||||
|
console.log('👋 Disconnected from Discord.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
338
services/arbiter-3.0/scripts/discord-channel-rename.js
Normal file
338
services/arbiter-3.0/scripts/discord-channel-rename.js
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Discord Channel Rename Script
|
||||||
|
*
|
||||||
|
* Renames existing server channels to match consistent naming convention
|
||||||
|
* based on Pterodactyl server names.
|
||||||
|
*
|
||||||
|
* Expected naming:
|
||||||
|
* Category: 🎮 {Server Name}
|
||||||
|
* Text: {base-name}-chat
|
||||||
|
* Text: {base-name}-in-game
|
||||||
|
* Forum: {base-name}-forum
|
||||||
|
* Voice: {Server Name}
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* DRY_RUN=true node discord-channel-rename.js # Preview changes
|
||||||
|
* DRY_RUN=false node discord-channel-rename.js # Execute changes
|
||||||
|
*
|
||||||
|
* Created: April 9, 2026
|
||||||
|
* Chronicler: #74
|
||||||
|
* Principle: "Make the plan. Execute the plan. Expect the plan to go off the rails."
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||||
|
const { Client, GatewayIntentBits, ChannelType } = require('discord.js');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONFIGURATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const DRY_RUN = process.env.DRY_RUN !== 'false'; // Default to dry run
|
||||||
|
const RATE_LIMIT_DELAY = 1500; // 1.5 seconds between operations
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HELPERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert server name to base channel name
|
||||||
|
* "Homestead - A Cozy Survival Experience" → "homestead"
|
||||||
|
* "All The Mons (Private) - TX" → "all-the-mons"
|
||||||
|
* "Stoneblock 4" → "stoneblock-4"
|
||||||
|
*/
|
||||||
|
function toBaseName(serverName) {
|
||||||
|
return serverName
|
||||||
|
.split(' - ')[0] // Take part before " - " subtitle
|
||||||
|
.replace(/\s*\([^)]*\)\s*/g, '') // Remove parentheticals
|
||||||
|
.replace(/\s*\[[^\]]*\]\s*/g, '') // Remove brackets like [Dungeons & Dragons]
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s]/g, '') // Remove special chars except spaces
|
||||||
|
.replace(/\s+/g, '-') // Spaces to hyphens
|
||||||
|
.replace(/-+/g, '-') // Multiple hyphens to single
|
||||||
|
.replace(/^-|-$/g, '') // Trim leading/trailing hyphens
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert server name to voice channel display name
|
||||||
|
* Same as base but keeps proper case and removes subtitles/parentheticals
|
||||||
|
*/
|
||||||
|
function toVoiceName(serverName) {
|
||||||
|
return serverName
|
||||||
|
.split(' - ')[0]
|
||||||
|
.replace(/\s*\([^)]*\)\s*/g, '')
|
||||||
|
.replace(/\s*\[[^\]]*\]\s*/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Minecraft servers from Pterodactyl
|
||||||
|
*/
|
||||||
|
async function fetchPterodactylServers() {
|
||||||
|
const PANEL_URL = process.env.PANEL_URL;
|
||||||
|
const API_KEY = process.env.PANEL_APPLICATION_KEY;
|
||||||
|
const NEST_IDS = (process.env.MINECRAFT_NEST_IDS || '1,5').split(',').map(n => parseInt(n.trim()));
|
||||||
|
|
||||||
|
const response = await fetch(`${PANEL_URL}/api/application/servers?per_page=100`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${API_KEY}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Pterodactyl API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Filter to Minecraft servers only
|
||||||
|
return data.data
|
||||||
|
.filter(s => NEST_IDS.includes(s.attributes.nest))
|
||||||
|
.map(s => ({
|
||||||
|
name: s.attributes.name,
|
||||||
|
identifier: s.attributes.identifier,
|
||||||
|
baseName: toBaseName(s.attributes.name),
|
||||||
|
voiceName: toVoiceName(s.attributes.name)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the best matching Discord category for a server
|
||||||
|
*/
|
||||||
|
function findCategoryForServer(server, categories) {
|
||||||
|
const serverBase = server.baseName;
|
||||||
|
const serverVoice = server.voiceName.toLowerCase();
|
||||||
|
|
||||||
|
for (const cat of categories) {
|
||||||
|
const catName = cat.name.replace(/^🎮\s*/, '').toLowerCase();
|
||||||
|
const catBase = toBaseName(cat.name);
|
||||||
|
|
||||||
|
// Exact match on base name
|
||||||
|
if (catBase === serverBase) return cat;
|
||||||
|
|
||||||
|
// Category contains server voice name
|
||||||
|
if (catName.includes(serverVoice)) return cat;
|
||||||
|
|
||||||
|
// Server voice name contains category name
|
||||||
|
if (serverVoice.includes(catBase)) return cat;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine what a channel should be renamed to based on its type
|
||||||
|
*/
|
||||||
|
function getExpectedChannelName(channel, server) {
|
||||||
|
const baseName = server.baseName;
|
||||||
|
const voiceName = server.voiceName;
|
||||||
|
const currentName = channel.name.toLowerCase();
|
||||||
|
|
||||||
|
switch (channel.type) {
|
||||||
|
case ChannelType.GuildVoice:
|
||||||
|
return voiceName;
|
||||||
|
|
||||||
|
case ChannelType.GuildForum:
|
||||||
|
return `${baseName}-forum`;
|
||||||
|
|
||||||
|
case ChannelType.GuildText:
|
||||||
|
// Determine if this is chat or in-game based on current name
|
||||||
|
if (currentName.includes('in-game') || currentName.includes('ingame')) {
|
||||||
|
return `${baseName}-in-game`;
|
||||||
|
} else if (currentName.includes('chat') || currentName.includes('general')) {
|
||||||
|
return `${baseName}-chat`;
|
||||||
|
} else {
|
||||||
|
// Default text channel to chat
|
||||||
|
return `${baseName}-chat`;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null; // Don't rename unknown types
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MAIN
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('');
|
||||||
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
console.log('🔧 Discord Channel Rename Script');
|
||||||
|
console.log(` Mode: ${DRY_RUN ? '🔍 DRY RUN (no changes)' : '⚡ LIVE (will rename channels)'}`);
|
||||||
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Initialize Discord client
|
||||||
|
const client = new Client({
|
||||||
|
intents: [GatewayIntentBits.Guilds]
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Login
|
||||||
|
console.log('📡 Connecting to Discord...');
|
||||||
|
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||||
|
await new Promise(resolve => client.once('ready', resolve));
|
||||||
|
console.log(` Logged in as ${client.user.tag}`);
|
||||||
|
|
||||||
|
// Get guild
|
||||||
|
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||||
|
if (!guild) {
|
||||||
|
throw new Error(`Guild not found: ${process.env.GUILD_ID}`);
|
||||||
|
}
|
||||||
|
console.log(` Guild: ${guild.name}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Fetch Pterodactyl servers
|
||||||
|
console.log('📋 Fetching Pterodactyl servers...');
|
||||||
|
const servers = await fetchPterodactylServers();
|
||||||
|
console.log(` Found ${servers.length} Minecraft servers`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Get all 🎮 categories
|
||||||
|
const categories = guild.channels.cache
|
||||||
|
.filter(ch => ch.type === ChannelType.GuildCategory && ch.name.includes('🎮'))
|
||||||
|
.map(ch => ch);
|
||||||
|
console.log(` Found ${categories.length} game categories in Discord`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const stats = {
|
||||||
|
matched: 0,
|
||||||
|
unmatched: [],
|
||||||
|
renames: [],
|
||||||
|
alreadyCorrect: 0,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process each server
|
||||||
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
console.log('PROCESSING SERVERS');
|
||||||
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
console.log(`\n📦 ${server.name}`);
|
||||||
|
console.log(` Base: ${server.baseName} | Voice: ${server.voiceName}`);
|
||||||
|
|
||||||
|
// Find matching category
|
||||||
|
const category = findCategoryForServer(server, categories);
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
console.log(` ⚠️ No matching category found`);
|
||||||
|
stats.unmatched.push(server.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` 📁 Matched to: ${category.name}`);
|
||||||
|
stats.matched++;
|
||||||
|
|
||||||
|
// Get children of this category
|
||||||
|
const children = guild.channels.cache.filter(ch => ch.parentId === category.id);
|
||||||
|
|
||||||
|
// Check each child channel
|
||||||
|
for (const [, channel] of children) {
|
||||||
|
const expectedName = getExpectedChannelName(channel, server);
|
||||||
|
|
||||||
|
if (!expectedName) {
|
||||||
|
console.log(` ⏭️ Skipping ${channel.name} (unknown type: ${channel.type})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel.name === expectedName) {
|
||||||
|
console.log(` ✓ ${channel.name} (already correct)`);
|
||||||
|
stats.alreadyCorrect++;
|
||||||
|
} else {
|
||||||
|
console.log(` 🔄 ${channel.name} → ${expectedName}`);
|
||||||
|
stats.renames.push({
|
||||||
|
from: channel.name,
|
||||||
|
to: expectedName,
|
||||||
|
channelId: channel.id,
|
||||||
|
type: ChannelType[channel.type]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!DRY_RUN) {
|
||||||
|
try {
|
||||||
|
await channel.setName(expectedName, 'Discord channel rename script - consistent naming');
|
||||||
|
console.log(` ✅ Renamed!`);
|
||||||
|
await sleep(RATE_LIMIT_DELAY);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ❌ Error: ${err.message}`);
|
||||||
|
stats.errors.push({ channel: channel.name, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if category itself needs renaming
|
||||||
|
const expectedCatName = `🎮 ${server.voiceName}`;
|
||||||
|
if (category.name !== expectedCatName) {
|
||||||
|
console.log(` 🔄 Category: ${category.name} → ${expectedCatName}`);
|
||||||
|
stats.renames.push({
|
||||||
|
from: category.name,
|
||||||
|
to: expectedCatName,
|
||||||
|
channelId: category.id,
|
||||||
|
type: 'Category'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!DRY_RUN) {
|
||||||
|
try {
|
||||||
|
await category.setName(expectedCatName, 'Discord channel rename script - consistent naming');
|
||||||
|
console.log(` ✅ Renamed!`);
|
||||||
|
await sleep(RATE_LIMIT_DELAY);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ❌ Error: ${err.message}`);
|
||||||
|
stats.errors.push({ channel: category.name, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log('');
|
||||||
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
console.log('SUMMARY');
|
||||||
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
console.log(` Mode: ${DRY_RUN ? 'DRY RUN' : 'LIVE'}`);
|
||||||
|
console.log(` Servers matched: ${stats.matched}`);
|
||||||
|
console.log(` Already correct: ${stats.alreadyCorrect}`);
|
||||||
|
console.log(` Renames ${DRY_RUN ? 'needed' : 'executed'}: ${stats.renames.length}`);
|
||||||
|
|
||||||
|
if (stats.unmatched.length > 0) {
|
||||||
|
console.log('');
|
||||||
|
console.log(' ⚠️ Unmatched servers (no Discord category found):');
|
||||||
|
stats.unmatched.forEach(s => console.log(` - ${s}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.errors.length > 0) {
|
||||||
|
console.log('');
|
||||||
|
console.log(' ❌ Errors:');
|
||||||
|
stats.errors.forEach(e => console.log(` - ${e.channel}: ${e.error}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DRY_RUN && stats.renames.length > 0) {
|
||||||
|
console.log('');
|
||||||
|
console.log(' 📝 Pending renames:');
|
||||||
|
stats.renames.forEach(r => console.log(` ${r.type}: ${r.from} → ${r.to}`));
|
||||||
|
console.log('');
|
||||||
|
console.log(' Run with DRY_RUN=false to execute these changes.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('');
|
||||||
|
console.error('❌ FATAL ERROR:', error.message);
|
||||||
|
if (error.stack) {
|
||||||
|
console.error(error.stack);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.destroy();
|
||||||
|
console.log('👋 Disconnected from Discord.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
812
services/arbiter-3.0/scripts/discord-channel-setup.js
Normal file
812
services/arbiter-3.0/scripts/discord-channel-setup.js
Normal file
@@ -0,0 +1,812 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Discord Channel Setup — Full Production Script
|
||||||
|
* Task #98: Discord Channel Automation
|
||||||
|
*
|
||||||
|
* Creates 46 channels across 15 server categories:
|
||||||
|
* - 5 existing servers: Add forum only
|
||||||
|
* - 10 new servers: Create category + chat + in-game + forum + voice
|
||||||
|
* - 1 archive category (staff only)
|
||||||
|
*
|
||||||
|
* Created: April 8, 2026
|
||||||
|
* Chronicler: #71
|
||||||
|
* Spec: docs/tasks/task-098-discord-channel-automation/forum-content-spec.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||||
|
const { Client, GatewayIntentBits, ChannelType, PermissionFlagsBits } = require('discord.js');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONFIGURATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const DRY_RUN = false; // Set to true to preview without creating
|
||||||
|
|
||||||
|
// Standard forum tags for all server forums
|
||||||
|
const STANDARD_FORUM_TAGS = [
|
||||||
|
{ name: 'Builds', emoji: '🏗️' },
|
||||||
|
{ name: 'Help', emoji: '❓' },
|
||||||
|
{ name: 'Suggestion', emoji: '💡' },
|
||||||
|
{ name: 'Bug Report', emoji: '🐛' },
|
||||||
|
{ name: 'Achievement', emoji: '🎉' },
|
||||||
|
{ name: 'Guide', emoji: '📖' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVER DEFINITIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Existing 5 servers — have categories, need forums only
|
||||||
|
const EXISTING_SERVERS = [
|
||||||
|
{
|
||||||
|
name: 'Stoneblock 4',
|
||||||
|
categoryName: 'Stoneblock 4', // Current name (will add 🎮 prefix)
|
||||||
|
roleName: 'Stoneblock 4',
|
||||||
|
welcomeTitle: 'Welcome to Stoneblock 4!',
|
||||||
|
welcomeBody: `🪨 **The stone remembers.**
|
||||||
|
|
||||||
|
You've entered the void — an endless expanse of stone hiding ancient Vaults, mysterious Echoes, and the legendary World Engine. Dig deep, automate everything, and never forget: in Stoneblock, even chickens are sacred.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your underground empires
|
||||||
|
- ❓ Ask for help with progression
|
||||||
|
- 💡 Suggest server improvements
|
||||||
|
- 🎉 Celebrate your victories
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Show Us Your Starting Cave!**
|
||||||
|
|
||||||
|
Post a screenshot of your first base setup. Did you pick the Lush Cave? The Darkness? Show us where your journey began!
|
||||||
|
|
||||||
|
React with 🔥 for Fire Path or ❄️ for Frost Path!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Society: Sunlit Valley',
|
||||||
|
categoryName: 'Society: Sunlit Valley',
|
||||||
|
roleName: 'Society: Sunlit Valley',
|
||||||
|
welcomeTitle: 'Welcome to Sunlit Valley!',
|
||||||
|
welcomeBody: `🌻 **A Stardew Valley experience in Minecraft.**
|
||||||
|
|
||||||
|
Welcome to the valley, farmer! Grow seasonal crops, raise animals, dive into the Skull Cavern for Iridium, and build the coziest farm this side of the blocky world. Money makes the world go round — so get shipping!
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Show off your farm layouts
|
||||||
|
- ❓ Ask about crop rotations and bundles
|
||||||
|
- 💡 Suggest new farming features
|
||||||
|
- 🎉 Share your biggest hauls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Dream Farm Name!**
|
||||||
|
|
||||||
|
What did you name your farm? Post it along with a screenshot of your farmhouse! Bonus points for creative theming.
|
||||||
|
|
||||||
|
React with 🌾 for farming focus or ⛏️ for mining focus!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'All the Mods 10: To the Sky',
|
||||||
|
categoryName: 'All the Mods 10: To the Sky',
|
||||||
|
roleName: 'All The Mods: To the Sky',
|
||||||
|
welcomeTitle: 'Welcome to ATM10: To the Sky!',
|
||||||
|
welcomeBody: `☁️ **Start with a tree. Build an empire in the void.**
|
||||||
|
|
||||||
|
You've got nothing but a single tree and infinite ambition. Sieve your way to resources, automate your way to power, and craft the legendary ATM Star. This is skyblock evolved — 500+ mods of pure vertical progression.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your sky islands
|
||||||
|
- ❓ Ask about automation setups
|
||||||
|
- 💡 Suggest efficiency improvements
|
||||||
|
- 🎉 Celebrate progression milestones
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Day One Screenshot!**
|
||||||
|
|
||||||
|
Show us your island after your first play session. Tiny platform? Sprawling network? We want to see your start!
|
||||||
|
|
||||||
|
React with 🔥 for Fire Path or ❄️ for Frost Path!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'All the Mons',
|
||||||
|
categoryName: 'All the Mons',
|
||||||
|
roleName: 'All The Mons',
|
||||||
|
welcomeTitle: 'Welcome to All the Mons!',
|
||||||
|
welcomeBody: `🐾 **All the Mods meets Cobblemon. Gotta catch AND automate 'em all.**
|
||||||
|
|
||||||
|
The ultimate crossover — 500+ tech and magic mods collide with Pokémon. Build factories, cast spells, AND catch 'em all. Custom Pokéball recipes use modded materials, so your automation skills directly power your trainer journey.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Show off your bases and Pokémon pastures
|
||||||
|
- ❓ Ask about spawn locations and evolution
|
||||||
|
- 💡 Suggest Pokémon-related improvements
|
||||||
|
- 🎉 Share shiny catches and team builds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Starter Trio!**
|
||||||
|
|
||||||
|
Post your first three Pokémon! Did you go classic starters or catch wild ones? Show us your team!
|
||||||
|
|
||||||
|
React with 🔥 for Fire types or ❄️ for Ice/Water types!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mythcraft 5',
|
||||||
|
categoryName: 'Mythcraft 5',
|
||||||
|
roleName: 'Mythcraft 5',
|
||||||
|
welcomeTitle: 'Welcome to Mythcraft 5!',
|
||||||
|
welcomeBody: `⚔️ **Magic. Alchemy. Technology. Adventure.**
|
||||||
|
|
||||||
|
Over 1,000 structures await exploration. A custom questline guides your progression through dungeons, fortresses, and strange dimensions. Master weapons AND spells. Unlock skills. Become legend.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your bases and discoveries
|
||||||
|
- ❓ Ask for help with progression and bosses
|
||||||
|
- 💡 Suggest adventure improvements
|
||||||
|
- 🎉 Celebrate boss kills and rare loot
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your First Boss Kill!**
|
||||||
|
|
||||||
|
What was the first boss you took down? Share the screenshot and the story!
|
||||||
|
|
||||||
|
React with ⚔️ for combat focus or 🔮 for magic focus!`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// New 10 servers — need category + all channels
|
||||||
|
const NEW_SERVERS = [
|
||||||
|
{
|
||||||
|
name: 'Beyond Depth',
|
||||||
|
roleName: 'Beyond Depth',
|
||||||
|
welcomeTitle: 'Welcome to Beyond!',
|
||||||
|
welcomeBody: `🐉 **Push the limits. Go beyond.**
|
||||||
|
|
||||||
|
Whether you're diving into the depths or ascending to new heights, the Beyond series challenges you to master progression and conquer the unknown.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your progress
|
||||||
|
- ❓ Ask for help with challenges
|
||||||
|
- 💡 Suggest improvements
|
||||||
|
- 🎉 Celebrate breakthroughs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Biggest Challenge So Far!**
|
||||||
|
|
||||||
|
What's been the hardest part? Share your struggles and triumphs!
|
||||||
|
|
||||||
|
React with ⬇️ for Depth or ⬆️ for Ascension!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Beyond Ascension',
|
||||||
|
roleName: 'Beyond Ascension',
|
||||||
|
welcomeTitle: 'Welcome to Beyond!',
|
||||||
|
welcomeBody: `🐉 **Push the limits. Go beyond.**
|
||||||
|
|
||||||
|
Whether you're diving into the depths or ascending to new heights, the Beyond series challenges you to master progression and conquer the unknown.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your progress
|
||||||
|
- ❓ Ask for help with challenges
|
||||||
|
- 💡 Suggest improvements
|
||||||
|
- 🎉 Celebrate breakthroughs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Biggest Challenge So Far!**
|
||||||
|
|
||||||
|
What's been the hardest part? Share your struggles and triumphs!
|
||||||
|
|
||||||
|
React with ⬇️ for Depth or ⬆️ for Ascension!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Wold's Vaults",
|
||||||
|
roleName: "Wold's Vaults",
|
||||||
|
welcomeTitle: "Welcome to Wold's Vaults!",
|
||||||
|
welcomeBody: `🗄️ **Crack the vaults. Claim the treasure.**
|
||||||
|
|
||||||
|
A progression-focused pack centered around vault hunting. Gear up, dive in, and see what riches await those brave enough to face the challenges within.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your vault hauls
|
||||||
|
- ❓ Ask about vault strategies
|
||||||
|
- 💡 Suggest improvements
|
||||||
|
- 🎉 Celebrate legendary finds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Best Vault Haul!**
|
||||||
|
|
||||||
|
What's the best thing you've pulled from a vault? Show us!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Otherworld [D&D]',
|
||||||
|
roleName: 'Otherworld [Dungeons & Dragons]',
|
||||||
|
welcomeTitle: 'Welcome to Otherworld!',
|
||||||
|
welcomeBody: `🎲 **Roll for initiative in Minecraft.**
|
||||||
|
|
||||||
|
D&D-inspired adventures await. Character classes, dungeon crawling, and tabletop vibes brought to life in block form.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your characters and builds
|
||||||
|
- ❓ Ask about classes and progression
|
||||||
|
- 💡 Suggest adventure improvements
|
||||||
|
- 🎉 Share epic moments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Introduce Your Character!**
|
||||||
|
|
||||||
|
Name, class, backstory. Let's hear it!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'DeceasedCraft',
|
||||||
|
roleName: 'DeceasedCraft',
|
||||||
|
welcomeTitle: 'Welcome to DeceasedCraft!',
|
||||||
|
welcomeBody: `☠️ **Survive the apocalypse.**
|
||||||
|
|
||||||
|
The world has ended, but you haven't. Scavenge, survive, and maybe even thrive in a hostile world where death lurks around every corner.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your survival setups
|
||||||
|
- ❓ Ask about survival strategies
|
||||||
|
- 💡 Suggest improvements
|
||||||
|
- 🎉 Celebrate survival milestones
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Day 7 Screenshot!**
|
||||||
|
|
||||||
|
If you survived a week, show us your base!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Submerged 2',
|
||||||
|
roleName: 'Submerged 2',
|
||||||
|
welcomeTitle: 'Welcome to Submerged 2!',
|
||||||
|
welcomeBody: `🌊 **The depths await.**
|
||||||
|
|
||||||
|
Dive into an underwater adventure where the ocean is your home. Build aquatic bases, explore sunken ruins, and survive the pressure of the deep.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your underwater bases
|
||||||
|
- ❓ Ask about aquatic survival
|
||||||
|
- 💡 Suggest ocean improvements
|
||||||
|
- 🎉 Celebrate deep sea discoveries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your First Underwater Base!**
|
||||||
|
|
||||||
|
Show us where you set up shop beneath the waves!
|
||||||
|
|
||||||
|
React with 🐠 for ocean life or 🏗️ for engineering focus!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sneak's Pirate Pack",
|
||||||
|
roleName: "Sneak's Pirate Pack",
|
||||||
|
welcomeTitle: "Ahoy, Welcome to Sneak's Pirate Pack!",
|
||||||
|
welcomeBody: `🏴☠️ **Set sail for adventure!**
|
||||||
|
|
||||||
|
A pirate's life for thee! Build ships, explore the seas, find treasure, and live the swashbuckling dream. Just watch out for sea monsters...
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your ships and ports
|
||||||
|
- ❓ Ask about naval adventures
|
||||||
|
- 💡 Suggest pirate improvements
|
||||||
|
- 🎉 Show off your treasure hoards
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Ship!**
|
||||||
|
|
||||||
|
Every pirate needs a vessel. Show us your pride and joy!
|
||||||
|
|
||||||
|
React with ⚓ for sailors or 💀 for scallywags!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cottage Witch',
|
||||||
|
roleName: 'Cottage Witch',
|
||||||
|
welcomeTitle: 'Welcome to Cottage Witch!',
|
||||||
|
welcomeBody: `🧙 **Cozy vibes. Domestic magic. Witchy aesthetics.**
|
||||||
|
|
||||||
|
Cottage Witch emphasizes the magic in everyday things — cooking, crafting, decorating. Build your perfect witch's cabin, brew potions, cast spells with Ars Nouveau and Hexerei, and let Create automate your cozy life.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your witchy builds
|
||||||
|
- ❓ Ask about magic systems
|
||||||
|
- 💡 Suggest cozy improvements
|
||||||
|
- 🎉 Show off your collections
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Witch's Corner!**
|
||||||
|
|
||||||
|
Every witch needs a cozy corner. Show us your cauldron setup, potion station, or spell crafting area!
|
||||||
|
|
||||||
|
React with 🌙 for dark witch or 🌻 for cottage witch!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Farm Crossing 5',
|
||||||
|
roleName: 'Farm Crossing 5',
|
||||||
|
welcomeTitle: 'Welcome to Farm Crossing 5!',
|
||||||
|
welcomeBody: `🌾 **The coziest crossover.**
|
||||||
|
|
||||||
|
Animal Crossing vibes meet Minecraft farming. Relax, decorate, farm, and make friends with your animal neighbors.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your island/farm layouts
|
||||||
|
- ❓ Ask about villagers and farming
|
||||||
|
- 💡 Suggest cozy additions
|
||||||
|
- 🎉 Show off your collections
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Favorite Corner!**
|
||||||
|
|
||||||
|
Show us your coziest spot!`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Homestead',
|
||||||
|
roleName: 'Homestead',
|
||||||
|
welcomeTitle: 'Welcome to Homestead!',
|
||||||
|
welcomeBody: `🏠 **Build your dream. Live your peace.**
|
||||||
|
|
||||||
|
Homestead is all about cozy survival — building, farming, and creating your perfect world without the pressure. Take your time, make it beautiful, and enjoy the journey.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your homestead builds
|
||||||
|
- ❓ Ask about building techniques
|
||||||
|
- 💡 Suggest cozy additions
|
||||||
|
- 🎉 Show off your finished projects
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Front Door!**
|
||||||
|
|
||||||
|
Post a screenshot standing at your front door looking out. What does home look like?
|
||||||
|
|
||||||
|
React with 🏡 for cottage vibes or 🏰 for grand builds!`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Staff/Admin roles that get full access
|
||||||
|
const ADMIN_ROLES = [
|
||||||
|
'Staff',
|
||||||
|
'🛡️ Moderator',
|
||||||
|
'👑 The Wizard',
|
||||||
|
'💎 The Emissary',
|
||||||
|
'✨ The Catalyst'
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function slugify(name) {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.substring(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MAIN SCRIPT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🎮 Discord Channel Setup — Full Production Script');
|
||||||
|
console.log('=================================================');
|
||||||
|
console.log(`Mode: ${DRY_RUN ? '🔍 DRY RUN (no changes)' : '⚡ LIVE (will create channels)'}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers]
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
categoriesCreated: 0,
|
||||||
|
categoriesRenamed: 0,
|
||||||
|
forumsCreated: 0,
|
||||||
|
textChannelsCreated: 0,
|
||||||
|
voiceChannelsCreated: 0,
|
||||||
|
welcomePostsCreated: 0,
|
||||||
|
permissionsApplied: 0,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Login
|
||||||
|
console.log('📡 Connecting to Discord...');
|
||||||
|
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||||
|
await new Promise(resolve => {
|
||||||
|
if (client.isReady()) resolve();
|
||||||
|
else client.once('ready', resolve);
|
||||||
|
});
|
||||||
|
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||||
|
|
||||||
|
// Get guild
|
||||||
|
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||||
|
if (!guild) throw new Error('Guild not found');
|
||||||
|
console.log(`✅ Found guild: ${guild.name}`);
|
||||||
|
|
||||||
|
// Fetch all data
|
||||||
|
await guild.channels.fetch();
|
||||||
|
await guild.roles.fetch();
|
||||||
|
await guild.members.fetch();
|
||||||
|
console.log(`📊 Current: ${guild.channels.cache.size} channels, ${guild.roles.cache.size} roles`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Build role lookup
|
||||||
|
const rolesByName = new Map();
|
||||||
|
guild.roles.cache.forEach(role => {
|
||||||
|
rolesByName.set(role.name, role);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get key roles
|
||||||
|
const everyoneRole = guild.roles.everyone;
|
||||||
|
const wandererRole = rolesByName.get('Wanderer');
|
||||||
|
|
||||||
|
if (!wandererRole) {
|
||||||
|
throw new Error('Wanderer role not found!');
|
||||||
|
}
|
||||||
|
console.log(`✅ Found Wanderer role: ${wandererRole.id}`);
|
||||||
|
|
||||||
|
// Get admin roles
|
||||||
|
const adminRoleIds = [];
|
||||||
|
for (const roleName of ADMIN_ROLES) {
|
||||||
|
const role = rolesByName.get(roleName);
|
||||||
|
if (role) {
|
||||||
|
adminRoleIds.push(role.id);
|
||||||
|
console.log(`✅ Found admin role: ${roleName} (${role.id})`);
|
||||||
|
} else {
|
||||||
|
console.log(`⚠️ Admin role not found: ${roleName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PHASE 1: Process existing 5 servers (add forum + rename category)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
console.log('═══════════════════════════════════════════════════════════');
|
||||||
|
console.log('PHASE 1: Existing 5 Servers — Add Forums + Rename Categories');
|
||||||
|
console.log('═══════════════════════════════════════════════════════════');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
for (const server of EXISTING_SERVERS) {
|
||||||
|
console.log(`📁 Processing: ${server.name}`);
|
||||||
|
|
||||||
|
// Find existing category
|
||||||
|
let category = guild.channels.cache.find(
|
||||||
|
ch => ch.type === ChannelType.GuildCategory &&
|
||||||
|
(ch.name === server.categoryName || ch.name === `🎮 ${server.categoryName}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
console.log(` ❌ Category not found: ${server.categoryName}`);
|
||||||
|
stats.errors.push(`Category not found: ${server.categoryName}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.log(` ✅ Found category: ${category.name} (${category.id})`);
|
||||||
|
|
||||||
|
// Find server role
|
||||||
|
const serverRole = rolesByName.get(server.roleName);
|
||||||
|
if (!serverRole) {
|
||||||
|
console.log(` ❌ Role not found: ${server.roleName}`);
|
||||||
|
stats.errors.push(`Role not found: ${server.roleName}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.log(` ✅ Found role: ${serverRole.name} (${serverRole.id})`);
|
||||||
|
|
||||||
|
if (DRY_RUN) {
|
||||||
|
console.log(` [DRY RUN] Would rename category to: 🎮 ${server.categoryName}`);
|
||||||
|
console.log(` [DRY RUN] Would create forum: ${slugify(server.name)}-forum`);
|
||||||
|
console.log(` [DRY RUN] Would post welcome message`);
|
||||||
|
console.log('');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename category if needed
|
||||||
|
if (!category.name.startsWith('🎮')) {
|
||||||
|
await category.setName(`🎮 ${server.categoryName}`);
|
||||||
|
console.log(` ✅ Renamed category to: 🎮 ${server.categoryName}`);
|
||||||
|
stats.categoriesRenamed++;
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if forum already exists
|
||||||
|
const existingForum = guild.channels.cache.find(
|
||||||
|
ch => ch.type === ChannelType.GuildForum && ch.parentId === category.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingForum) {
|
||||||
|
console.log(` ⚠️ Forum already exists: ${existingForum.name}`);
|
||||||
|
} else {
|
||||||
|
// Create forum
|
||||||
|
const forum = await guild.channels.create({
|
||||||
|
name: `${slugify(server.name)}-forum`,
|
||||||
|
type: ChannelType.GuildForum,
|
||||||
|
parent: category.id,
|
||||||
|
topic: `Discussion forum for ${server.name}`,
|
||||||
|
availableTags: STANDARD_FORUM_TAGS.map(tag => ({
|
||||||
|
name: tag.name,
|
||||||
|
emoji: { name: tag.emoji }
|
||||||
|
})),
|
||||||
|
reason: 'Task #98 Discord Channel Automation - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(` ✅ Created forum: ${forum.name} (${forum.id})`);
|
||||||
|
stats.forumsCreated++;
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Post welcome message
|
||||||
|
const welcomeThread = await forum.threads.create({
|
||||||
|
name: server.welcomeTitle,
|
||||||
|
message: { content: server.welcomeBody },
|
||||||
|
reason: 'Task #98 Welcome Post - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(` ✅ Posted welcome: ${welcomeThread.name}`);
|
||||||
|
stats.welcomePostsCreated++;
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply permissions to category
|
||||||
|
const permissionOverwrites = [
|
||||||
|
// @everyone: deny all
|
||||||
|
{ id: everyoneRole.id, deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] },
|
||||||
|
// Wanderer: view only
|
||||||
|
{ id: wandererRole.id, allow: [PermissionFlagsBits.ViewChannel], deny: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] },
|
||||||
|
// Server role: full access
|
||||||
|
{ id: serverRole.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory] },
|
||||||
|
// Admin roles: full access
|
||||||
|
...adminRoleIds.map(roleId => ({
|
||||||
|
id: roleId,
|
||||||
|
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory]
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
await category.permissionOverwrites.set(permissionOverwrites);
|
||||||
|
console.log(` ✅ Applied permissions to category`);
|
||||||
|
stats.permissionsApplied++;
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PHASE 2: Create 10 new servers (category + all channels)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
console.log('═══════════════════════════════════════════════════════════');
|
||||||
|
console.log('PHASE 2: New 10 Servers — Create Categories + All Channels');
|
||||||
|
console.log('═══════════════════════════════════════════════════════════');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
for (const server of NEW_SERVERS) {
|
||||||
|
console.log(`📁 Creating: ${server.name}`);
|
||||||
|
|
||||||
|
// Find server role
|
||||||
|
const serverRole = rolesByName.get(server.roleName);
|
||||||
|
if (!serverRole) {
|
||||||
|
console.log(` ❌ Role not found: ${server.roleName}`);
|
||||||
|
stats.errors.push(`Role not found: ${server.roleName}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.log(` ✅ Found role: ${serverRole.name} (${serverRole.id})`);
|
||||||
|
|
||||||
|
// Check if category already exists
|
||||||
|
let category = guild.channels.cache.find(
|
||||||
|
ch => ch.type === ChannelType.GuildCategory &&
|
||||||
|
(ch.name === server.name || ch.name === `🎮 ${server.name}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (DRY_RUN) {
|
||||||
|
console.log(` [DRY RUN] Would create category: 🎮 ${server.name}`);
|
||||||
|
console.log(` [DRY RUN] Would create: ${slugify(server.name)}-chat`);
|
||||||
|
console.log(` [DRY RUN] Would create: ${slugify(server.name)}-in-game`);
|
||||||
|
console.log(` [DRY RUN] Would create: ${slugify(server.name)}-forum`);
|
||||||
|
console.log(` [DRY RUN] Would create voice: ${server.name}`);
|
||||||
|
console.log(` [DRY RUN] Would post welcome message`);
|
||||||
|
console.log('');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build permission overwrites
|
||||||
|
const permissionOverwrites = [
|
||||||
|
{ id: everyoneRole.id, deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] },
|
||||||
|
{ id: wandererRole.id, allow: [PermissionFlagsBits.ViewChannel], deny: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] },
|
||||||
|
{ id: serverRole.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory] },
|
||||||
|
...adminRoleIds.map(roleId => ({
|
||||||
|
id: roleId,
|
||||||
|
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory]
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
console.log(` ⚠️ Category already exists: ${category.name}`);
|
||||||
|
} else {
|
||||||
|
// Create category
|
||||||
|
category = await guild.channels.create({
|
||||||
|
name: `🎮 ${server.name}`,
|
||||||
|
type: ChannelType.GuildCategory,
|
||||||
|
permissionOverwrites,
|
||||||
|
reason: 'Task #98 Discord Channel Automation - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(` ✅ Created category: ${category.name} (${category.id})`);
|
||||||
|
stats.categoriesCreated++;
|
||||||
|
stats.permissionsApplied++;
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create chat channel
|
||||||
|
const chatExists = guild.channels.cache.find(
|
||||||
|
ch => ch.parentId === category.id && ch.name.includes('chat')
|
||||||
|
);
|
||||||
|
if (!chatExists) {
|
||||||
|
const chat = await guild.channels.create({
|
||||||
|
name: `${slugify(server.name)}-chat`,
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
parent: category.id,
|
||||||
|
topic: `General chat for ${server.name}`,
|
||||||
|
reason: 'Task #98 Discord Channel Automation - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(` ✅ Created: ${chat.name}`);
|
||||||
|
stats.textChannelsCreated++;
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create in-game channel
|
||||||
|
const ingameExists = guild.channels.cache.find(
|
||||||
|
ch => ch.parentId === category.id && ch.name.includes('in-game')
|
||||||
|
);
|
||||||
|
if (!ingameExists) {
|
||||||
|
const ingame = await guild.channels.create({
|
||||||
|
name: `${slugify(server.name)}-in-game`,
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
parent: category.id,
|
||||||
|
topic: `In-game chat bridge for ${server.name}`,
|
||||||
|
reason: 'Task #98 Discord Channel Automation - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(` ✅ Created: ${ingame.name}`);
|
||||||
|
stats.textChannelsCreated++;
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create forum
|
||||||
|
const forumExists = guild.channels.cache.find(
|
||||||
|
ch => ch.type === ChannelType.GuildForum && ch.parentId === category.id
|
||||||
|
);
|
||||||
|
if (!forumExists) {
|
||||||
|
const forum = await guild.channels.create({
|
||||||
|
name: `${slugify(server.name)}-forum`,
|
||||||
|
type: ChannelType.GuildForum,
|
||||||
|
parent: category.id,
|
||||||
|
topic: `Discussion forum for ${server.name}`,
|
||||||
|
availableTags: STANDARD_FORUM_TAGS.map(tag => ({
|
||||||
|
name: tag.name,
|
||||||
|
emoji: { name: tag.emoji }
|
||||||
|
})),
|
||||||
|
reason: 'Task #98 Discord Channel Automation - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(` ✅ Created forum: ${forum.name} (${forum.id})`);
|
||||||
|
stats.forumsCreated++;
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Post welcome message
|
||||||
|
const welcomeThread = await forum.threads.create({
|
||||||
|
name: server.welcomeTitle,
|
||||||
|
message: { content: server.welcomeBody },
|
||||||
|
reason: 'Task #98 Welcome Post - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(` ✅ Posted welcome: ${welcomeThread.name}`);
|
||||||
|
stats.welcomePostsCreated++;
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create voice channel
|
||||||
|
const voiceExists = guild.channels.cache.find(
|
||||||
|
ch => ch.type === ChannelType.GuildVoice && ch.parentId === category.id
|
||||||
|
);
|
||||||
|
if (!voiceExists) {
|
||||||
|
const voice = await guild.channels.create({
|
||||||
|
name: server.name,
|
||||||
|
type: ChannelType.GuildVoice,
|
||||||
|
parent: category.id,
|
||||||
|
reason: 'Task #98 Discord Channel Automation - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(` ✅ Created voice: ${voice.name}`);
|
||||||
|
stats.voiceChannelsCreated++;
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PHASE 3: Create Archive category
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
console.log('═══════════════════════════════════════════════════════════');
|
||||||
|
console.log('PHASE 3: Create Archive Category');
|
||||||
|
console.log('═══════════════════════════════════════════════════════════');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const archiveExists = guild.channels.cache.find(
|
||||||
|
ch => ch.type === ChannelType.GuildCategory && ch.name.includes('Archive')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (DRY_RUN) {
|
||||||
|
console.log('[DRY RUN] Would create: 📦 Archive (staff only)');
|
||||||
|
} else if (archiveExists) {
|
||||||
|
console.log(`⚠️ Archive category already exists: ${archiveExists.name}`);
|
||||||
|
} else {
|
||||||
|
const archivePerms = [
|
||||||
|
{ id: everyoneRole.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||||
|
...adminRoleIds.map(roleId => ({
|
||||||
|
id: roleId,
|
||||||
|
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
const archive = await guild.channels.create({
|
||||||
|
name: '📦 Archive',
|
||||||
|
type: ChannelType.GuildCategory,
|
||||||
|
permissionOverwrites: archivePerms,
|
||||||
|
reason: 'Task #98 Discord Channel Automation - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(`✅ Created: ${archive.name} (${archive.id})`);
|
||||||
|
stats.categoriesCreated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// SUMMARY
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('═══════════════════════════════════════════════════════════');
|
||||||
|
console.log('📊 SUMMARY');
|
||||||
|
console.log('═══════════════════════════════════════════════════════════');
|
||||||
|
console.log(`Categories created: ${stats.categoriesCreated}`);
|
||||||
|
console.log(`Categories renamed: ${stats.categoriesRenamed}`);
|
||||||
|
console.log(`Forums created: ${stats.forumsCreated}`);
|
||||||
|
console.log(`Text channels created: ${stats.textChannelsCreated}`);
|
||||||
|
console.log(`Voice channels created: ${stats.voiceChannelsCreated}`);
|
||||||
|
console.log(`Welcome posts created: ${stats.welcomePostsCreated}`);
|
||||||
|
console.log(`Permissions applied: ${stats.permissionsApplied}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
if (stats.errors.length > 0) {
|
||||||
|
console.log('⚠️ ERRORS:');
|
||||||
|
stats.errors.forEach(e => console.log(` - ${e}`));
|
||||||
|
} else {
|
||||||
|
console.log('✅ No errors!');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('🎉 Task #98 Discord Channel Automation — COMPLETE');
|
||||||
|
console.log('');
|
||||||
|
console.log('👀 Next steps:');
|
||||||
|
console.log(' 1. Check Discord to verify all channels');
|
||||||
|
console.log(' 2. Test permissions with a Wanderer account');
|
||||||
|
console.log(' 3. Test permissions with a subscriber account');
|
||||||
|
console.log(' 4. Revoke Arbiter admin permissions');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('');
|
||||||
|
console.error('❌ FATAL ERROR:', error.message);
|
||||||
|
console.error(error.stack);
|
||||||
|
} finally {
|
||||||
|
client.destroy();
|
||||||
|
console.log('');
|
||||||
|
console.log('👋 Disconnected from Discord.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
200
services/arbiter-3.0/scripts/discord-channel-test.js
Normal file
200
services/arbiter-3.0/scripts/discord-channel-test.js
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Discord Channel Creation Test Script
|
||||||
|
* Phase 1: Create ONE test category with ONE forum channel
|
||||||
|
*
|
||||||
|
* Purpose: Verify our channel creation approach works before running full script
|
||||||
|
*
|
||||||
|
* Created: April 8, 2026
|
||||||
|
* Chronicler: #71
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||||
|
const { Client, GatewayIntentBits, ChannelType, PermissionFlagsBits } = require('discord.js');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONFIGURATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const DRY_RUN = false; // Set to false to actually create channels
|
||||||
|
|
||||||
|
const TEST_CATEGORY_NAME = '🧪 Test Category';
|
||||||
|
const TEST_FORUM_NAME = 'test-forum';
|
||||||
|
const TEST_FORUM_TOPIC = 'Testing forum creation - safe to delete';
|
||||||
|
|
||||||
|
// Forum tags we'll use on all server forums
|
||||||
|
const STANDARD_FORUM_TAGS = [
|
||||||
|
{ name: 'Builds', emoji: '🏗️' },
|
||||||
|
{ name: 'Help', emoji: '❓' },
|
||||||
|
{ name: 'Suggestion', emoji: '💡' },
|
||||||
|
{ name: 'Bug Report', emoji: '🐛' },
|
||||||
|
{ name: 'Achievement', emoji: '🎉' },
|
||||||
|
{ name: 'Guide', emoji: '📖' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MAIN SCRIPT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔧 Discord Channel Creation Test');
|
||||||
|
console.log('================================');
|
||||||
|
console.log(`Mode: ${DRY_RUN ? '🔍 DRY RUN (no changes)' : '⚡ LIVE (will create channels)'}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Initialize client
|
||||||
|
const client = new Client({
|
||||||
|
intents: [GatewayIntentBits.Guilds]
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Login
|
||||||
|
console.log('📡 Connecting to Discord...');
|
||||||
|
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||||
|
|
||||||
|
// Wait for ready
|
||||||
|
await new Promise(resolve => {
|
||||||
|
if (client.isReady()) resolve();
|
||||||
|
else client.once('ready', resolve);
|
||||||
|
});
|
||||||
|
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||||
|
|
||||||
|
// Get guild
|
||||||
|
const guildId = process.env.GUILD_ID;
|
||||||
|
const guild = client.guilds.cache.get(guildId);
|
||||||
|
|
||||||
|
if (!guild) {
|
||||||
|
throw new Error(`Guild ${guildId} not found. Is the bot in the server?`);
|
||||||
|
}
|
||||||
|
console.log(`✅ Found guild: ${guild.name}`);
|
||||||
|
|
||||||
|
// Check bot permissions
|
||||||
|
const botMember = guild.members.cache.get(client.user.id);
|
||||||
|
if (!botMember) {
|
||||||
|
await guild.members.fetch(client.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = guild.members.cache.get(client.user.id)?.permissions;
|
||||||
|
console.log('');
|
||||||
|
console.log('🔐 Bot Permissions Check:');
|
||||||
|
console.log(` Manage Channels: ${permissions?.has(PermissionFlagsBits.ManageChannels) ? '✅' : '❌'}`);
|
||||||
|
console.log(` Manage Roles: ${permissions?.has(PermissionFlagsBits.ManageRoles) ? '✅' : '❌'}`);
|
||||||
|
console.log(` Send Messages: ${permissions?.has(PermissionFlagsBits.SendMessages) ? '✅' : '❌'}`);
|
||||||
|
console.log(` Create Public Threads: ${permissions?.has(PermissionFlagsBits.CreatePublicThreads) ? '✅' : '❌'}`);
|
||||||
|
|
||||||
|
if (!permissions?.has(PermissionFlagsBits.ManageChannels)) {
|
||||||
|
throw new Error('Bot lacks Manage Channels permission!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch existing channels
|
||||||
|
await guild.channels.fetch();
|
||||||
|
console.log('');
|
||||||
|
console.log(`📊 Current channel count: ${guild.channels.cache.size}`);
|
||||||
|
|
||||||
|
// Check if test category already exists
|
||||||
|
const existingCategory = guild.channels.cache.find(
|
||||||
|
ch => ch.type === ChannelType.GuildCategory && ch.name === TEST_CATEGORY_NAME
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCategory) {
|
||||||
|
console.log(`⚠️ Test category "${TEST_CATEGORY_NAME}" already exists (ID: ${existingCategory.id})`);
|
||||||
|
console.log(' Delete it manually if you want to re-run this test.');
|
||||||
|
|
||||||
|
// Check for forum in that category
|
||||||
|
const existingForum = guild.channels.cache.find(
|
||||||
|
ch => ch.type === ChannelType.GuildForum && ch.parentId === existingCategory.id
|
||||||
|
);
|
||||||
|
if (existingForum) {
|
||||||
|
console.log(` Forum "${existingForum.name}" exists in category (ID: ${existingForum.id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
client.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DRY_RUN) {
|
||||||
|
console.log('');
|
||||||
|
console.log('📋 DRY RUN - Would create:');
|
||||||
|
console.log(` 1. Category: "${TEST_CATEGORY_NAME}"`);
|
||||||
|
console.log(` 2. Forum: "${TEST_FORUM_NAME}" with ${STANDARD_FORUM_TAGS.length} tags`);
|
||||||
|
console.log('');
|
||||||
|
console.log('Set DRY_RUN = false to create these channels.');
|
||||||
|
client.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// LIVE MODE - CREATE CHANNELS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('🚀 Creating test channels...');
|
||||||
|
|
||||||
|
// Step 1: Create category
|
||||||
|
console.log(` Creating category: ${TEST_CATEGORY_NAME}`);
|
||||||
|
const category = await guild.channels.create({
|
||||||
|
name: TEST_CATEGORY_NAME,
|
||||||
|
type: ChannelType.GuildCategory,
|
||||||
|
reason: 'Test by Chronicler #71 - Discord channel automation'
|
||||||
|
});
|
||||||
|
console.log(` ✅ Category created: ${category.id}`);
|
||||||
|
|
||||||
|
// Step 2: Create forum channel
|
||||||
|
console.log(` Creating forum: ${TEST_FORUM_NAME}`);
|
||||||
|
const forum = await guild.channels.create({
|
||||||
|
name: TEST_FORUM_NAME,
|
||||||
|
type: ChannelType.GuildForum,
|
||||||
|
parent: category.id,
|
||||||
|
topic: TEST_FORUM_TOPIC,
|
||||||
|
availableTags: STANDARD_FORUM_TAGS.map(tag => ({
|
||||||
|
name: tag.name,
|
||||||
|
emoji: tag.emoji ? { name: tag.emoji } : null
|
||||||
|
})),
|
||||||
|
reason: 'Test by Chronicler #71 - Discord channel automation'
|
||||||
|
});
|
||||||
|
console.log(` ✅ Forum created: ${forum.id}`);
|
||||||
|
|
||||||
|
// Step 3: Create a test welcome post in the forum
|
||||||
|
console.log(' Creating welcome post in forum...');
|
||||||
|
const welcomeThread = await forum.threads.create({
|
||||||
|
name: '👋 Welcome to the Test Forum!',
|
||||||
|
message: {
|
||||||
|
content: `**This is a test welcome post.**\n\nIf you can see this, forum creation is working!\n\n🏗️ **Tags available:** ${STANDARD_FORUM_TAGS.map(t => t.name).join(', ')}\n\n*Created by Chronicler #71*`
|
||||||
|
},
|
||||||
|
reason: 'Test welcome post by Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(` ✅ Welcome post created: ${welcomeThread.id}`);
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log('');
|
||||||
|
console.log('✅ TEST COMPLETE!');
|
||||||
|
console.log('================');
|
||||||
|
console.log(`Category: ${category.name} (${category.id})`);
|
||||||
|
console.log(`Forum: ${forum.name} (${forum.id})`);
|
||||||
|
console.log(`Welcome Post: ${welcomeThread.name} (${welcomeThread.id})`);
|
||||||
|
console.log('');
|
||||||
|
console.log('👀 Check Discord to verify:');
|
||||||
|
console.log(' 1. Category appears with 🧪 emoji');
|
||||||
|
console.log(' 2. Forum is inside the category');
|
||||||
|
console.log(' 3. Forum has 6 tags (Builds, Help, Suggestion, Bug Report, Achievement, Guide)');
|
||||||
|
console.log(' 4. Welcome post is visible in the forum');
|
||||||
|
console.log('');
|
||||||
|
console.log('🗑️ When done testing, delete the category from Discord.');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('');
|
||||||
|
console.error('❌ ERROR:', error.message);
|
||||||
|
if (error.code) {
|
||||||
|
console.error(' Discord Error Code:', error.code);
|
||||||
|
}
|
||||||
|
if (error.rawError) {
|
||||||
|
console.error(' Raw Error:', JSON.stringify(error.rawError, null, 2));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.destroy();
|
||||||
|
console.log('');
|
||||||
|
console.log('👋 Disconnected from Discord.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
169
services/arbiter-3.0/scripts/fix-wolds-vaults-v2.js
Normal file
169
services/arbiter-3.0/scripts/fix-wolds-vaults-v2.js
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Quick Fix: Wold's Vaults
|
||||||
|
* Using role ID directly since the apostrophe character is weird
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||||
|
const { Client, GatewayIntentBits, ChannelType, PermissionFlagsBits } = require('discord.js');
|
||||||
|
|
||||||
|
const STANDARD_FORUM_TAGS = [
|
||||||
|
{ name: 'Builds', emoji: '🏗️' },
|
||||||
|
{ name: 'Help', emoji: '❓' },
|
||||||
|
{ name: 'Suggestion', emoji: '💡' },
|
||||||
|
{ name: 'Bug Report', emoji: '🐛' },
|
||||||
|
{ name: 'Achievement', emoji: '🎉' },
|
||||||
|
{ name: 'Guide', emoji: '📖' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Using role ID directly!
|
||||||
|
const WOLDS_VAULTS_ROLE_ID = '1491029373640376330';
|
||||||
|
|
||||||
|
const SERVER = {
|
||||||
|
name: "Wold's Vaults",
|
||||||
|
welcomeTitle: "Welcome to Wold's Vaults!",
|
||||||
|
welcomeBody: `🗄️ **Crack the vaults. Claim the treasure.**
|
||||||
|
|
||||||
|
A progression-focused pack centered around vault hunting. Gear up, dive in, and see what riches await those brave enough to face the challenges within.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your vault hauls
|
||||||
|
- ❓ Ask about vault strategies
|
||||||
|
- 💡 Suggest improvements
|
||||||
|
- 🎉 Celebrate legendary finds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Best Vault Haul!**
|
||||||
|
|
||||||
|
What's the best thing you've pulled from a vault? Show us!`
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADMIN_ROLES = ['Staff', '🛡️ Moderator', '👑 The Wizard', '💎 The Emissary', '✨ The Catalyst'];
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("🔧 Quick Fix: Wold's Vaults (using role ID)");
|
||||||
|
console.log('=============================================');
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers]
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||||
|
await new Promise(resolve => {
|
||||||
|
if (client.isReady()) resolve();
|
||||||
|
else client.once('ready', resolve);
|
||||||
|
});
|
||||||
|
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||||
|
|
||||||
|
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||||
|
await guild.channels.fetch();
|
||||||
|
await guild.roles.fetch();
|
||||||
|
|
||||||
|
// Get role by ID
|
||||||
|
const serverRole = guild.roles.cache.get(WOLDS_VAULTS_ROLE_ID);
|
||||||
|
if (!serverRole) {
|
||||||
|
console.log(`❌ Role ID not found: ${WOLDS_VAULTS_ROLE_ID}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`✅ Found role: ${serverRole.name} (${serverRole.id})`);
|
||||||
|
|
||||||
|
const everyoneRole = guild.roles.everyone;
|
||||||
|
const wandererRole = guild.roles.cache.find(r => r.name === 'Wanderer');
|
||||||
|
const adminRoleIds = ADMIN_ROLES.map(name => guild.roles.cache.find(r => r.name === name)?.id).filter(Boolean);
|
||||||
|
|
||||||
|
const permissionOverwrites = [
|
||||||
|
{ id: everyoneRole.id, deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] },
|
||||||
|
{ id: wandererRole.id, allow: [PermissionFlagsBits.ViewChannel], deny: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] },
|
||||||
|
{ id: serverRole.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory] },
|
||||||
|
...adminRoleIds.map(roleId => ({
|
||||||
|
id: roleId,
|
||||||
|
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory]
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create category
|
||||||
|
console.log(`Creating category: 🎮 ${SERVER.name}`);
|
||||||
|
const category = await guild.channels.create({
|
||||||
|
name: `🎮 ${SERVER.name}`,
|
||||||
|
type: ChannelType.GuildCategory,
|
||||||
|
permissionOverwrites,
|
||||||
|
reason: 'Task #98 Fix - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(`✅ Created category: ${category.id}`);
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Create chat
|
||||||
|
const chat = await guild.channels.create({
|
||||||
|
name: 'wolds-vaults-chat',
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
parent: category.id,
|
||||||
|
reason: 'Task #98 Fix - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(`✅ Created: ${chat.name}`);
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Create in-game
|
||||||
|
const ingame = await guild.channels.create({
|
||||||
|
name: 'wolds-vaults-in-game',
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
parent: category.id,
|
||||||
|
reason: 'Task #98 Fix - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(`✅ Created: ${ingame.name}`);
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Create forum
|
||||||
|
const forum = await guild.channels.create({
|
||||||
|
name: 'wolds-vaults-forum',
|
||||||
|
type: ChannelType.GuildForum,
|
||||||
|
parent: category.id,
|
||||||
|
availableTags: STANDARD_FORUM_TAGS.map(tag => ({ name: tag.name, emoji: { name: tag.emoji } })),
|
||||||
|
reason: 'Task #98 Fix - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(`✅ Created forum: ${forum.name}`);
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Welcome post
|
||||||
|
const welcomeThread = await forum.threads.create({
|
||||||
|
name: SERVER.welcomeTitle,
|
||||||
|
message: { content: SERVER.welcomeBody },
|
||||||
|
reason: 'Task #98 Fix - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(`✅ Posted welcome: ${welcomeThread.name}`);
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Create voice
|
||||||
|
const voice = await guild.channels.create({
|
||||||
|
name: "Wold's Vaults",
|
||||||
|
type: ChannelType.GuildVoice,
|
||||||
|
parent: category.id,
|
||||||
|
reason: 'Task #98 Fix - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(`✅ Created voice: ${voice.name}`);
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log("🎉 Wold's Vaults — COMPLETE!");
|
||||||
|
console.log('');
|
||||||
|
console.log('📊 FINAL TOTALS:');
|
||||||
|
console.log(' Categories: 11 (10 new + 1 archive)');
|
||||||
|
console.log(' Forums: 15');
|
||||||
|
console.log(' Text channels: 20');
|
||||||
|
console.log(' Voice channels: 10');
|
||||||
|
console.log(' Welcome posts: 15');
|
||||||
|
console.log(' Total new channels: 46 ✅');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ ERROR:', error.message);
|
||||||
|
console.error(error.stack);
|
||||||
|
} finally {
|
||||||
|
client.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
166
services/arbiter-3.0/scripts/fix-wolds-vaults.js
Normal file
166
services/arbiter-3.0/scripts/fix-wolds-vaults.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Quick Fix: Wold's Vaults
|
||||||
|
* The role uses a curly apostrophe ('), not straight (')
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||||
|
const { Client, GatewayIntentBits, ChannelType, PermissionFlagsBits } = require('discord.js');
|
||||||
|
|
||||||
|
const STANDARD_FORUM_TAGS = [
|
||||||
|
{ name: 'Builds', emoji: '🏗️' },
|
||||||
|
{ name: 'Help', emoji: '❓' },
|
||||||
|
{ name: 'Suggestion', emoji: '💡' },
|
||||||
|
{ name: 'Bug Report', emoji: '🐛' },
|
||||||
|
{ name: 'Achievement', emoji: '🎉' },
|
||||||
|
{ name: 'Guide', emoji: '📖' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const SERVER = {
|
||||||
|
name: "Wold's Vaults",
|
||||||
|
roleName: "Wold's Vaults", // Curly apostrophe!
|
||||||
|
welcomeTitle: "Welcome to Wold's Vaults!",
|
||||||
|
welcomeBody: `🗄️ **Crack the vaults. Claim the treasure.**
|
||||||
|
|
||||||
|
A progression-focused pack centered around vault hunting. Gear up, dive in, and see what riches await those brave enough to face the challenges within.
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your vault hauls
|
||||||
|
- ❓ Ask about vault strategies
|
||||||
|
- 💡 Suggest improvements
|
||||||
|
- 🎉 Celebrate legendary finds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Your Best Vault Haul!**
|
||||||
|
|
||||||
|
What's the best thing you've pulled from a vault? Show us!`
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADMIN_ROLES = ['Staff', '🛡️ Moderator', '👑 The Wizard', '💎 The Emissary', '✨ The Catalyst'];
|
||||||
|
|
||||||
|
function slugify(name) {
|
||||||
|
return name.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').substring(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("🔧 Quick Fix: Wold's Vaults");
|
||||||
|
console.log('============================');
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers]
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||||
|
await new Promise(resolve => {
|
||||||
|
if (client.isReady()) resolve();
|
||||||
|
else client.once('ready', resolve);
|
||||||
|
});
|
||||||
|
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||||
|
|
||||||
|
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||||
|
await guild.channels.fetch();
|
||||||
|
await guild.roles.fetch();
|
||||||
|
|
||||||
|
// Find role with curly apostrophe
|
||||||
|
const serverRole = guild.roles.cache.find(r => r.name === SERVER.roleName);
|
||||||
|
if (!serverRole) {
|
||||||
|
console.log(`❌ Role still not found: ${SERVER.roleName}`);
|
||||||
|
console.log('Available roles with "Wold":');
|
||||||
|
guild.roles.cache.filter(r => r.name.toLowerCase().includes('wold')).forEach(r => {
|
||||||
|
console.log(` "${r.name}" (${r.id})`);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`✅ Found role: ${serverRole.name} (${serverRole.id})`);
|
||||||
|
|
||||||
|
const everyoneRole = guild.roles.everyone;
|
||||||
|
const wandererRole = guild.roles.cache.find(r => r.name === 'Wanderer');
|
||||||
|
const adminRoleIds = ADMIN_ROLES.map(name => guild.roles.cache.find(r => r.name === name)?.id).filter(Boolean);
|
||||||
|
|
||||||
|
const permissionOverwrites = [
|
||||||
|
{ id: everyoneRole.id, deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] },
|
||||||
|
{ id: wandererRole.id, allow: [PermissionFlagsBits.ViewChannel], deny: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] },
|
||||||
|
{ id: serverRole.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory] },
|
||||||
|
...adminRoleIds.map(roleId => ({
|
||||||
|
id: roleId,
|
||||||
|
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory]
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create category
|
||||||
|
console.log(`Creating category: 🎮 ${SERVER.name}`);
|
||||||
|
const category = await guild.channels.create({
|
||||||
|
name: `🎮 ${SERVER.name}`,
|
||||||
|
type: ChannelType.GuildCategory,
|
||||||
|
permissionOverwrites,
|
||||||
|
reason: 'Task #98 Fix - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(`✅ Created category: ${category.id}`);
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Create chat
|
||||||
|
const chat = await guild.channels.create({
|
||||||
|
name: `${slugify(SERVER.name)}-chat`,
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
parent: category.id,
|
||||||
|
reason: 'Task #98 Fix - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(`✅ Created: ${chat.name}`);
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Create in-game
|
||||||
|
const ingame = await guild.channels.create({
|
||||||
|
name: `${slugify(SERVER.name)}-in-game`,
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
parent: category.id,
|
||||||
|
reason: 'Task #98 Fix - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(`✅ Created: ${ingame.name}`);
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Create forum
|
||||||
|
const forum = await guild.channels.create({
|
||||||
|
name: `${slugify(SERVER.name)}-forum`,
|
||||||
|
type: ChannelType.GuildForum,
|
||||||
|
parent: category.id,
|
||||||
|
availableTags: STANDARD_FORUM_TAGS.map(tag => ({ name: tag.name, emoji: { name: tag.emoji } })),
|
||||||
|
reason: 'Task #98 Fix - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(`✅ Created forum: ${forum.name}`);
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Welcome post
|
||||||
|
const welcomeThread = await forum.threads.create({
|
||||||
|
name: SERVER.welcomeTitle,
|
||||||
|
message: { content: SERVER.welcomeBody },
|
||||||
|
reason: 'Task #98 Fix - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(`✅ Posted welcome: ${welcomeThread.name}`);
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Create voice
|
||||||
|
const voice = await guild.channels.create({
|
||||||
|
name: SERVER.name,
|
||||||
|
type: ChannelType.GuildVoice,
|
||||||
|
parent: category.id,
|
||||||
|
reason: 'Task #98 Fix - Chronicler #71'
|
||||||
|
});
|
||||||
|
console.log(`✅ Created voice: ${voice.name}`);
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log("🎉 Wold's Vaults — COMPLETE!");
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ ERROR:', error.message);
|
||||||
|
} finally {
|
||||||
|
client.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
295
services/arbiter-3.0/src/discord/createserver.js
Normal file
295
services/arbiter-3.0/src/discord/createserver.js
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
/**
|
||||||
|
* /createserver Command
|
||||||
|
* Creates a complete server setup with one command:
|
||||||
|
* - Role
|
||||||
|
* - Category with emoji prefix
|
||||||
|
* - Chat, in-game, forum, voice channels
|
||||||
|
* - Permission template
|
||||||
|
* - Welcome post (archived)
|
||||||
|
* - Suggests emoji for reaction roles
|
||||||
|
*
|
||||||
|
* Created: April 8, 2026
|
||||||
|
* Chronicler: #71
|
||||||
|
* Task: #98 Discord Channel Automation
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { SlashCommandBuilder, ChannelType, PermissionFlagsBits, PermissionsBitField } = require('discord.js');
|
||||||
|
|
||||||
|
// Channel ID for #get-roles
|
||||||
|
const GET_ROLES_CHANNEL_ID = '1403980899464384572';
|
||||||
|
|
||||||
|
// Staff role names that can use this command
|
||||||
|
const STAFF_ROLES = ['Staff', '🛡️ Moderator', '👑 The Wizard', '💎 The Emissary', '✨ The Catalyst'];
|
||||||
|
|
||||||
|
// Admin roles that get full access to new server channels
|
||||||
|
const ADMIN_ROLES = ['Staff', '🛡️ Moderator', '👑 The Wizard', '💎 The Emissary', '✨ The Catalyst'];
|
||||||
|
|
||||||
|
// Standard forum tags
|
||||||
|
const STANDARD_FORUM_TAGS = [
|
||||||
|
{ name: 'Builds', emoji: '🏗️' },
|
||||||
|
{ name: 'Help', emoji: '❓' },
|
||||||
|
{ name: 'Suggestion', emoji: '💡' },
|
||||||
|
{ name: 'Bug Report', emoji: '🐛' },
|
||||||
|
{ name: 'Achievement', emoji: '🎉' },
|
||||||
|
{ name: 'Guide', emoji: '📖' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Emoji pool for reaction role suggestions (gaming/server themed)
|
||||||
|
const EMOJI_POOL = [
|
||||||
|
'🎮', '🕹️', '⚔️', '🛡️', '🏰', '🗡️', '🔮', '🧙', '🐉', '🌋',
|
||||||
|
'🌊', '🏔️', '🌲', '🍄', '⚡', '💎', '🪨', '⛏️', '🧱', '🏠',
|
||||||
|
'🌙', '☀️', '🌟', '✨', '🎯', '🎪', '🎭', '🎨', '🧪', '🔧',
|
||||||
|
'⚙️', '🚀', '👾', '🤖', '🎲', '🃏', '🏴☠️', '⚓', '🧭', '🗺️',
|
||||||
|
'🦊', '🐺', '🦁', '🐲', '🦅', '🐋', '🦈', '🐙', '🦑', '🕷️',
|
||||||
|
'🌸', '🌺', '🌻', '🍀', '🌿', '🔥', '❄️', '💧', '🌪️', '⭐'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Generic welcome post template
|
||||||
|
const WELCOME_TEMPLATE = (serverName) => `🎮 **Welcome to ${serverName}!**
|
||||||
|
|
||||||
|
This is your community space for all things ${serverName}. Share your adventures, ask questions, and connect with fellow players!
|
||||||
|
|
||||||
|
**This forum is your space to:**
|
||||||
|
- 🏗️ Share your builds and creations
|
||||||
|
- ❓ Ask for help and advice
|
||||||
|
- 💡 Suggest improvements
|
||||||
|
- 🎉 Celebrate your achievements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 First Challenge: Introduce Yourself!**
|
||||||
|
|
||||||
|
Tell us about your playstyle! What are you most excited to try on this server?
|
||||||
|
|
||||||
|
*Welcome to Firefrost Gaming!* 🔥❄️`;
|
||||||
|
|
||||||
|
// Build the slash command
|
||||||
|
const createServerCommand = new SlashCommandBuilder()
|
||||||
|
.setName('createserver')
|
||||||
|
.setDescription('Create a complete server setup (Staff only)')
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('name')
|
||||||
|
.setDescription('Server name (e.g., "Beyond Depth")')
|
||||||
|
.setRequired(true)
|
||||||
|
.setMaxLength(50)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slugify a server name for channel names
|
||||||
|
*/
|
||||||
|
function slugify(name) {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.substring(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has staff role
|
||||||
|
*/
|
||||||
|
function isStaff(member) {
|
||||||
|
return member.roles.cache.some(role => STAFF_ROLES.includes(role.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unused emoji from pool
|
||||||
|
*/
|
||||||
|
async function getUnusedEmoji(channel) {
|
||||||
|
try {
|
||||||
|
const message = await channel.messages.fetch({ limit: 50 });
|
||||||
|
const getRolesMsg = message.find(m => m.reactions.cache.size > 0);
|
||||||
|
|
||||||
|
if (!getRolesMsg) {
|
||||||
|
// No message with reactions found, return first emoji
|
||||||
|
return EMOJI_POOL[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedEmojis = new Set();
|
||||||
|
getRolesMsg.reactions.cache.forEach(reaction => {
|
||||||
|
usedEmojis.add(reaction.emoji.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find first unused emoji
|
||||||
|
for (const emoji of EMOJI_POOL) {
|
||||||
|
if (!usedEmojis.has(emoji)) {
|
||||||
|
return emoji;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All emojis used, return a random one with note
|
||||||
|
return EMOJI_POOL[Math.floor(Math.random() * EMOJI_POOL.length)];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching emojis:', error);
|
||||||
|
return EMOJI_POOL[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle /createserver command
|
||||||
|
*/
|
||||||
|
async function handleCreateServerCommand(interaction) {
|
||||||
|
// Check permissions
|
||||||
|
if (!isStaff(interaction.member)) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: '❌ This command is restricted to Staff members.',
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.deferReply({ ephemeral: false });
|
||||||
|
|
||||||
|
const serverName = interaction.options.getString('name');
|
||||||
|
const guild = interaction.guild;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch roles
|
||||||
|
await guild.roles.fetch();
|
||||||
|
|
||||||
|
// Check if role already exists
|
||||||
|
const existingRole = guild.roles.cache.find(r => r.name.toLowerCase() === serverName.toLowerCase());
|
||||||
|
if (existingRole) {
|
||||||
|
return interaction.editReply(`❌ Role **${serverName}** already exists!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if category already exists
|
||||||
|
const existingCategory = guild.channels.cache.find(
|
||||||
|
ch => ch.type === ChannelType.GuildCategory &&
|
||||||
|
(ch.name === serverName || ch.name === `🎮 ${serverName}`)
|
||||||
|
);
|
||||||
|
if (existingCategory) {
|
||||||
|
return interaction.editReply(`❌ Category **${serverName}** already exists!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get key roles
|
||||||
|
const everyoneRole = guild.roles.everyone;
|
||||||
|
const wandererRole = guild.roles.cache.find(r => r.name === 'Wanderer');
|
||||||
|
|
||||||
|
if (!wandererRole) {
|
||||||
|
return interaction.editReply('❌ Wanderer role not found!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get admin role IDs
|
||||||
|
const adminRoleIds = ADMIN_ROLES
|
||||||
|
.map(name => guild.roles.cache.find(r => r.name === name)?.id)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// Progress update
|
||||||
|
await interaction.editReply(`⏳ Creating **${serverName}**...`);
|
||||||
|
|
||||||
|
// Step 1: Create role
|
||||||
|
const serverRole = await guild.roles.create({
|
||||||
|
name: serverName,
|
||||||
|
reason: `/createserver by ${interaction.user.tag}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Build permission overwrites
|
||||||
|
const permissionOverwrites = [
|
||||||
|
{
|
||||||
|
id: everyoneRole.id,
|
||||||
|
deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: wandererRole.id,
|
||||||
|
allow: [PermissionFlagsBits.ViewChannel],
|
||||||
|
deny: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: serverRole.id,
|
||||||
|
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory]
|
||||||
|
},
|
||||||
|
...adminRoleIds.map(roleId => ({
|
||||||
|
id: roleId,
|
||||||
|
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory]
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
// Step 3: Create category
|
||||||
|
const category = await guild.channels.create({
|
||||||
|
name: `🎮 ${serverName}`,
|
||||||
|
type: ChannelType.GuildCategory,
|
||||||
|
permissionOverwrites,
|
||||||
|
reason: `/createserver by ${interaction.user.tag}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: Create chat channel
|
||||||
|
const slug = slugify(serverName);
|
||||||
|
await guild.channels.create({
|
||||||
|
name: `${slug}-chat`,
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
parent: category.id,
|
||||||
|
topic: `General chat for ${serverName}`,
|
||||||
|
reason: `/createserver by ${interaction.user.tag}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 5: Create in-game channel
|
||||||
|
await guild.channels.create({
|
||||||
|
name: `${slug}-in-game`,
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
parent: category.id,
|
||||||
|
topic: `In-game chat bridge for ${serverName}`,
|
||||||
|
reason: `/createserver by ${interaction.user.tag}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 6: Create forum
|
||||||
|
const forum = await guild.channels.create({
|
||||||
|
name: `${slug}-forum`,
|
||||||
|
type: ChannelType.GuildForum,
|
||||||
|
parent: category.id,
|
||||||
|
topic: `Discussion forum for ${serverName}`,
|
||||||
|
availableTags: STANDARD_FORUM_TAGS.map(tag => ({
|
||||||
|
name: tag.name,
|
||||||
|
emoji: { name: tag.emoji }
|
||||||
|
})),
|
||||||
|
reason: `/createserver by ${interaction.user.tag}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 7: Create voice channel
|
||||||
|
await guild.channels.create({
|
||||||
|
name: serverName,
|
||||||
|
type: ChannelType.GuildVoice,
|
||||||
|
parent: category.id,
|
||||||
|
reason: `/createserver by ${interaction.user.tag}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 8: Post welcome message
|
||||||
|
const welcomeThread = await forum.threads.create({
|
||||||
|
name: `Welcome to ${serverName}!`,
|
||||||
|
message: { content: WELCOME_TEMPLATE(serverName) },
|
||||||
|
reason: `/createserver by ${interaction.user.tag}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 9: Archive the welcome post
|
||||||
|
await welcomeThread.setArchived(true, 'Auto-archive welcome post');
|
||||||
|
|
||||||
|
// Step 10: Get suggested emoji
|
||||||
|
const getRolesChannel = await guild.channels.fetch(GET_ROLES_CHANNEL_ID);
|
||||||
|
const suggestedEmoji = await getUnusedEmoji(getRolesChannel);
|
||||||
|
|
||||||
|
// Success message
|
||||||
|
const successMessage = `✅ **${serverName}** created!
|
||||||
|
|
||||||
|
**Created:**
|
||||||
|
• Role: ${serverRole}
|
||||||
|
• Category: 🎮 ${serverName}
|
||||||
|
• Channels: ${slug}-chat, ${slug}-in-game, ${slug}-forum, voice
|
||||||
|
• Welcome post: Archived ✓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Suggested emoji for #get-roles:** ${suggestedEmoji}
|
||||||
|
|
||||||
|
To complete setup, add ${suggestedEmoji} as a reaction to the #get-roles message, then configure Carl-bot to assign the "${serverName}" role.`;
|
||||||
|
|
||||||
|
await interaction.editReply(successMessage);
|
||||||
|
|
||||||
|
console.log(`✅ /createserver: ${serverName} created by ${interaction.user.tag}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('/createserver error:', error);
|
||||||
|
await interaction.editReply(`❌ Error creating server: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createServerCommand, handleCreateServerCommand };
|
||||||
190
services/arbiter-3.0/src/discord/delserver.js
Normal file
190
services/arbiter-3.0/src/discord/delserver.js
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* /delserver Command
|
||||||
|
* Deletes a complete server setup:
|
||||||
|
* - All channels in the category
|
||||||
|
* - The category itself
|
||||||
|
* - The server role
|
||||||
|
*
|
||||||
|
* Requires confirm:True to execute.
|
||||||
|
* Without confirm, shows preview of what would be deleted.
|
||||||
|
*
|
||||||
|
* Created: April 8, 2026
|
||||||
|
* Chronicler: #71
|
||||||
|
* Task: #98 Discord Channel Automation
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { SlashCommandBuilder, ChannelType } = require('discord.js');
|
||||||
|
|
||||||
|
// Staff role names that can use this command
|
||||||
|
const STAFF_ROLES = ['Staff', '🛡️ Moderator', '👑 The Wizard', '💎 The Emissary', '✨ The Catalyst'];
|
||||||
|
|
||||||
|
// Build the slash command
|
||||||
|
const delServerCommand = new SlashCommandBuilder()
|
||||||
|
.setName('delserver')
|
||||||
|
.setDescription('Delete a server setup completely (Staff only)')
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('name')
|
||||||
|
.setDescription('Server name (e.g., "Beyond Depth")')
|
||||||
|
.setRequired(true)
|
||||||
|
.setMaxLength(50)
|
||||||
|
)
|
||||||
|
.addBooleanOption(option =>
|
||||||
|
option.setName('confirm')
|
||||||
|
.setDescription('Set to True to confirm deletion')
|
||||||
|
.setRequired(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has staff role
|
||||||
|
*/
|
||||||
|
function isStaff(member) {
|
||||||
|
return member.roles.cache.some(role => STAFF_ROLES.includes(role.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle /delserver command
|
||||||
|
*/
|
||||||
|
async function handleDelServerCommand(interaction) {
|
||||||
|
// Check permissions
|
||||||
|
if (!isStaff(interaction.member)) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: '❌ This command is restricted to Staff members.',
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverName = interaction.options.getString('name');
|
||||||
|
const confirmed = interaction.options.getBoolean('confirm') || false;
|
||||||
|
const guild = interaction.guild;
|
||||||
|
|
||||||
|
await interaction.deferReply({ ephemeral: false });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await guild.channels.fetch();
|
||||||
|
await guild.roles.fetch();
|
||||||
|
|
||||||
|
// Find the category (with or without emoji)
|
||||||
|
const category = guild.channels.cache.find(
|
||||||
|
ch => ch.type === ChannelType.GuildCategory &&
|
||||||
|
(ch.name === serverName || ch.name === `🎮 ${serverName}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the role
|
||||||
|
const serverRole = guild.roles.cache.find(
|
||||||
|
r => r.name.toLowerCase() === serverName.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build preview
|
||||||
|
const channelsToDelete = category
|
||||||
|
? guild.channels.cache.filter(ch => ch.parentId === category.id)
|
||||||
|
: new Map();
|
||||||
|
|
||||||
|
const preview = [];
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
preview.push(`**Category:** ${category.name}`);
|
||||||
|
if (channelsToDelete.size > 0) {
|
||||||
|
preview.push(`**Channels (${channelsToDelete.size}):**`);
|
||||||
|
channelsToDelete.forEach(ch => {
|
||||||
|
const typeLabel = ch.type === ChannelType.GuildVoice ? '🔊' :
|
||||||
|
ch.type === ChannelType.GuildForum ? '💬' : '#';
|
||||||
|
preview.push(`• ${typeLabel} ${ch.name}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
preview.push(`**Category:** ⚠️ Not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverRole) {
|
||||||
|
preview.push(`**Role:** @${serverRole.name} (${serverRole.members.size} members)`);
|
||||||
|
} else {
|
||||||
|
preview.push(`**Role:** ⚠️ Not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If nothing found
|
||||||
|
if (!category && !serverRole) {
|
||||||
|
return interaction.editReply(`❌ Server **${serverName}** not found.\n\nNo category or role matches that name.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not confirmed, show preview
|
||||||
|
if (!confirmed) {
|
||||||
|
const previewMessage = `⚠️ **Delete Server: ${serverName}**
|
||||||
|
|
||||||
|
This will permanently delete:
|
||||||
|
|
||||||
|
${preview.join('\n')}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**To confirm, run:**
|
||||||
|
\`\`\`
|
||||||
|
/delserver name:${serverName} confirm:True
|
||||||
|
\`\`\``;
|
||||||
|
|
||||||
|
return interaction.editReply(previewMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CONFIRMED - Execute deletion
|
||||||
|
await interaction.editReply(`🗑️ Deleting **${serverName}**...`);
|
||||||
|
|
||||||
|
const deleted = {
|
||||||
|
channels: 0,
|
||||||
|
category: false,
|
||||||
|
role: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete channels first
|
||||||
|
if (category) {
|
||||||
|
for (const [id, channel] of channelsToDelete) {
|
||||||
|
try {
|
||||||
|
await channel.delete(`/delserver by ${interaction.user.tag}`);
|
||||||
|
deleted.channels++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to delete channel ${channel.name}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete category
|
||||||
|
try {
|
||||||
|
await category.delete(`/delserver by ${interaction.user.tag}`);
|
||||||
|
deleted.category = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to delete category:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete role
|
||||||
|
if (serverRole) {
|
||||||
|
try {
|
||||||
|
await serverRole.delete(`/delserver by ${interaction.user.tag}`);
|
||||||
|
deleted.role = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to delete role:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success message
|
||||||
|
const successMessage = `🗑️ **${serverName}** deleted!
|
||||||
|
|
||||||
|
**Removed:**
|
||||||
|
• ${deleted.channels} channels
|
||||||
|
• ${deleted.category ? '1 category' : '0 categories'}
|
||||||
|
• ${deleted.role ? '1 role' : '0 roles'}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Don't forget to:**
|
||||||
|
1. Remove the reaction emoji from <#1403980899464384572>
|
||||||
|
2. Remove the role mapping from Carl-bot`;
|
||||||
|
|
||||||
|
await interaction.editReply(successMessage);
|
||||||
|
|
||||||
|
console.log(`🗑️ /delserver: ${serverName} deleted by ${interaction.user.tag}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('/delserver error:', error);
|
||||||
|
await interaction.editReply(`❌ Error deleting server: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { delServerCommand, handleDelServerCommand };
|
||||||
@@ -1,15 +1,38 @@
|
|||||||
const { handleLinkCommand } = require('./commands');
|
const { handleLinkCommand } = require('./commands');
|
||||||
|
const { handleCreateServerCommand } = require('./createserver');
|
||||||
|
const { handleDelServerCommand } = require('./delserver');
|
||||||
|
const { handleTasksCommand, handleTaskButton } = require('./tasks');
|
||||||
|
const discordRoleSync = require('../services/discordRoleSync');
|
||||||
|
|
||||||
function registerEvents(client) {
|
function registerEvents(client) {
|
||||||
client.on('interactionCreate', async interaction => {
|
client.on('interactionCreate', async interaction => {
|
||||||
|
// Button interactions
|
||||||
|
if (interaction.isButton()) {
|
||||||
|
if (interaction.customId.startsWith('task_')) {
|
||||||
|
await handleTaskButton(interaction);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!interaction.isChatInputCommand()) return;
|
if (!interaction.isChatInputCommand()) return;
|
||||||
if (interaction.commandName === 'link') {
|
if (interaction.commandName === 'link') {
|
||||||
await handleLinkCommand(interaction);
|
await handleLinkCommand(interaction);
|
||||||
}
|
}
|
||||||
|
if (interaction.commandName === 'createserver') {
|
||||||
|
await handleCreateServerCommand(interaction);
|
||||||
|
}
|
||||||
|
if (interaction.commandName === 'delserver') {
|
||||||
|
await handleDelServerCommand(interaction);
|
||||||
|
}
|
||||||
|
if (interaction.commandName === 'tasks') {
|
||||||
|
await handleTasksCommand(interaction);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on('ready', () => {
|
client.on('ready', () => {
|
||||||
console.log(`Discord bot logged in as ${client.user.tag}`);
|
console.log(`Discord bot logged in as ${client.user.tag}`);
|
||||||
|
// Initialize role sync service with the ready client
|
||||||
|
discordRoleSync.init(client);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
369
services/arbiter-3.0/src/discord/tasks.js
Normal file
369
services/arbiter-3.0/src/discord/tasks.js
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
/**
|
||||||
|
* /tasks Command
|
||||||
|
* View and manage Firefrost tasks via Discord buttons.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* /tasks — Show open tasks
|
||||||
|
* /tasks status:done — Show completed tasks
|
||||||
|
* /tasks mine — Show your tasks
|
||||||
|
*
|
||||||
|
* Buttons: Mark Done, Take Task, Details
|
||||||
|
*
|
||||||
|
* Created: April 11, 2026
|
||||||
|
* Chronicler: #78
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } = require('discord.js');
|
||||||
|
const db = require('../database');
|
||||||
|
|
||||||
|
const STAFF_ROLES = ['Staff', '🛡️ Moderator', '👑 The Wizard', '💎 The Emissary', '✨ The Catalyst'];
|
||||||
|
|
||||||
|
const PRIORITY_EMOJI = {
|
||||||
|
critical: '🔴',
|
||||||
|
high: '🟠',
|
||||||
|
medium: '🟡',
|
||||||
|
low: '🔵',
|
||||||
|
wish: '🟣'
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_EMOJI = {
|
||||||
|
open: '⬡',
|
||||||
|
in_progress: '🔄',
|
||||||
|
blocked: '⛔',
|
||||||
|
done: '✅',
|
||||||
|
obsolete: '⚫'
|
||||||
|
};
|
||||||
|
|
||||||
|
const tasksCommand = new SlashCommandBuilder()
|
||||||
|
.setName('tasks')
|
||||||
|
.setDescription('View and manage Firefrost tasks')
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('filter')
|
||||||
|
.setDescription('Filter tasks')
|
||||||
|
.addChoices(
|
||||||
|
{ name: 'Open (default)', value: 'open' },
|
||||||
|
{ name: 'In Progress', value: 'in_progress' },
|
||||||
|
{ name: 'My Tasks', value: 'mine' },
|
||||||
|
{ name: 'High Priority', value: 'high' },
|
||||||
|
{ name: 'All Active', value: 'active' },
|
||||||
|
{ name: 'Done', value: 'done' },
|
||||||
|
{ name: 'Everything', value: 'all' }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addIntegerOption(option =>
|
||||||
|
option.setName('number')
|
||||||
|
.setDescription('View a specific task by number (e.g. 26)')
|
||||||
|
);
|
||||||
|
|
||||||
|
function isStaff(member) {
|
||||||
|
return member.roles.cache.some(role => STAFF_ROLES.includes(role.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownerFromDiscord(user) {
|
||||||
|
// Map Discord users to task owners
|
||||||
|
// This could be database-driven later
|
||||||
|
const displayName = user.displayName || user.username;
|
||||||
|
if (displayName.includes('Frosty') || displayName.includes('Wizard')) return 'Michael';
|
||||||
|
if (displayName.includes('Ginger') || displayName.includes('Emissary')) return 'Meg';
|
||||||
|
if (displayName.includes('unicorn') || displayName.includes('Catalyst')) return 'Holly';
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildTaskEmbed(tasks, filterLabel) {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`📋 Firefrost Tasks — ${filterLabel}`)
|
||||||
|
.setColor(0x4ECDC4)
|
||||||
|
.setFooter({ text: `${tasks.length} task(s) · Use buttons to update` })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
embed.setDescription('No tasks found for this filter.');
|
||||||
|
return { embed, rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build description with task list
|
||||||
|
const lines = tasks.slice(0, 15).map(t => {
|
||||||
|
const pri = PRIORITY_EMOJI[t.priority] || '⚪';
|
||||||
|
const sta = STATUS_EMOJI[t.status] || '⬡';
|
||||||
|
const owner = t.owner !== 'unassigned' ? ` · ${t.owner}` : '';
|
||||||
|
return `${sta} ${pri} **#${t.task_number}** ${t.title}${owner}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tasks.length > 15) {
|
||||||
|
lines.push(`\n*...and ${tasks.length - 15} more*`);
|
||||||
|
}
|
||||||
|
|
||||||
|
embed.setDescription(lines.join('\n'));
|
||||||
|
|
||||||
|
// Build button rows (max 5 buttons per row, max 5 rows)
|
||||||
|
const rows = [];
|
||||||
|
const actionableTasks = tasks.filter(t => t.status !== 'done' && t.status !== 'obsolete').slice(0, 10);
|
||||||
|
|
||||||
|
if (actionableTasks.length > 0) {
|
||||||
|
// "Mark Done" buttons - first 5
|
||||||
|
const doneButtons = actionableTasks.slice(0, 5).map(t =>
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`task_done_${t.id}`)
|
||||||
|
.setLabel(`✓ #${t.task_number}`)
|
||||||
|
.setStyle(ButtonStyle.Success)
|
||||||
|
);
|
||||||
|
if (doneButtons.length > 0) {
|
||||||
|
rows.push(new ActionRowBuilder().addComponents(doneButtons));
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Take" buttons for unassigned - next row
|
||||||
|
const unassigned = actionableTasks.filter(t => t.owner === 'unassigned').slice(0, 5);
|
||||||
|
if (unassigned.length > 0) {
|
||||||
|
const takeButtons = unassigned.map(t =>
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`task_take_${t.id}`)
|
||||||
|
.setLabel(`📌 Take #${t.task_number}`)
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
);
|
||||||
|
rows.push(new ActionRowBuilder().addComponents(takeButtons));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { embed, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTaskDetail(interaction, taskNumber) {
|
||||||
|
try {
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT * FROM tasks WHERE task_number = $1',
|
||||||
|
[taskNumber]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return interaction.editReply(`❌ Task #${taskNumber} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = result.rows[0];
|
||||||
|
const pri = PRIORITY_EMOJI[t.priority] || '⚪';
|
||||||
|
const sta = STATUS_EMOJI[t.status] || '⬡';
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`${sta} #${t.task_number} — ${t.title}`)
|
||||||
|
.setColor(
|
||||||
|
t.status === 'done' ? 0x22c55e :
|
||||||
|
t.status === 'blocked' ? 0xef4444 :
|
||||||
|
t.priority === 'critical' ? 0xef4444 :
|
||||||
|
t.priority === 'high' ? 0xff6b35 :
|
||||||
|
0x4ECDC4
|
||||||
|
)
|
||||||
|
.addFields(
|
||||||
|
{ name: 'Status', value: `${sta} ${t.status}`, inline: true },
|
||||||
|
{ name: 'Priority', value: `${pri} ${t.priority}`, inline: true },
|
||||||
|
{ name: 'Owner', value: t.owner || 'unassigned', inline: true }
|
||||||
|
)
|
||||||
|
.setTimestamp(new Date(t.updated_at));
|
||||||
|
|
||||||
|
if (t.description) {
|
||||||
|
embed.setDescription(t.description.length > 400 ? t.description.substring(0, 400) + '...' : t.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.tags && t.tags.length > 0) {
|
||||||
|
embed.addFields({ name: 'Tags', value: t.tags.map(tag => `\`${tag}\``).join(' '), inline: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.spec_path) {
|
||||||
|
const specUrl = `https://git.firefrostgaming.com/firefrost-gaming/firefrost-operations-manual/src/branch/master/${t.spec_path}`;
|
||||||
|
embed.addFields({ name: '📄 Full Spec', value: `[View in Gitea](${specUrl})`, inline: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.completed_at) {
|
||||||
|
const completedDate = new Date(t.completed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
embed.addFields({ name: 'Completed', value: `${completedDate}${t.completed_by ? ` by ${t.completed_by}` : ''}`, inline: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
embed.setFooter({ text: `Created ${new Date(t.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} · Updated ${new Date(t.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}` });
|
||||||
|
|
||||||
|
// Action buttons for active tasks
|
||||||
|
const rows = [];
|
||||||
|
if (t.status !== 'done' && t.status !== 'obsolete') {
|
||||||
|
const buttons = [
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`task_done_${t.id}`)
|
||||||
|
.setLabel('Mark Done')
|
||||||
|
.setStyle(ButtonStyle.Success),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`task_progress_${t.id}`)
|
||||||
|
.setLabel('In Progress')
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
];
|
||||||
|
if (t.owner === 'unassigned') {
|
||||||
|
buttons.push(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`task_take_${t.id}`)
|
||||||
|
.setLabel('Take Task')
|
||||||
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rows.push(new ActionRowBuilder().addComponents(buttons));
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed], components: rows });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Task detail error:', err);
|
||||||
|
await interaction.editReply('❌ Error loading task details.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTasksCommand(interaction) {
|
||||||
|
if (!isStaff(interaction.member)) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: '❌ This command is restricted to Staff members.',
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
// Check if viewing a specific task
|
||||||
|
const taskNumber = interaction.options.getInteger('number');
|
||||||
|
if (taskNumber) {
|
||||||
|
return handleTaskDetail(interaction, taskNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = interaction.options.getString('filter') || 'open';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let query, params = [], filterLabel;
|
||||||
|
|
||||||
|
switch (filter) {
|
||||||
|
case 'open':
|
||||||
|
query = `SELECT * FROM tasks WHERE status = 'open' ORDER BY
|
||||||
|
CASE priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 WHEN 'low' THEN 4 WHEN 'wish' THEN 5 END, task_number`;
|
||||||
|
filterLabel = 'Open';
|
||||||
|
break;
|
||||||
|
case 'in_progress':
|
||||||
|
query = `SELECT * FROM tasks WHERE status = 'in_progress' ORDER BY task_number`;
|
||||||
|
filterLabel = 'In Progress';
|
||||||
|
break;
|
||||||
|
case 'mine':
|
||||||
|
const owner = ownerFromDiscord(interaction.member);
|
||||||
|
query = `SELECT * FROM tasks WHERE owner = $1 AND status NOT IN ('done', 'obsolete') ORDER BY
|
||||||
|
CASE priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 WHEN 'low' THEN 4 WHEN 'wish' THEN 5 END`;
|
||||||
|
params = [owner];
|
||||||
|
filterLabel = `${owner}'s Tasks`;
|
||||||
|
break;
|
||||||
|
case 'high':
|
||||||
|
query = `SELECT * FROM tasks WHERE priority IN ('critical', 'high') AND status NOT IN ('done', 'obsolete') ORDER BY
|
||||||
|
CASE priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 END, task_number`;
|
||||||
|
filterLabel = 'High Priority';
|
||||||
|
break;
|
||||||
|
case 'active':
|
||||||
|
query = `SELECT * FROM tasks WHERE status NOT IN ('done', 'obsolete') ORDER BY
|
||||||
|
CASE priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 WHEN 'low' THEN 4 WHEN 'wish' THEN 5 END, task_number`;
|
||||||
|
filterLabel = 'All Active';
|
||||||
|
break;
|
||||||
|
case 'done':
|
||||||
|
query = `SELECT * FROM tasks WHERE status = 'done' ORDER BY completed_at DESC LIMIT 20`;
|
||||||
|
filterLabel = 'Recently Completed';
|
||||||
|
break;
|
||||||
|
case 'all':
|
||||||
|
query = `SELECT * FROM tasks ORDER BY
|
||||||
|
CASE status WHEN 'in_progress' THEN 1 WHEN 'open' THEN 2 WHEN 'blocked' THEN 3 WHEN 'done' THEN 4 WHEN 'obsolete' THEN 5 END,
|
||||||
|
CASE priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 WHEN 'low' THEN 4 WHEN 'wish' THEN 5 END`;
|
||||||
|
filterLabel = 'Everything';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(query, params);
|
||||||
|
const { embed, rows } = await buildTaskEmbed(result.rows, filterLabel);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [embed],
|
||||||
|
components: rows
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('/tasks error:', err);
|
||||||
|
await interaction.editReply('❌ Error loading tasks.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTaskButton(interaction) {
|
||||||
|
const customId = interaction.customId;
|
||||||
|
|
||||||
|
if (customId.startsWith('task_done_')) {
|
||||||
|
const taskId = customId.replace('task_done_', '');
|
||||||
|
const completedBy = ownerFromDiscord(interaction.member);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db.query(
|
||||||
|
`UPDATE tasks SET status = 'done', completed_at = NOW(), completed_by = $1, updated_at = NOW()
|
||||||
|
WHERE id = $2 RETURNING task_number, title`,
|
||||||
|
[completedBy, taskId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length > 0) {
|
||||||
|
const t = result.rows[0];
|
||||||
|
await interaction.reply({
|
||||||
|
content: `✅ **#${t.task_number} — ${t.title}** marked done by ${completedBy}!`,
|
||||||
|
ephemeral: false
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ content: '❌ Task not found.', ephemeral: true });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Task done error:', err);
|
||||||
|
await interaction.reply({ content: '❌ Error updating task.', ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customId.startsWith('task_take_')) {
|
||||||
|
const taskId = customId.replace('task_take_', '');
|
||||||
|
const owner = ownerFromDiscord(interaction.member);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db.query(
|
||||||
|
`UPDATE tasks SET owner = $1, status = 'in_progress', updated_at = NOW()
|
||||||
|
WHERE id = $2 RETURNING task_number, title`,
|
||||||
|
[owner, taskId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length > 0) {
|
||||||
|
const t = result.rows[0];
|
||||||
|
await interaction.reply({
|
||||||
|
content: `📌 **#${t.task_number} — ${t.title}** claimed by ${owner}!`,
|
||||||
|
ephemeral: false
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ content: '❌ Task not found.', ephemeral: true });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Task take error:', err);
|
||||||
|
await interaction.reply({ content: '❌ Error updating task.', ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customId.startsWith('task_progress_')) {
|
||||||
|
const taskId = customId.replace('task_progress_', '');
|
||||||
|
const owner = ownerFromDiscord(interaction.member);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db.query(
|
||||||
|
`UPDATE tasks SET status = 'in_progress', owner = $1, updated_at = NOW()
|
||||||
|
WHERE id = $2 RETURNING task_number, title`,
|
||||||
|
[owner, taskId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length > 0) {
|
||||||
|
const t = result.rows[0];
|
||||||
|
await interaction.reply({
|
||||||
|
content: `🔄 **#${t.task_number} — ${t.title}** marked in progress by ${owner}!`,
|
||||||
|
ephemeral: false
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ content: '❌ Task not found.', ephemeral: true });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Task progress error:', err);
|
||||||
|
await interaction.reply({ content: '❌ Error updating task.', ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { tasksCommand, handleTasksCommand, handleTaskButton };
|
||||||
@@ -2,19 +2,35 @@ require('dotenv').config();
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const expressLayouts = require('express-ejs-layouts');
|
const expressLayouts = require('express-ejs-layouts');
|
||||||
const session = require('express-session');
|
const session = require('express-session');
|
||||||
|
const PgSession = require('connect-pg-simple')(session);
|
||||||
const passport = require('passport');
|
const passport = require('passport');
|
||||||
const DiscordStrategy = require('passport-discord').Strategy;
|
const DiscordStrategy = require('passport-discord').Strategy;
|
||||||
const { Client, GatewayIntentBits, REST, Routes } = require('discord.js');
|
const { Client, GatewayIntentBits, REST, Routes } = require('discord.js');
|
||||||
const csrf = require('csurf');
|
const csrf = require('csurf');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const adminRoutes = require('./routes/admin/index');
|
const adminRoutes = require('./routes/admin/index');
|
||||||
const webhookRoutes = require('./routes/webhook');
|
const webhookRoutes = require('./routes/webhook');
|
||||||
const stripeRoutes = require('./routes/stripe');
|
const stripeRoutes = require('./routes/stripe');
|
||||||
|
const apiRoutes = require('./routes/api');
|
||||||
const { registerEvents } = require('./discord/events');
|
const { registerEvents } = require('./discord/events');
|
||||||
const { linkCommand } = require('./discord/commands');
|
const { linkCommand } = require('./discord/commands');
|
||||||
|
const { createServerCommand } = require('./discord/createserver');
|
||||||
|
const { delServerCommand } = require('./discord/delserver');
|
||||||
|
const { tasksCommand } = require('./discord/tasks');
|
||||||
const { initCron } = require('./sync/cron');
|
const { initCron } = require('./sync/cron');
|
||||||
|
const discordRoleSync = require('./services/discordRoleSync');
|
||||||
|
|
||||||
|
// PostgreSQL connection pool for sessions
|
||||||
|
const pgPool = new Pool({
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
port: process.env.DB_PORT || 5432
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize Discord Client
|
// Initialize Discord Client
|
||||||
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] });
|
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] });
|
||||||
@@ -64,6 +80,11 @@ app.use(express.urlencoded({ extended: true }));
|
|||||||
app.locals.client = client;
|
app.locals.client = client;
|
||||||
|
|
||||||
app.use(session({
|
app.use(session({
|
||||||
|
store: new PgSession({
|
||||||
|
pool: pgPool,
|
||||||
|
tableName: 'session',
|
||||||
|
createTableIfMissing: true
|
||||||
|
}),
|
||||||
secret: process.env.SESSION_SECRET,
|
secret: process.env.SESSION_SECRET,
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
@@ -95,6 +116,7 @@ app.use('/auth', authRoutes);
|
|||||||
app.use('/admin', csrfProtection, adminRoutes);
|
app.use('/admin', csrfProtection, adminRoutes);
|
||||||
app.use('/webhook', webhookRoutes);
|
app.use('/webhook', webhookRoutes);
|
||||||
app.use('/stripe', stripeRoutes); // Checkout and portal routes (uses JSON body)
|
app.use('/stripe', stripeRoutes); // Checkout and portal routes (uses JSON body)
|
||||||
|
app.use('/api/internal', apiRoutes); // Internal API for n8n (token-based auth)
|
||||||
|
|
||||||
// Start Application
|
// Start Application
|
||||||
const PORT = process.env.PORT || 3500;
|
const PORT = process.env.PORT || 3500;
|
||||||
@@ -111,7 +133,7 @@ const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_BOT_TOKEN)
|
|||||||
console.log('Refreshing application (/) commands.');
|
console.log('Refreshing application (/) commands.');
|
||||||
await rest.put(
|
await rest.put(
|
||||||
Routes.applicationGuildCommands(process.env.DISCORD_CLIENT_ID, process.env.GUILD_ID),
|
Routes.applicationGuildCommands(process.env.DISCORD_CLIENT_ID, process.env.GUILD_ID),
|
||||||
{ body: [linkCommand.toJSON()] },
|
{ body: [linkCommand.toJSON(), createServerCommand.toJSON(), delServerCommand.toJSON(), tasksCommand.toJSON()] },
|
||||||
);
|
);
|
||||||
console.log('✅ Successfully reloaded application (/) commands.');
|
console.log('✅ Successfully reloaded application (/) commands.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -123,6 +145,11 @@ const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_BOT_TOKEN)
|
|||||||
initCron();
|
initCron();
|
||||||
console.log('✅ Hourly sync cron initialized.');
|
console.log('✅ Hourly sync cron initialized.');
|
||||||
|
|
||||||
|
// Initialize Server Status Poller (updates Discord status channels every 5 minutes)
|
||||||
|
const serverStatusPoller = require('./services/serverStatusPoller');
|
||||||
|
serverStatusPoller.start(5); // 5 minute interval
|
||||||
|
console.log('✅ Server status poller initialized (5 min interval).');
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
process.on('unhandledRejection', error => {
|
process.on('unhandledRejection', error => {
|
||||||
console.error('❌ Unhandled promise rejection:', error);
|
console.error('❌ Unhandled promise rejection:', error);
|
||||||
|
|||||||
172
services/arbiter-3.0/src/lib/ptero-sync.js
Normal file
172
services/arbiter-3.0/src/lib/ptero-sync.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const db = require('../database');
|
||||||
|
|
||||||
|
const PTERO_URL = 'https://panel.firefrostgaming.com/api/client/servers';
|
||||||
|
|
||||||
|
function getHeaders() {
|
||||||
|
return {
|
||||||
|
'Authorization': `Bearer ${process.env.PTERO_CLIENT_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit helper - 200ms between calls
|
||||||
|
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a single server's schedule to Pterodactyl
|
||||||
|
*/
|
||||||
|
async function syncToPterodactyl(serverId) {
|
||||||
|
const result = await db.query('SELECT * FROM server_restart_schedules WHERE server_id = $1', [serverId]);
|
||||||
|
const server = result.rows[0];
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return { success: false, error: 'Server not found in database' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [hour, minute] = server.effective_time.split(':');
|
||||||
|
const pteroUrl = `${PTERO_URL}/${server.server_id}/schedules`;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: "[Trinity] Daily Restart",
|
||||||
|
minute,
|
||||||
|
hour,
|
||||||
|
day_of_week: "*",
|
||||||
|
day_of_month: "*",
|
||||||
|
month: "*",
|
||||||
|
is_active: !server.skip_restart
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let scheduleId = server.ptero_schedule_id;
|
||||||
|
|
||||||
|
if (!scheduleId) {
|
||||||
|
// Create new schedule
|
||||||
|
const res = await axios.post(pteroUrl, payload, { headers: getHeaders() });
|
||||||
|
scheduleId = res.data.attributes.id;
|
||||||
|
|
||||||
|
// Attach the restart task
|
||||||
|
await sleep(200);
|
||||||
|
await axios.post(`${pteroUrl}/${scheduleId}/tasks`, {
|
||||||
|
action: "power",
|
||||||
|
payload: "restart",
|
||||||
|
time_offset: 0
|
||||||
|
}, { headers: getHeaders() });
|
||||||
|
} else {
|
||||||
|
// Update existing schedule
|
||||||
|
await axios.post(`${pteroUrl}/${scheduleId}`, payload, { headers: getHeaders() });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`UPDATE server_restart_schedules
|
||||||
|
SET ptero_schedule_id = $1, sync_status = $2, last_error = NULL, last_synced_at = NOW()
|
||||||
|
WHERE server_id = $3`,
|
||||||
|
[scheduleId, 'SUCCESS', server.server_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log success
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO sync_logs (server_id, action, status) VALUES ($1, $2, $3)`,
|
||||||
|
[server.server_id, 'Created/Updated Schedule', 'SUCCESS']
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, scheduleId };
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err.response?.data?.errors?.[0]?.detail || err.message;
|
||||||
|
await db.query(
|
||||||
|
`UPDATE server_restart_schedules
|
||||||
|
SET sync_status = $1, last_error = $2
|
||||||
|
WHERE server_id = $3`,
|
||||||
|
['FAILED', errorMsg, server.server_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO sync_logs (server_id, action, status, error_message) VALUES ($1, $2, $3, $4)`,
|
||||||
|
[server.server_id, 'Sync Failed', 'FAILED', errorMsg]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: false, error: errorMsg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find existing restart schedules NOT owned by Trinity
|
||||||
|
*/
|
||||||
|
async function auditServerSchedules(serverId, serverName) {
|
||||||
|
const pteroUrl = `${PTERO_URL}/${serverId}/schedules`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.get(pteroUrl, { headers: getHeaders() });
|
||||||
|
const schedules = res.data.data || [];
|
||||||
|
|
||||||
|
// Find ANY schedule not created by Trinity
|
||||||
|
const rogueSchedules = schedules
|
||||||
|
.filter(s => !s.attributes.name.startsWith('[Trinity]'))
|
||||||
|
.map(s => ({
|
||||||
|
id: s.attributes.id,
|
||||||
|
name: s.attributes.name,
|
||||||
|
cron: `${s.attributes.minute} ${s.attributes.hour} * * *`,
|
||||||
|
isActive: s.attributes.is_active
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { serverId, serverName, rogueSchedules };
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Audit failed for ${serverName}:`, err.message);
|
||||||
|
return { serverId, serverName, rogueSchedules: [], error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a specific schedule from Pterodactyl
|
||||||
|
*/
|
||||||
|
async function deleteSchedule(serverId, scheduleId, scheduleName) {
|
||||||
|
const pteroUrl = `${PTERO_URL}/${serverId}/schedules/${scheduleId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(pteroUrl, { headers: getHeaders() });
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO sync_logs (server_id, action, status) VALUES ($1, $2, $3)`,
|
||||||
|
[serverId, `Deleted Rogue Schedule: ${scheduleName}`, 'SUCCESS']
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err.response?.data?.errors?.[0]?.detail || err.message;
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO sync_logs (server_id, action, status, error_message) VALUES ($1, $2, $3, $4)`,
|
||||||
|
[serverId, `Failed to Delete: ${scheduleName}`, 'FAILED', errorMsg]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: false, error: errorMsg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync all servers for a node
|
||||||
|
*/
|
||||||
|
async function syncAllForNode(node) {
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT server_id FROM server_restart_schedules WHERE node = $1 ORDER BY sort_order',
|
||||||
|
[node]
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const syncResult = await syncToPterodactyl(row.server_id);
|
||||||
|
results.push({ serverId: row.server_id, ...syncResult });
|
||||||
|
await sleep(200); // Rate limiting
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
syncToPterodactyl,
|
||||||
|
auditServerSchedules,
|
||||||
|
deleteSchedule,
|
||||||
|
syncAllForNode,
|
||||||
|
sleep
|
||||||
|
};
|
||||||
@@ -19,12 +19,19 @@ async function getMinecraftServers() {
|
|||||||
// Parse the allowed nest IDs from the environment variable
|
// Parse the allowed nest IDs from the environment variable
|
||||||
const allowedNests = process.env.MINECRAFT_NEST_IDS.split(',').map(id => parseInt(id.trim(), 10));
|
const allowedNests = process.env.MINECRAFT_NEST_IDS.split(',').map(id => parseInt(id.trim(), 10));
|
||||||
|
|
||||||
|
// Node ID to friendly name mapping
|
||||||
|
const nodeMap = {
|
||||||
|
2: 'NC1',
|
||||||
|
3: 'TX1'
|
||||||
|
};
|
||||||
|
|
||||||
return data.data.filter(server => {
|
return data.data.filter(server => {
|
||||||
// The API returns the nest ID directly as an integer when relationships aren't included
|
|
||||||
return allowedNests.includes(server.attributes.nest);
|
return allowedNests.includes(server.attributes.nest);
|
||||||
}).map(server => ({
|
}).map(server => ({
|
||||||
identifier: server.attributes.identifier,
|
identifier: server.attributes.identifier,
|
||||||
name: server.attributes.name
|
name: server.attributes.name,
|
||||||
|
nodeId: server.attributes.node,
|
||||||
|
node: nodeMap[server.attributes.node] || `Node ${server.attributes.node}`
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Discovery failed:", error);
|
console.error("Discovery failed:", error);
|
||||||
|
|||||||
@@ -15,12 +15,47 @@ const isAdmin = (req, res, next) => {
|
|||||||
router.get('/', isAdmin, async (req, res) => {
|
router.get('/', isAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const mappings = getRoleMappings();
|
const mappings = getRoleMappings();
|
||||||
|
|
||||||
|
// Fetch social stats across all platforms
|
||||||
|
const socialStats = await db.query(`
|
||||||
|
SELECT
|
||||||
|
platform,
|
||||||
|
COUNT(*) as post_count,
|
||||||
|
COALESCE(SUM(views), 0) as total_views,
|
||||||
|
COALESCE(SUM(likes), 0) as total_likes,
|
||||||
|
COALESCE(SUM(comments), 0) as total_comments
|
||||||
|
FROM social_posts
|
||||||
|
GROUP BY platform
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Aggregate totals
|
||||||
|
const socialTotals = {
|
||||||
|
posts: 0,
|
||||||
|
views: 0,
|
||||||
|
likes: 0,
|
||||||
|
comments: 0,
|
||||||
|
platforms: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of socialStats.rows) {
|
||||||
|
socialTotals.posts += parseInt(row.post_count);
|
||||||
|
socialTotals.views += parseInt(row.total_views);
|
||||||
|
socialTotals.likes += parseInt(row.total_likes);
|
||||||
|
socialTotals.comments += parseInt(row.total_comments);
|
||||||
|
socialTotals.platforms[row.platform] = {
|
||||||
|
posts: parseInt(row.post_count),
|
||||||
|
views: parseInt(row.total_views),
|
||||||
|
likes: parseInt(row.total_likes)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
res.render('admin/dashboard', {
|
res.render('admin/dashboard', {
|
||||||
title: 'Dashboard',
|
title: 'Dashboard',
|
||||||
adminUser: req.user,
|
adminUser: req.user,
|
||||||
csrfToken: req.csrfToken(),
|
csrfToken: req.csrfToken(),
|
||||||
mappings: mappings,
|
mappings: mappings,
|
||||||
currentPath: '/dashboard'
|
currentPath: '/dashboard',
|
||||||
|
socialTotals
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Admin dashboard error:', error);
|
console.error('Admin dashboard error:', error);
|
||||||
|
|||||||
91
services/arbiter-3.0/src/routes/admin/about.js
Normal file
91
services/arbiter-3.0/src/routes/admin/about.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* About Module — Trinity Console
|
||||||
|
*
|
||||||
|
* Console version, module versions, deploy button, system meta.
|
||||||
|
*
|
||||||
|
* GET /admin/about — About page
|
||||||
|
* POST /admin/about/deploy — Deploy Arbiter (moved from sidebar)
|
||||||
|
* GET /admin/about/status — Arbiter health check
|
||||||
|
*
|
||||||
|
* Chronicler #78 | April 11, 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MODULES = [
|
||||||
|
{ name: 'Dashboard', version: '1.0.0', path: '/admin/dashboard', icon: '📊', status: 'stable' },
|
||||||
|
{ name: 'Tasks', version: '1.0.0', path: '/admin/tasks', icon: '📋', status: 'new' },
|
||||||
|
{ name: 'Servers', version: '1.0.0', path: '/admin/servers', icon: '🖥️', status: 'stable' },
|
||||||
|
{ name: 'Players', version: '1.0.0', path: '/admin/players', icon: '👥', status: 'stable' },
|
||||||
|
{ name: 'Financials', version: '1.0.0', path: '/admin/financials', icon: '💰', status: 'stable' },
|
||||||
|
{ name: 'Grace Period', version: '1.0.0', path: '/admin/grace', icon: '⏳', status: 'stable' },
|
||||||
|
{ name: 'Discord', version: '1.0.0', path: '/admin/discord', icon: '💬', status: 'stable' },
|
||||||
|
{ name: 'Social', version: '1.0.0', path: '/admin/social', icon: '📈', status: 'stable' },
|
||||||
|
{ name: 'Infrastructure', version: '1.0.0', path: '/admin/infrastructure', icon: '🌐', status: 'new' },
|
||||||
|
{ name: 'Restart Scheduler', version: '1.0.0', path: '/admin/scheduler', icon: '⏰', status: 'stable' },
|
||||||
|
{ name: 'Audit Log', version: '1.0.0', path: '/admin/audit', icon: '📋', status: 'stable' },
|
||||||
|
{ name: 'Role Audit', version: '1.0.0', path: '/admin/roles', icon: '🔍', status: 'stable' },
|
||||||
|
{ name: 'MCP Logs', version: '1.0.0', path: '/admin/mcp-logs', icon: '🖥️', status: 'new' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getNodeVersion() {
|
||||||
|
return process.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArbiterUptime() {
|
||||||
|
return process.uptime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(seconds) {
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (days > 0) return `${days}d ${hours}h ${mins}m`;
|
||||||
|
if (hours > 0) return `${hours}h ${mins}m`;
|
||||||
|
return `${mins}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/about
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(require.resolve('../../../package.json'), 'utf8'));
|
||||||
|
|
||||||
|
res.render('admin/about/index', {
|
||||||
|
title: 'About',
|
||||||
|
currentPath: '/about',
|
||||||
|
consoleVersion: pkg.version || '3.5.0',
|
||||||
|
nodeVersion: getNodeVersion(),
|
||||||
|
arbiterUptime: formatUptime(getArbiterUptime()),
|
||||||
|
modules: MODULES,
|
||||||
|
totalModules: MODULES.length,
|
||||||
|
adminUser: req.user,
|
||||||
|
layout: 'layout'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /admin/about/deploy — Deploy Arbiter
|
||||||
|
router.post('/deploy', (req, res) => {
|
||||||
|
const username = req.user?.username || 'unknown';
|
||||||
|
console.log(`[DEPLOY] Deployment initiated by ${username} from About page`);
|
||||||
|
exec(`nohup sudo /opt/scripts/deploy-arbiter.sh "${username}" > /tmp/deploy.log 2>&1 &`);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Deploy started. Arbiter will restart momentarily.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /admin/about/status — Health check
|
||||||
|
router.get('/status', (req, res) => {
|
||||||
|
exec('systemctl is-active arbiter-3', (error, stdout) => {
|
||||||
|
const isRunning = stdout.trim() === 'active';
|
||||||
|
res.json({
|
||||||
|
arbiter: isRunning ? 'running' : 'stopped',
|
||||||
|
uptime: formatUptime(getArbiterUptime()),
|
||||||
|
deployAvailable: fs.existsSync('/opt/scripts/deploy-arbiter.sh')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
300
services/arbiter-3.0/src/routes/admin/discord-audit.js
Normal file
300
services/arbiter-3.0/src/routes/admin/discord-audit.js
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* Discord Audit Routes
|
||||||
|
* Provides server structure auditing via Trinity Console
|
||||||
|
*
|
||||||
|
* Created: April 8, 2026
|
||||||
|
* Chronicler: #70
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/discord
|
||||||
|
* Main Discord audit dashboard
|
||||||
|
*/
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const client = req.app.locals.client;
|
||||||
|
const guildId = process.env.GUILD_ID;
|
||||||
|
|
||||||
|
if (!client || !client.isReady()) {
|
||||||
|
return res.render('admin/discord/index', {
|
||||||
|
title: 'Discord',
|
||||||
|
error: 'Discord client not ready',
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = client.guilds.cache.get(guildId);
|
||||||
|
if (!guild) {
|
||||||
|
return res.render('admin/discord/index', {
|
||||||
|
title: 'Discord',
|
||||||
|
error: 'Guild not found',
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh data
|
||||||
|
await guild.channels.fetch();
|
||||||
|
await guild.roles.fetch();
|
||||||
|
|
||||||
|
// Build channel structure
|
||||||
|
const channels = guild.channels.cache.map(ch => ({
|
||||||
|
id: ch.id,
|
||||||
|
name: ch.name,
|
||||||
|
type: ch.type,
|
||||||
|
typeName: getChannelTypeName(ch.type),
|
||||||
|
parentId: ch.parentId,
|
||||||
|
position: ch.position,
|
||||||
|
nsfw: ch.nsfw || false,
|
||||||
|
topic: ch.topic || null,
|
||||||
|
permissionOverwrites: ch.permissionOverwrites?.cache.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
type: p.type,
|
||||||
|
allow: p.allow.bitfield.toString(),
|
||||||
|
deny: p.deny.bitfield.toString()
|
||||||
|
})) || []
|
||||||
|
})).sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
// Build role structure with permission overwrites lookup
|
||||||
|
const roles = guild.roles.cache.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
color: r.hexColor,
|
||||||
|
position: r.position,
|
||||||
|
permissions: r.permissions.bitfield.toString(),
|
||||||
|
mentionable: r.mentionable,
|
||||||
|
managed: r.managed,
|
||||||
|
memberCount: r.members.size
|
||||||
|
})).sort((a, b) => b.position - a.position);
|
||||||
|
|
||||||
|
// Categories with their children
|
||||||
|
const categories = channels
|
||||||
|
.filter(ch => ch.type === 4)
|
||||||
|
.map(cat => ({
|
||||||
|
...cat,
|
||||||
|
children: channels.filter(ch => ch.parentId === cat.id)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Orphan channels
|
||||||
|
const orphanChannels = channels.filter(ch => !ch.parentId && ch.type !== 4);
|
||||||
|
|
||||||
|
// Server info
|
||||||
|
const serverInfo = {
|
||||||
|
id: guild.id,
|
||||||
|
name: guild.name,
|
||||||
|
memberCount: guild.memberCount,
|
||||||
|
ownerId: guild.ownerId,
|
||||||
|
createdAt: guild.createdAt,
|
||||||
|
icon: guild.iconURL(),
|
||||||
|
features: guild.features
|
||||||
|
};
|
||||||
|
|
||||||
|
// Health checks
|
||||||
|
const healthChecks = {
|
||||||
|
orphanChannels: orphanChannels.length,
|
||||||
|
emptyRoles: roles.filter(r => r.memberCount === 0 && !r.managed && r.name !== '@everyone').length,
|
||||||
|
botRoles: roles.filter(r => r.managed).length
|
||||||
|
};
|
||||||
|
|
||||||
|
res.render('admin/discord/index', {
|
||||||
|
title: 'Discord',
|
||||||
|
error: null,
|
||||||
|
data: {
|
||||||
|
server: serverInfo,
|
||||||
|
categories,
|
||||||
|
orphanChannels,
|
||||||
|
allChannels: channels,
|
||||||
|
roles,
|
||||||
|
healthChecks,
|
||||||
|
summary: {
|
||||||
|
totalChannels: channels.length,
|
||||||
|
totalRoles: roles.length,
|
||||||
|
categoryCount: categories.length,
|
||||||
|
orphanCount: orphanChannels.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Discord dashboard error:', error);
|
||||||
|
res.render('admin/discord/index', {
|
||||||
|
title: 'Discord',
|
||||||
|
error: error.message,
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/discord/audit
|
||||||
|
* Full Discord server audit - channels, roles, members
|
||||||
|
*/
|
||||||
|
router.get('/audit', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const client = req.app.locals.client;
|
||||||
|
const guildId = process.env.GUILD_ID;
|
||||||
|
|
||||||
|
if (!client || !client.isReady()) {
|
||||||
|
return res.status(503).json({ error: 'Discord client not ready' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = client.guilds.cache.get(guildId);
|
||||||
|
if (!guild) {
|
||||||
|
return res.status(404).json({ error: 'Guild not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh data
|
||||||
|
await guild.channels.fetch();
|
||||||
|
await guild.roles.fetch();
|
||||||
|
|
||||||
|
// Build channel structure
|
||||||
|
const channels = guild.channels.cache.map(ch => ({
|
||||||
|
id: ch.id,
|
||||||
|
name: ch.name,
|
||||||
|
type: ch.type,
|
||||||
|
typeName: getChannelTypeName(ch.type),
|
||||||
|
parentId: ch.parentId,
|
||||||
|
position: ch.position,
|
||||||
|
nsfw: ch.nsfw || false,
|
||||||
|
topic: ch.topic || null,
|
||||||
|
permissionOverwrites: ch.permissionOverwrites?.cache.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
type: p.type, // 0 = role, 1 = member
|
||||||
|
allow: p.allow.bitfield.toString(),
|
||||||
|
deny: p.deny.bitfield.toString()
|
||||||
|
})) || []
|
||||||
|
})).sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
// Build role structure
|
||||||
|
const roles = guild.roles.cache.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
color: r.hexColor,
|
||||||
|
position: r.position,
|
||||||
|
permissions: r.permissions.bitfield.toString(),
|
||||||
|
mentionable: r.mentionable,
|
||||||
|
managed: r.managed, // Bot roles
|
||||||
|
memberCount: r.members.size
|
||||||
|
})).sort((a, b) => b.position - a.position); // Higher position first
|
||||||
|
|
||||||
|
// Categories with their children
|
||||||
|
const categories = channels
|
||||||
|
.filter(ch => ch.type === 4)
|
||||||
|
.map(cat => ({
|
||||||
|
...cat,
|
||||||
|
children: channels.filter(ch => ch.parentId === cat.id)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Orphan channels (not in any category)
|
||||||
|
const orphanChannels = channels.filter(ch => !ch.parentId && ch.type !== 4);
|
||||||
|
|
||||||
|
// Server info
|
||||||
|
const serverInfo = {
|
||||||
|
id: guild.id,
|
||||||
|
name: guild.name,
|
||||||
|
memberCount: guild.memberCount,
|
||||||
|
ownerId: guild.ownerId,
|
||||||
|
createdAt: guild.createdAt,
|
||||||
|
icon: guild.iconURL(),
|
||||||
|
features: guild.features
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
server: serverInfo,
|
||||||
|
categories,
|
||||||
|
orphanChannels,
|
||||||
|
allChannels: channels,
|
||||||
|
roles,
|
||||||
|
summary: {
|
||||||
|
totalChannels: channels.length,
|
||||||
|
totalRoles: roles.length,
|
||||||
|
categoryCount: categories.length,
|
||||||
|
orphanCount: orphanChannels.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Discord audit error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/discord/channels
|
||||||
|
* Just channels
|
||||||
|
*/
|
||||||
|
router.get('/channels', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const client = req.app.locals.client;
|
||||||
|
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||||
|
|
||||||
|
if (!guild) return res.status(404).json({ error: 'Guild not found' });
|
||||||
|
|
||||||
|
await guild.channels.fetch();
|
||||||
|
|
||||||
|
const channels = guild.channels.cache.map(ch => ({
|
||||||
|
id: ch.id,
|
||||||
|
name: ch.name,
|
||||||
|
type: ch.type,
|
||||||
|
typeName: getChannelTypeName(ch.type),
|
||||||
|
parentId: ch.parentId,
|
||||||
|
position: ch.position
|
||||||
|
})).sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
res.json({ channels });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/discord/roles
|
||||||
|
* Just roles
|
||||||
|
*/
|
||||||
|
router.get('/roles', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const client = req.app.locals.client;
|
||||||
|
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||||
|
|
||||||
|
if (!guild) return res.status(404).json({ error: 'Guild not found' });
|
||||||
|
|
||||||
|
await guild.roles.fetch();
|
||||||
|
|
||||||
|
const roles = guild.roles.cache.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
color: r.hexColor,
|
||||||
|
position: r.position,
|
||||||
|
memberCount: r.members.size,
|
||||||
|
managed: r.managed
|
||||||
|
})).sort((a, b) => b.position - a.position);
|
||||||
|
|
||||||
|
res.json({ roles });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Channel type to human name
|
||||||
|
*/
|
||||||
|
function getChannelTypeName(type) {
|
||||||
|
const types = {
|
||||||
|
0: 'Text',
|
||||||
|
2: 'Voice',
|
||||||
|
4: 'Category',
|
||||||
|
5: 'Announcement',
|
||||||
|
10: 'Announcement Thread',
|
||||||
|
11: 'Public Thread',
|
||||||
|
12: 'Private Thread',
|
||||||
|
13: 'Stage',
|
||||||
|
14: 'Directory',
|
||||||
|
15: 'Forum',
|
||||||
|
16: 'Media'
|
||||||
|
};
|
||||||
|
return types[type] || `Unknown (${type})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { requireTrinityAccess } = require('./middleware');
|
const { requireTrinityAccess } = require('./middleware');
|
||||||
|
const { getMinecraftServers } = require('../../panel/discovery');
|
||||||
|
const db = require('../../database');
|
||||||
|
|
||||||
// Sub-routers
|
// Sub-routers
|
||||||
const playersRouter = require('./players');
|
const playersRouter = require('./players');
|
||||||
@@ -9,6 +11,14 @@ const financialsRouter = require('./financials');
|
|||||||
const graceRouter = require('./grace');
|
const graceRouter = require('./grace');
|
||||||
const auditRouter = require('./audit');
|
const auditRouter = require('./audit');
|
||||||
const rolesRouter = require('./roles');
|
const rolesRouter = require('./roles');
|
||||||
|
const schedulerRouter = require('./scheduler');
|
||||||
|
const discordAuditRouter = require('./discord-audit');
|
||||||
|
const systemRouter = require('./system');
|
||||||
|
const socialRouter = require('./social');
|
||||||
|
const infrastructureRouter = require('./infrastructure');
|
||||||
|
const aboutRouter = require('./about');
|
||||||
|
const mcpLogsRouter = require('./mcp-logs');
|
||||||
|
const tasksRouter = require('./tasks');
|
||||||
|
|
||||||
router.use(requireTrinityAccess);
|
router.use(requireTrinityAccess);
|
||||||
|
|
||||||
@@ -22,8 +32,83 @@ router.get('/', (req, res) => {
|
|||||||
res.redirect('/admin/dashboard');
|
res.redirect('/admin/dashboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/dashboard', (req, res) => {
|
router.get('/dashboard', async (req, res) => {
|
||||||
res.render('admin/dashboard', { title: 'Command Bridge' });
|
try {
|
||||||
|
// Fetch server count from Pterodactyl
|
||||||
|
const servers = await getMinecraftServers();
|
||||||
|
const serversOnline = servers.length;
|
||||||
|
|
||||||
|
// Fetch subscriber stats from database
|
||||||
|
const { rows: subStats } = await db.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status IN ('active', 'grace_period') OR is_lifetime = true) as active_count,
|
||||||
|
COALESCE(SUM(mrr_value) FILTER (WHERE status = 'active'), 0) as mrr
|
||||||
|
FROM subscriptions
|
||||||
|
`);
|
||||||
|
|
||||||
|
const activeSubscribers = parseInt(subStats[0]?.active_count || 0);
|
||||||
|
const totalMRR = parseFloat(subStats[0]?.mrr || 0);
|
||||||
|
|
||||||
|
// Fetch most recent successful sync time
|
||||||
|
const { rows: syncRows } = await db.query(`
|
||||||
|
SELECT MAX(last_successful_sync) as last_sync
|
||||||
|
FROM server_sync_log
|
||||||
|
WHERE is_online = true
|
||||||
|
`);
|
||||||
|
const lastSyncTime = syncRows[0]?.last_sync || null;
|
||||||
|
|
||||||
|
// Fetch social stats across all platforms
|
||||||
|
const { rows: socialStats } = await db.query(`
|
||||||
|
SELECT
|
||||||
|
platform,
|
||||||
|
COUNT(*) as post_count,
|
||||||
|
COALESCE(SUM(views), 0) as total_views,
|
||||||
|
COALESCE(SUM(likes), 0) as total_likes,
|
||||||
|
COALESCE(SUM(comments), 0) as total_comments
|
||||||
|
FROM social_posts
|
||||||
|
GROUP BY platform
|
||||||
|
`);
|
||||||
|
|
||||||
|
const socialTotals = {
|
||||||
|
posts: 0,
|
||||||
|
views: 0,
|
||||||
|
likes: 0,
|
||||||
|
comments: 0,
|
||||||
|
platforms: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of socialStats) {
|
||||||
|
socialTotals.posts += parseInt(row.post_count);
|
||||||
|
socialTotals.views += parseInt(row.total_views);
|
||||||
|
socialTotals.likes += parseInt(row.total_likes);
|
||||||
|
socialTotals.comments += parseInt(row.total_comments);
|
||||||
|
socialTotals.platforms[row.platform] = {
|
||||||
|
posts: parseInt(row.post_count),
|
||||||
|
views: parseInt(row.total_views),
|
||||||
|
likes: parseInt(row.total_likes)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render('admin/dashboard', {
|
||||||
|
title: 'Command Bridge',
|
||||||
|
serversOnline,
|
||||||
|
activeSubscribers,
|
||||||
|
totalMRR,
|
||||||
|
lastSyncTime,
|
||||||
|
socialTotals
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard data fetch error:', error);
|
||||||
|
// Fallback to zeros on error
|
||||||
|
res.render('admin/dashboard', {
|
||||||
|
title: 'Command Bridge',
|
||||||
|
serversOnline: 0,
|
||||||
|
activeSubscribers: 0,
|
||||||
|
totalMRR: 0,
|
||||||
|
lastSyncTime: null,
|
||||||
|
socialTotals: { posts: 0, views: 0, likes: 0, comments: 0, platforms: {} }
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.use('/players', playersRouter);
|
router.use('/players', playersRouter);
|
||||||
@@ -32,5 +117,13 @@ router.use('/financials', financialsRouter);
|
|||||||
router.use('/grace', graceRouter);
|
router.use('/grace', graceRouter);
|
||||||
router.use('/audit', auditRouter);
|
router.use('/audit', auditRouter);
|
||||||
router.use('/roles', rolesRouter);
|
router.use('/roles', rolesRouter);
|
||||||
|
router.use('/scheduler', schedulerRouter);
|
||||||
|
router.use('/discord', discordAuditRouter);
|
||||||
|
router.use('/system', systemRouter);
|
||||||
|
router.use('/social', socialRouter);
|
||||||
|
router.use('/infrastructure', infrastructureRouter);
|
||||||
|
router.use('/about', aboutRouter);
|
||||||
|
router.use('/mcp-logs', mcpLogsRouter);
|
||||||
|
router.use('/tasks', tasksRouter);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
265
services/arbiter-3.0/src/routes/admin/infrastructure.js
Normal file
265
services/arbiter-3.0/src/routes/admin/infrastructure.js
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infrastructure Module — Trinity Console
|
||||||
|
*
|
||||||
|
* Live infrastructure topology and server health monitoring.
|
||||||
|
* Data sourced from Trinity Core MCP gateway via HTTP API.
|
||||||
|
*
|
||||||
|
* GET /admin/infrastructure — Main topology view
|
||||||
|
* GET /admin/infrastructure/refresh — Force cache refresh (HTMX)
|
||||||
|
* GET /admin/infrastructure/server/:id — Server detail partial (HTMX)
|
||||||
|
*
|
||||||
|
* Chronicler #78 | April 11, 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TRINITY_CORE_URL = 'https://mcp.firefrostgaming.com';
|
||||||
|
const TRINITY_CORE_TOKEN = 'FFG-Trinity-2026-Core-Access';
|
||||||
|
const CACHE_TTL = 60000; // 60 seconds
|
||||||
|
|
||||||
|
// Server definitions (matches Trinity Core's SERVERS config)
|
||||||
|
const SERVERS = {
|
||||||
|
'command-center': { label: 'Command Center', role: 'Management Hub', color: '#A855F7' },
|
||||||
|
'tx1-dallas': { label: 'TX1 Dallas', role: 'Game Node (Primary)', color: '#FF6B35' },
|
||||||
|
'nc1-charlotte': { label: 'NC1 Charlotte', role: 'Game Node (Secondary)', color: '#4ECDC4' },
|
||||||
|
'panel-vps': { label: 'Panel VPS', role: 'Pterodactyl Panel', color: '#3b82f6' },
|
||||||
|
'dev-panel': { label: 'Dev Panel', role: 'Development & Testing', color: '#6b7280' },
|
||||||
|
'wiki-vps': { label: 'Wiki VPS', role: 'Knowledge Base', color: '#06b6d4' },
|
||||||
|
'services-vps': { label: 'Services VPS', role: 'Email & Services', color: '#f59e0b' },
|
||||||
|
'trinity-core': { label: 'Trinity Core', role: 'MCP Gateway (Pi)', color: '#A855F7' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// In-memory cache
|
||||||
|
let auditCache = { data: null, lastFetch: 0 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a command on a server via Trinity Core
|
||||||
|
*/
|
||||||
|
async function trinityExec(server, command) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${TRINITY_CORE_URL}/exec`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${TRINITY_CORE_TOKEN}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ server, command })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Trinity Core error: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return data.output || data.stdout || '';
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[INFRA] trinityExec failed for ${server}:`, err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the standard audit command output
|
||||||
|
*/
|
||||||
|
function parseAuditOutput(output) {
|
||||||
|
if (!output) return null;
|
||||||
|
const lines = output.split('\n');
|
||||||
|
const get = (marker) => {
|
||||||
|
const idx = lines.findIndex(l => l.includes(marker));
|
||||||
|
return idx >= 0 && idx + 1 < lines.length ? lines[idx + 1]?.trim() : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse RAM line: "Mem: 3.8Gi 1.4Gi 803Mi ..."
|
||||||
|
const memLine = get('=== RAM ===');
|
||||||
|
const memParts = memLine.split(/\s+/);
|
||||||
|
const totalRam = memParts[1] || '?';
|
||||||
|
const usedRam = memParts[2] || '?';
|
||||||
|
|
||||||
|
// Parse disk line: "/dev/xxx 38G 17G 21G 45% /"
|
||||||
|
const diskLine = get('=== DISK ===');
|
||||||
|
const diskParts = diskLine.split(/\s+/);
|
||||||
|
const totalDisk = diskParts[1] || '?';
|
||||||
|
const usedDisk = diskParts[2] || '?';
|
||||||
|
const diskPct = parseInt(diskParts[4]) || 0;
|
||||||
|
|
||||||
|
// Parse uptime: "05:03:05 up 61 days, ..."
|
||||||
|
const uptimeLine = get('=== UPTIME ===');
|
||||||
|
const uptimeMatch = uptimeLine.match(/up\s+(.+?),\s+\d+\s+user/);
|
||||||
|
const uptime = uptimeMatch ? uptimeMatch[1].trim() : uptimeLine;
|
||||||
|
|
||||||
|
// Parse load averages
|
||||||
|
const loadMatch = uptimeLine.match(/load average:\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)/);
|
||||||
|
const load = loadMatch ? [parseFloat(loadMatch[1]), parseFloat(loadMatch[2]), parseFloat(loadMatch[3])] : [0, 0, 0];
|
||||||
|
|
||||||
|
// Parse restart required
|
||||||
|
const restartLine = get('=== RESTART ===');
|
||||||
|
const restart = restartLine.includes('restart required');
|
||||||
|
|
||||||
|
// Parse hostname, OS, kernel, CPU
|
||||||
|
const hostname = get('=== HOSTNAME ===');
|
||||||
|
const osLines = output.split('\n').filter(l => l.startsWith('NAME=') || l.startsWith('VERSION='));
|
||||||
|
const osName = osLines.map(l => l.split('=')[1]?.replace(/"/g, '')).join(' ');
|
||||||
|
const kernel = get('=== KERNEL ===');
|
||||||
|
const cpuModel = lines.find(l => l.includes('Model name'))?.split(':')[1]?.trim() || '?';
|
||||||
|
const cpuCores = lines.find(l => l.match(/^CPU\(s\)/))?.split(':')[1]?.trim() || '?';
|
||||||
|
|
||||||
|
// Parse RAM percentage (handle Gi vs Mi units)
|
||||||
|
function toGi(val) {
|
||||||
|
if (!val) return 0;
|
||||||
|
const num = parseFloat(val);
|
||||||
|
if (val.includes('Mi')) return num / 1024;
|
||||||
|
if (val.includes('Gi')) return num;
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
const ramPct = toGi(totalRam) > 0
|
||||||
|
? Math.round((toGi(usedRam) / toGi(totalRam)) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hostname,
|
||||||
|
os: osName,
|
||||||
|
kernel,
|
||||||
|
cpu: `${cpuModel} (${cpuCores} cores)`,
|
||||||
|
ram: { total: totalRam, used: usedRam, pct: ramPct },
|
||||||
|
disk: { total: totalDisk, used: usedDisk, pct: diskPct },
|
||||||
|
uptime,
|
||||||
|
load,
|
||||||
|
restart,
|
||||||
|
diskWarning: diskPct >= 70,
|
||||||
|
status: diskPct >= 85 ? 'critical' : diskPct >= 70 ? 'warning' : restart ? 'warning' : 'healthy'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch audit data from all servers in parallel
|
||||||
|
*/
|
||||||
|
async function fetchFleetAudit() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (auditCache.data && (now - auditCache.lastFetch < CACHE_TTL)) {
|
||||||
|
return auditCache.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[INFRA] Fetching fleet audit from Trinity Core...');
|
||||||
|
|
||||||
|
const auditCommand = [
|
||||||
|
'echo "=== HOSTNAME ===" && hostname',
|
||||||
|
'echo "=== OS ===" && cat /etc/os-release | grep -E "^(NAME|VERSION)="',
|
||||||
|
'echo "=== CPU ===" && lscpu | grep -E "^(Architecture|CPU\\(s\\)|Model name|Thread)"',
|
||||||
|
'echo "=== RAM ===" && free -h | grep Mem',
|
||||||
|
'echo "=== DISK ===" && df -h / | tail -1',
|
||||||
|
'echo "=== KERNEL ===" && uname -r',
|
||||||
|
'echo "=== RESTART ===" && [ -f /var/run/reboot-required ] && cat /var/run/reboot-required || echo "No restart required"',
|
||||||
|
'echo "=== UPTIME ===" && uptime'
|
||||||
|
].join(' && ');
|
||||||
|
|
||||||
|
// Run all server audits in parallel
|
||||||
|
const serverIds = Object.keys(SERVERS);
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
serverIds.map(id => trinityExec(id, auditCommand))
|
||||||
|
);
|
||||||
|
|
||||||
|
const audit = {};
|
||||||
|
serverIds.forEach((id, i) => {
|
||||||
|
const output = results[i].status === 'fulfilled' ? results[i].value : null;
|
||||||
|
const parsed = parseAuditOutput(output);
|
||||||
|
audit[id] = {
|
||||||
|
...SERVERS[id],
|
||||||
|
...(parsed || { status: 'offline', error: 'Could not reach server' }),
|
||||||
|
online: !!parsed
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch game servers from Pterodactyl
|
||||||
|
let gameServers = [];
|
||||||
|
try {
|
||||||
|
const panelRes = await fetch(`${process.env.PANEL_URL}/api/application/servers?per_page=50`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${process.env.PANEL_APPLICATION_KEY}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (panelRes.ok) {
|
||||||
|
const panelData = await panelRes.json();
|
||||||
|
const nodeMap = { 2: 'NC1', 3: 'TX1' };
|
||||||
|
gameServers = panelData.data.map(s => ({
|
||||||
|
name: s.attributes.name,
|
||||||
|
uuid: s.attributes.uuid,
|
||||||
|
node: nodeMap[s.attributes.node] || `Node ${s.attributes.node}`,
|
||||||
|
nodeId: s.attributes.node,
|
||||||
|
ram: s.attributes.limits.memory,
|
||||||
|
disk: s.attributes.limits.disk,
|
||||||
|
suspended: s.attributes.suspended
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[INFRA] Pterodactyl API error:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fleetData = {
|
||||||
|
servers: audit,
|
||||||
|
gameServers,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
summary: {
|
||||||
|
totalServers: serverIds.length,
|
||||||
|
online: Object.values(audit).filter(s => s.online).length,
|
||||||
|
needRestart: Object.values(audit).filter(s => s.restart).length,
|
||||||
|
totalGameServers: gameServers.length,
|
||||||
|
tx1Games: gameServers.filter(g => g.node === 'TX1').length,
|
||||||
|
nc1Games: gameServers.filter(g => g.node === 'NC1').length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
auditCache = { data: fleetData, lastFetch: now };
|
||||||
|
console.log(`[INFRA] Fleet audit complete: ${fleetData.summary.online}/${fleetData.summary.totalServers} online`);
|
||||||
|
|
||||||
|
return fleetData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/infrastructure — Main page
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const fleet = await fetchFleetAudit();
|
||||||
|
res.render('admin/infrastructure/index', {
|
||||||
|
title: 'Infrastructure',
|
||||||
|
currentPath: '/infrastructure',
|
||||||
|
fleet,
|
||||||
|
adminUser: req.user,
|
||||||
|
layout: 'layout'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[INFRA] Route error:', err);
|
||||||
|
res.render('admin/infrastructure/index', {
|
||||||
|
title: 'Infrastructure',
|
||||||
|
currentPath: '/infrastructure',
|
||||||
|
fleet: null,
|
||||||
|
error: err.message,
|
||||||
|
adminUser: req.user,
|
||||||
|
layout: 'layout'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /admin/infrastructure/refresh — Force refresh (HTMX)
|
||||||
|
router.get('/refresh', async (req, res) => {
|
||||||
|
auditCache = { data: null, lastFetch: 0 };
|
||||||
|
try {
|
||||||
|
const fleet = await fetchFleetAudit();
|
||||||
|
res.json({ success: true, fleet, fetchedAt: fleet.fetchedAt });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /admin/infrastructure/server/:id — Server detail (JSON for client)
|
||||||
|
router.get('/server/:id', async (req, res) => {
|
||||||
|
const fleet = await fetchFleetAudit();
|
||||||
|
const server = fleet.servers[req.params.id];
|
||||||
|
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
|
const games = fleet.gameServers.filter(g => {
|
||||||
|
if (req.params.id === 'tx1-dallas') return g.node === 'TX1';
|
||||||
|
if (req.params.id === 'nc1-charlotte') return g.node === 'NC1';
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ server, games });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
98
services/arbiter-3.0/src/routes/admin/mcp-logs.js
Normal file
98
services/arbiter-3.0/src/routes/admin/mcp-logs.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require('../../database');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Logs Module — Trinity Console
|
||||||
|
*
|
||||||
|
* View and filter command execution logs from Trinity Core.
|
||||||
|
*
|
||||||
|
* GET /admin/mcp-logs — Main logs page with filters
|
||||||
|
*
|
||||||
|
* Chronicler #78 | April 11, 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SERVERS = [
|
||||||
|
'command-center', 'tx1-dallas', 'nc1-charlotte',
|
||||||
|
'panel-vps', 'dev-panel', 'wiki-vps', 'services-vps', 'trinity-core'
|
||||||
|
];
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { server, success, limit = 50, offset = 0 } = req.query;
|
||||||
|
|
||||||
|
// Build filtered query
|
||||||
|
let where = 'WHERE 1=1';
|
||||||
|
const params = [];
|
||||||
|
let p = 0;
|
||||||
|
|
||||||
|
if (server) {
|
||||||
|
p++;
|
||||||
|
where += ` AND server = $${p}`;
|
||||||
|
params.push(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success !== undefined && success !== '') {
|
||||||
|
p++;
|
||||||
|
where += ` AND success = $${p}`;
|
||||||
|
params.push(success === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get logs
|
||||||
|
const logsResult = await db.query(
|
||||||
|
`SELECT * FROM mcp_logs ${where} ORDER BY executed_at DESC LIMIT $${p + 1} OFFSET $${p + 2}`,
|
||||||
|
[...params, parseInt(limit), parseInt(offset)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const countResult = await db.query(
|
||||||
|
`SELECT COUNT(*) FROM mcp_logs ${where}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const total = parseInt(countResult.rows[0].count);
|
||||||
|
|
||||||
|
// Get stats
|
||||||
|
const statsResult = await db.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(*) FILTER (WHERE success = true) as success_count,
|
||||||
|
COUNT(*) FILTER (WHERE success = false) as fail_count,
|
||||||
|
COALESCE(ROUND(AVG(execution_time_ms)), 0) as avg_time
|
||||||
|
FROM mcp_logs
|
||||||
|
`);
|
||||||
|
|
||||||
|
const stats = statsResult.rows[0];
|
||||||
|
|
||||||
|
res.render('admin/mcp-logs/index', {
|
||||||
|
title: 'MCP Logs',
|
||||||
|
currentPath: '/mcp-logs',
|
||||||
|
logs: logsResult.rows,
|
||||||
|
total,
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
query: req.query,
|
||||||
|
stats,
|
||||||
|
servers: SERVERS,
|
||||||
|
adminUser: req.user,
|
||||||
|
layout: 'layout'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[MCP Logs] Route error:', err);
|
||||||
|
res.render('admin/mcp-logs/index', {
|
||||||
|
title: 'MCP Logs',
|
||||||
|
currentPath: '/mcp-logs',
|
||||||
|
logs: [],
|
||||||
|
total: 0,
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
query: {},
|
||||||
|
stats: { total: 0, success_count: 0, fail_count: 0, avg_time: 0 },
|
||||||
|
servers: SERVERS,
|
||||||
|
error: err.message,
|
||||||
|
adminUser: req.user,
|
||||||
|
layout: 'layout'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
function requireTrinityAccess(req, res, next) {
|
function requireTrinityAccess(req, res, next) {
|
||||||
|
// Allow localhost requests (for curl debugging from Command Center)
|
||||||
|
const ip = req.ip || req.connection.remoteAddress;
|
||||||
|
if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') {
|
||||||
|
res.locals.adminUser = { username: 'localhost', id: 'localhost' };
|
||||||
|
res.locals.currentPath = req.path;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
if (!req.isAuthenticated()) {
|
if (!req.isAuthenticated()) {
|
||||||
return res.redirect('/auth/discord');
|
return res.redirect('/auth/discord');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,64 @@ router.get('/', async (req, res) => {
|
|||||||
res.render('admin/players/index', { title: 'Player Management', tiers: TIER_INFO });
|
res.render('admin/players/index', { title: 'Player Management', tiers: TIER_INFO });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Export all players as CSV
|
||||||
|
router.get('/export', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows: players } = await db.query(`
|
||||||
|
SELECT
|
||||||
|
s.discord_id,
|
||||||
|
u.minecraft_username,
|
||||||
|
u.minecraft_uuid,
|
||||||
|
COALESCE(u.is_staff, false) as is_staff,
|
||||||
|
s.tier_level,
|
||||||
|
s.status,
|
||||||
|
s.mrr_value,
|
||||||
|
s.is_lifetime,
|
||||||
|
s.stripe_customer_id,
|
||||||
|
s.created_at,
|
||||||
|
s.updated_at
|
||||||
|
FROM subscriptions s
|
||||||
|
LEFT JOIN users u ON s.discord_id = u.discord_id
|
||||||
|
ORDER BY s.updated_at DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Build CSV
|
||||||
|
const headers = ['discord_id', 'minecraft_username', 'minecraft_uuid', 'is_staff', 'tier_level', 'tier_name', 'status', 'mrr_value', 'is_lifetime', 'stripe_customer_id', 'created_at', 'updated_at'];
|
||||||
|
|
||||||
|
const csvRows = [headers.join(',')];
|
||||||
|
|
||||||
|
for (const player of players) {
|
||||||
|
const tierName = TIER_INFO[player.tier_level]?.name || 'Unknown';
|
||||||
|
const row = [
|
||||||
|
player.discord_id || '',
|
||||||
|
player.minecraft_username || '',
|
||||||
|
player.minecraft_uuid || '',
|
||||||
|
player.is_staff ? 'true' : 'false',
|
||||||
|
player.tier_level || '',
|
||||||
|
tierName,
|
||||||
|
player.status || '',
|
||||||
|
player.mrr_value || '0',
|
||||||
|
player.is_lifetime ? 'true' : 'false',
|
||||||
|
player.stripe_customer_id || '',
|
||||||
|
player.created_at ? new Date(player.created_at).toISOString() : '',
|
||||||
|
player.updated_at ? new Date(player.updated_at).toISOString() : ''
|
||||||
|
].map(val => `"${String(val).replace(/"/g, '""')}"`);
|
||||||
|
csvRows.push(row.join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
const csv = csvRows.join('\n');
|
||||||
|
const filename = `firefrost-players-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.send(csv);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export error:', error);
|
||||||
|
res.status(500).send('Error exporting players');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// HTMX Endpoint for the table body (Handles pagination, sorting, searching)
|
// HTMX Endpoint for the table body (Handles pagination, sorting, searching)
|
||||||
router.get('/table', async (req, res) => {
|
router.get('/table', async (req, res) => {
|
||||||
const page = parseInt(req.query.page) || 1;
|
const page = parseInt(req.query.page) || 1;
|
||||||
|
|||||||
401
services/arbiter-3.0/src/routes/admin/scheduler.js
Normal file
401
services/arbiter-3.0/src/routes/admin/scheduler.js
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require('../../database');
|
||||||
|
const { calculateStagger } = require('../../utils/scheduler');
|
||||||
|
const { syncToPterodactyl, auditServerSchedules, deleteSchedule, syncAllForNode, sleep } = require('../../lib/ptero-sync');
|
||||||
|
|
||||||
|
// GET /admin/scheduler - Main page
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Get config for both nodes
|
||||||
|
const configResult = await db.query('SELECT * FROM global_restart_config ORDER BY node');
|
||||||
|
const configs = configResult.rows;
|
||||||
|
|
||||||
|
// Get all servers ordered by node and sort_order
|
||||||
|
const serversResult = await db.query(`
|
||||||
|
SELECT s.*, c.base_time, c.interval_minutes
|
||||||
|
FROM server_restart_schedules s
|
||||||
|
JOIN global_restart_config c ON s.node = c.node
|
||||||
|
ORDER BY s.node, s.sort_order
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.render('admin/scheduler', {
|
||||||
|
title: 'Global Restart Scheduler',
|
||||||
|
configs,
|
||||||
|
servers: serversResult.rows
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Scheduler page error:', err);
|
||||||
|
res.status(500).send('Error loading scheduler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /admin/scheduler/table-only - HTMX partial refresh
|
||||||
|
router.get('/table-only', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const serversResult = await db.query(`
|
||||||
|
SELECT s.*, c.base_time, c.interval_minutes
|
||||||
|
FROM server_restart_schedules s
|
||||||
|
JOIN global_restart_config c ON s.node = c.node
|
||||||
|
ORDER BY s.node, s.sort_order
|
||||||
|
`);
|
||||||
|
|
||||||
|
const servers = serversResult.rows;
|
||||||
|
|
||||||
|
let html = `<table class="w-full text-left">
|
||||||
|
<thead class="bg-gray-100 dark:bg-darkbg text-gray-600 dark:text-gray-300">
|
||||||
|
<tr>
|
||||||
|
<th class="p-3 w-10"></th>
|
||||||
|
<th class="p-3">Server</th>
|
||||||
|
<th class="p-3">Node</th>
|
||||||
|
<th class="p-3">Restart Time (Central)</th>
|
||||||
|
<th class="p-3">Status</th>
|
||||||
|
<th class="p-3">Skip</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sortable-servers">`;
|
||||||
|
|
||||||
|
if (servers.length === 0) {
|
||||||
|
html += `<tr><td colspan="6" class="p-6 text-center text-gray-500">No servers imported yet. Click "Import Servers" to populate from Pterodactyl.</td></tr>`;
|
||||||
|
} else {
|
||||||
|
servers.forEach(server => {
|
||||||
|
const nodeClass = server.node === 'TX1' ? 'bg-fire/20 text-fire' : 'bg-frost/20 text-frost';
|
||||||
|
let statusHtml;
|
||||||
|
if (server.sync_status === 'SUCCESS') {
|
||||||
|
statusHtml = `<span class="text-green-500">● Synced</span>`;
|
||||||
|
} else if (server.sync_status === 'FAILED') {
|
||||||
|
statusHtml = `<span class="text-red-500" title="${server.last_error || ''}">✕ Error</span>`;
|
||||||
|
} else {
|
||||||
|
statusHtml = `<span class="text-yellow-500">○ Pending</span>`;
|
||||||
|
}
|
||||||
|
const skipClass = server.skip_restart ? 'bg-red-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300';
|
||||||
|
const skipText = server.skip_restart ? 'Skipped' : 'Active';
|
||||||
|
|
||||||
|
html += `<tr class="border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition" data-id="${server.server_id}">
|
||||||
|
<td class="p-3 cursor-grab text-gray-400 hover:text-gray-900 dark:hover:text-white"><span class="drag-handle text-lg">☰</span></td>
|
||||||
|
<td class="p-3 font-medium">${server.server_name}</td>
|
||||||
|
<td class="p-3"><span class="px-2 py-1 rounded text-xs font-bold ${nodeClass}">${server.node}</span></td>
|
||||||
|
<td class="p-3 font-mono text-sm">${server.effective_time || 'Not set'}</td>
|
||||||
|
<td class="p-3 text-sm">${statusHtml}</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<button hx-post="/admin/scheduler/toggle-skip/${server.server_id}" hx-swap="none" hx-on::after-request="htmx.ajax('GET', '/admin/scheduler/table-only', '#scheduler-table')" class="px-2 py-1 rounded text-xs ${skipClass}">${skipText}</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</tbody></table>`;
|
||||||
|
res.send(html);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).send('Error loading table');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /admin/scheduler/reorder-servers - Handle drag-and-drop reorder
|
||||||
|
router.post('/reorder-servers', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { orderedIds } = req.body;
|
||||||
|
|
||||||
|
// Update sort_order for each server
|
||||||
|
for (let i = 0; i < orderedIds.length; i++) {
|
||||||
|
await db.query(
|
||||||
|
'UPDATE server_restart_schedules SET sort_order = $1 WHERE server_id = $2',
|
||||||
|
[i, orderedIds[i]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate effective times for each node
|
||||||
|
for (const node of ['TX1', 'NC1']) {
|
||||||
|
const configResult = await db.query(
|
||||||
|
'SELECT base_time, interval_minutes FROM global_restart_config WHERE node = $1',
|
||||||
|
[node]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (configResult.rows.length === 0) continue;
|
||||||
|
|
||||||
|
const { base_time, interval_minutes } = configResult.rows[0];
|
||||||
|
|
||||||
|
const serversResult = await db.query(
|
||||||
|
'SELECT server_id FROM server_restart_schedules WHERE node = $1 ORDER BY sort_order',
|
||||||
|
[node]
|
||||||
|
);
|
||||||
|
|
||||||
|
const servers = serversResult.rows;
|
||||||
|
const staggered = calculateStagger(base_time, interval_minutes, servers);
|
||||||
|
|
||||||
|
for (const server of staggered) {
|
||||||
|
await db.query(
|
||||||
|
'UPDATE server_restart_schedules SET effective_time = $1 WHERE server_id = $2',
|
||||||
|
[server.effective_time, server.server_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Reorder error:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /admin/scheduler/update-config - Update node config
|
||||||
|
router.post('/update-config', async (req, res) => {
|
||||||
|
try {
|
||||||
|
let { node, base_time, interval_minutes } = req.body;
|
||||||
|
const updatedBy = req.session?.user?.username || 'Unknown';
|
||||||
|
|
||||||
|
// Normalize time to HH:mm:ss format
|
||||||
|
if (base_time && !base_time.includes(':00', 3)) {
|
||||||
|
base_time = base_time + ':00';
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`UPDATE global_restart_config
|
||||||
|
SET base_time = $1, interval_minutes = $2, updated_at = NOW(), updated_by = $3
|
||||||
|
WHERE node = $4`,
|
||||||
|
[base_time, interval_minutes, updatedBy, node]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recalculate effective times for this node
|
||||||
|
const serversResult = await db.query(
|
||||||
|
'SELECT server_id FROM server_restart_schedules WHERE node = $1 ORDER BY sort_order',
|
||||||
|
[node]
|
||||||
|
);
|
||||||
|
|
||||||
|
const servers = serversResult.rows;
|
||||||
|
const staggered = calculateStagger(base_time, interval_minutes, servers);
|
||||||
|
|
||||||
|
for (const server of staggered) {
|
||||||
|
await db.query(
|
||||||
|
'UPDATE server_restart_schedules SET effective_time = $1, sync_status = $2 WHERE server_id = $3',
|
||||||
|
[server.effective_time, 'PENDING', server.server_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect('/admin/scheduler');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update config error:', err);
|
||||||
|
res.status(500).send('Error updating config');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /admin/scheduler/sync/:node - Sync all servers for a node
|
||||||
|
router.post('/sync/:node', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { node } = req.params;
|
||||||
|
const results = await syncAllForNode(node);
|
||||||
|
|
||||||
|
const success = results.filter(r => r.success).length;
|
||||||
|
const failed = results.filter(r => !r.success).length;
|
||||||
|
|
||||||
|
res.json({ success: true, synced: success, failed });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Sync error:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /admin/scheduler/audit/:node - Audit a node for rogue schedules
|
||||||
|
router.get('/audit/:node', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { node } = req.params;
|
||||||
|
|
||||||
|
const serversResult = await db.query(
|
||||||
|
'SELECT server_id, server_name FROM server_restart_schedules WHERE node = $1',
|
||||||
|
[node]
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
let totalRogue = 0;
|
||||||
|
|
||||||
|
for (const server of serversResult.rows) {
|
||||||
|
const auditResult = await auditServerSchedules(server.server_id, server.server_name);
|
||||||
|
if (auditResult.rogueSchedules.length > 0) {
|
||||||
|
results.push(auditResult);
|
||||||
|
totalRogue += auditResult.rogueSchedules.length;
|
||||||
|
}
|
||||||
|
await sleep(200); // Rate limiting
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `<div id="audit-modal" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-70 backdrop-blur-sm">
|
||||||
|
<div class="bg-white dark:bg-darkcard border ${totalRogue > 0 ? 'border-red-500' : 'border-green-500'} rounded-lg shadow-2xl w-full max-w-2xl p-6 relative">`;
|
||||||
|
|
||||||
|
if (totalRogue > 0) {
|
||||||
|
const nukePayload = [];
|
||||||
|
results.forEach(r => r.rogueSchedules.forEach(s => nukePayload.push({
|
||||||
|
serverId: r.serverId, scheduleId: s.id, scheduleName: s.name
|
||||||
|
})));
|
||||||
|
|
||||||
|
html += `<h2 class="text-2xl font-bold text-red-500 mb-2">⚠ Conflicts Detected</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-300 mb-4">
|
||||||
|
Found <strong class="text-gray-900 dark:text-white">${totalRogue}</strong> rogue restart schedule(s) across
|
||||||
|
<strong class="text-gray-900 dark:text-white">${results.length}</strong> server(s) on ${node}.
|
||||||
|
These must be removed before Trinity can take control.
|
||||||
|
</p>
|
||||||
|
<div class="bg-gray-100 dark:bg-darkbg rounded p-4 mb-6 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700">
|
||||||
|
<ul class="space-y-3">`;
|
||||||
|
|
||||||
|
results.forEach(result => {
|
||||||
|
html += `<li class="border-b border-gray-200 dark:border-gray-700 pb-2 last:border-0">
|
||||||
|
<span class="text-fire font-semibold">${result.serverName}</span>
|
||||||
|
<ul class="ml-4 mt-1 text-sm text-gray-500 dark:text-gray-400">`;
|
||||||
|
result.rogueSchedules.forEach(sched => {
|
||||||
|
html += `<li>- "${sched.name}" (Cron: ${sched.cron})</li>`;
|
||||||
|
});
|
||||||
|
html += `</ul></li>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `</ul></div>
|
||||||
|
<form hx-post="/admin/scheduler/audit/nuke/${node}" hx-target="#audit-modal" hx-swap="outerHTML">
|
||||||
|
<input type="hidden" name="nukeData" value='${JSON.stringify(nukePayload)}'>
|
||||||
|
<div class="flex justify-end gap-4 mt-6">
|
||||||
|
<button type="button" onclick="document.getElementById('audit-modal').remove()"
|
||||||
|
class="px-4 py-2 text-gray-500 hover:text-gray-900 dark:hover:text-white transition">Cancel</button>
|
||||||
|
<button type="submit" class="bg-red-600 hover:bg-red-500 text-white px-6 py-2 rounded font-bold transition">
|
||||||
|
🔥 Nuke ${totalRogue} Schedules
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>`;
|
||||||
|
} else {
|
||||||
|
html += `<h2 class="text-2xl font-bold text-green-500 mb-2">✓ All Clear</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-300 mb-6">No conflicts found on ${node}. Trinity is ready to take control.</p>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="button" onclick="document.getElementById('audit-modal').remove()"
|
||||||
|
class="bg-green-600 hover:bg-green-500 text-white px-6 py-2 rounded transition">Close</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</div></div>`;
|
||||||
|
res.send(html);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Audit error:', err);
|
||||||
|
res.status(500).send('Error running audit');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /admin/scheduler/audit/nuke/:node - Delete all rogue schedules
|
||||||
|
router.post('/audit/nuke/:node', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { node } = req.params;
|
||||||
|
const nukeData = JSON.parse(req.body.nukeData);
|
||||||
|
|
||||||
|
let deleted = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const item of nukeData) {
|
||||||
|
const result = await deleteSchedule(item.serverId, item.scheduleId, item.scheduleName);
|
||||||
|
if (result.success) {
|
||||||
|
deleted++;
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
await sleep(200); // Rate limiting
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success message as modal replacement
|
||||||
|
res.send(`
|
||||||
|
<div id="audit-modal" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-70 backdrop-blur-sm">
|
||||||
|
<div class="bg-slate-900 border border-green-500 rounded-lg shadow-2xl w-full max-w-md p-6">
|
||||||
|
<h2 class="text-2xl font-bold text-green-400 mb-4">✓ Cleanup Complete</h2>
|
||||||
|
<p class="text-slate-300 mb-6">
|
||||||
|
Deleted <strong class="text-white">${deleted}</strong> rogue schedule(s) on ${node}.
|
||||||
|
${failed > 0 ? `<br><span class="text-red-400">${failed} failed.</span>` : ''}
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="button" onclick="document.getElementById('audit-modal').remove(); htmx.ajax('GET', '/admin/scheduler/table-only', '#scheduler-table');"
|
||||||
|
class="bg-green-600 hover:bg-green-500 text-white px-6 py-2 rounded transition">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Nuke error:', err);
|
||||||
|
res.status(500).send('Error deleting schedules');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /admin/scheduler/toggle-skip/:serverId - Toggle skip_restart
|
||||||
|
router.post('/toggle-skip/:serverId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { serverId } = req.params;
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
'UPDATE server_restart_schedules SET skip_restart = NOT skip_restart, sync_status = $1 WHERE server_id = $2',
|
||||||
|
['PENDING', serverId]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /admin/scheduler/import-servers - Import servers from Pterodactyl
|
||||||
|
router.post('/import-servers', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Use discovery to get servers
|
||||||
|
const { getMinecraftServers } = require('../../panel/discovery');
|
||||||
|
const servers = await getMinecraftServers();
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const node = server.node || 'TX1'; // Default to TX1 if unknown
|
||||||
|
|
||||||
|
// Check if server already exists
|
||||||
|
const existing = await db.query(
|
||||||
|
'SELECT id FROM server_restart_schedules WHERE server_id = $1',
|
||||||
|
[server.identifier]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
// Get current count for this node for sort_order
|
||||||
|
const countResult = await db.query(
|
||||||
|
'SELECT COUNT(*) as count FROM server_restart_schedules WHERE node = $1',
|
||||||
|
[node]
|
||||||
|
);
|
||||||
|
const sortOrder = parseInt(countResult.rows[0].count);
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO server_restart_schedules (server_id, server_name, node, sort_order)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
[server.identifier, server.name, node, sortOrder]
|
||||||
|
);
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate effective times
|
||||||
|
for (const node of ['TX1', 'NC1']) {
|
||||||
|
const configResult = await db.query(
|
||||||
|
'SELECT base_time, interval_minutes FROM global_restart_config WHERE node = $1',
|
||||||
|
[node]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (configResult.rows.length === 0) continue;
|
||||||
|
|
||||||
|
const { base_time, interval_minutes } = configResult.rows[0];
|
||||||
|
|
||||||
|
const serversResult = await db.query(
|
||||||
|
'SELECT server_id FROM server_restart_schedules WHERE node = $1 ORDER BY sort_order',
|
||||||
|
[node]
|
||||||
|
);
|
||||||
|
|
||||||
|
const staggered = calculateStagger(base_time, interval_minutes, serversResult.rows);
|
||||||
|
|
||||||
|
for (const server of staggered) {
|
||||||
|
await db.query(
|
||||||
|
'UPDATE server_restart_schedules SET effective_time = $1 WHERE server_id = $2',
|
||||||
|
[server.effective_time, server.server_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, imported });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Import error:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -4,11 +4,105 @@ const db = require('../../database');
|
|||||||
const { getMinecraftServers } = require('../../panel/discovery');
|
const { getMinecraftServers } = require('../../panel/discovery');
|
||||||
const { readServerProperties, writeWhitelistFile } = require('../../panel/files');
|
const { readServerProperties, writeWhitelistFile } = require('../../panel/files');
|
||||||
const { reloadWhitelistCommand } = require('../../panel/commands');
|
const { reloadWhitelistCommand } = require('../../panel/commands');
|
||||||
|
const { ChannelType } = require('discord.js');
|
||||||
|
|
||||||
// In-memory cache for RV low-bandwidth operations
|
// In-memory cache for RV low-bandwidth operations
|
||||||
let serverCache = { data: null, lastFetch: 0 };
|
let serverCache = { data: null, lastFetch: 0 };
|
||||||
const CACHE_TTL = 60000; // 60 seconds
|
const CACHE_TTL = 60000; // 60 seconds
|
||||||
|
|
||||||
|
// Cache for Discord channels (refresh less frequently)
|
||||||
|
let discordChannelCache = { channels: null, lastFetch: 0 };
|
||||||
|
const DISCORD_CACHE_TTL = 300000; // 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Discord channels from cache or fetch fresh
|
||||||
|
*/
|
||||||
|
async function getDiscordChannels(client) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (discordChannelCache.channels && (now - discordChannelCache.lastFetch < DISCORD_CACHE_TTL)) {
|
||||||
|
return discordChannelCache.channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||||
|
if (!guild) return [];
|
||||||
|
|
||||||
|
const channels = guild.channels.cache.map(ch => ({
|
||||||
|
id: ch.id,
|
||||||
|
name: ch.name,
|
||||||
|
type: ch.type,
|
||||||
|
parentId: ch.parentId
|
||||||
|
}));
|
||||||
|
|
||||||
|
discordChannelCache = { channels, lastFetch: now };
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which Discord channels exist for a server
|
||||||
|
* Returns object with missing channels array
|
||||||
|
*/
|
||||||
|
function checkServerChannels(serverName, allChannels) {
|
||||||
|
// Extract the base name (before any " - " subtitle or parenthetical)
|
||||||
|
// "Homestead - A Cozy Survival Experience" -> "homestead"
|
||||||
|
// "All The Mons (Private) - TX" -> "all-the-mons"
|
||||||
|
// "Stoneblock 4" -> "stoneblock-4"
|
||||||
|
let baseName = serverName
|
||||||
|
.split(' - ')[0] // Take part before " - " subtitle
|
||||||
|
.replace(/\s*\([^)]*\)\s*/g, '') // Remove parentheticals like (Private)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s]/g, '') // Remove special chars except spaces
|
||||||
|
.replace(/\s+/g, '-') // Spaces to hyphens
|
||||||
|
.replace(/-+/g, '-') // Multiple hyphens to single
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Also create a display name for voice channel matching
|
||||||
|
const voiceDisplayName = serverName
|
||||||
|
.split(' - ')[0]
|
||||||
|
.replace(/\s*\([^)]*\)\s*/g, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const expectedChannels = [
|
||||||
|
{ name: `${baseName}-chat`, type: 'text', label: 'Chat' },
|
||||||
|
{ name: `${baseName}-in-game`, type: 'text', label: 'In-Game' },
|
||||||
|
{ name: `${baseName}-forum`, type: 'forum', label: 'Forum' },
|
||||||
|
{ name: voiceDisplayName, type: 'voice', label: 'Voice' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const missing = [];
|
||||||
|
const found = [];
|
||||||
|
|
||||||
|
for (const expected of expectedChannels) {
|
||||||
|
let exists = false;
|
||||||
|
|
||||||
|
if (expected.type === 'voice') {
|
||||||
|
// Voice channels match by exact name (case-insensitive)
|
||||||
|
exists = allChannels.some(ch =>
|
||||||
|
ch.type === ChannelType.GuildVoice &&
|
||||||
|
ch.name.toLowerCase() === expected.name.toLowerCase()
|
||||||
|
);
|
||||||
|
} else if (expected.type === 'forum') {
|
||||||
|
exists = allChannels.some(ch =>
|
||||||
|
ch.type === ChannelType.GuildForum &&
|
||||||
|
ch.name === expected.name
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Text channels
|
||||||
|
exists = allChannels.some(ch =>
|
||||||
|
ch.type === ChannelType.GuildText &&
|
||||||
|
ch.name === expected.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
found.push(expected.label);
|
||||||
|
} else {
|
||||||
|
missing.push(expected.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { missing, found, complete: missing.length === 0 };
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
res.render('admin/servers/index', { title: 'Server Matrix' });
|
res.render('admin/servers/index', { title: 'Server Matrix' });
|
||||||
});
|
});
|
||||||
@@ -38,14 +132,22 @@ router.get('/matrix', async (req, res) => {
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const enrichedServers = serversData.map(srv => ({
|
// Get Discord channels
|
||||||
...srv,
|
const client = req.app.locals.client;
|
||||||
log: logMap[srv.identifier] || { is_online: false, last_error: 'Never synced' }
|
const discordChannels = await getDiscordChannels(client);
|
||||||
}));
|
|
||||||
|
const enrichedServers = serversData.map(srv => {
|
||||||
|
const channelStatus = checkServerChannels(srv.name, discordChannels);
|
||||||
|
return {
|
||||||
|
...srv,
|
||||||
|
log: logMap[srv.identifier] || { is_online: false, last_error: 'Never synced' },
|
||||||
|
discord: channelStatus
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Group by Node Location
|
// Group by Node Location
|
||||||
const txServers = enrichedServers.filter(s => s.node === 'TX1' || s.node === 'Node 3' || s.name.includes('TX'));
|
const txServers = enrichedServers.filter(s => s.node === 'TX1');
|
||||||
const ncServers = enrichedServers.filter(s => s.node === 'NC1' || s.node === 'Node 2' || s.name.includes('NC'));
|
const ncServers = enrichedServers.filter(s => s.node === 'NC1');
|
||||||
|
|
||||||
res.render('admin/servers/_matrix_body', { txServers, ncServers, layout: false });
|
res.render('admin/servers/_matrix_body', { txServers, ncServers, layout: false });
|
||||||
});
|
});
|
||||||
@@ -108,4 +210,50 @@ router.post('/:identifier/toggle-whitelist', async (req, res) => {
|
|||||||
res.send(`<span class="text-yellow-500 font-bold text-sm">⚠️ Requires Restart</span>`);
|
res.send(`<span class="text-yellow-500 font-bold text-sm">⚠️ Requires Restart</span>`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync all servers on a specific node
|
||||||
|
router.post('/sync-all/:node', async (req, res) => {
|
||||||
|
const { node } = req.params;
|
||||||
|
const nodeId = node === 'tx1' ? 3 : node === 'nc1' ? 2 : null;
|
||||||
|
|
||||||
|
if (!nodeId) {
|
||||||
|
return res.send(`<span class="text-red-500">Invalid node</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const discovered = await getMinecraftServers();
|
||||||
|
const nodeServers = discovered.filter(s => s.nodeId === nodeId);
|
||||||
|
|
||||||
|
const { rows: players } = await db.query(
|
||||||
|
`SELECT minecraft_username as name, minecraft_uuid as uuid FROM users
|
||||||
|
JOIN subscriptions ON users.discord_id = subscriptions.discord_id
|
||||||
|
WHERE subscriptions.status IN ('active', 'grace_period', 'lifetime')`
|
||||||
|
);
|
||||||
|
|
||||||
|
let synced = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
for (const srv of nodeServers) {
|
||||||
|
try {
|
||||||
|
await writeWhitelistFile(srv.identifier, players);
|
||||||
|
await reloadWhitelistCommand(srv.identifier);
|
||||||
|
await db.query(
|
||||||
|
"INSERT INTO server_sync_log (server_identifier, last_successful_sync, is_online, last_error) VALUES ($1, NOW(), true, NULL) ON CONFLICT (server_identifier) DO UPDATE SET last_successful_sync = NOW(), is_online = true, last_error = NULL",
|
||||||
|
[srv.identifier]
|
||||||
|
);
|
||||||
|
synced++;
|
||||||
|
} catch (err) {
|
||||||
|
await db.query(
|
||||||
|
"INSERT INTO server_sync_log (server_identifier, last_error, is_online) VALUES ($1, $2, false) ON CONFLICT (server_identifier) DO UPDATE SET last_error = $2, is_online = false",
|
||||||
|
[srv.identifier, err.message]
|
||||||
|
);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send(`<span class="text-green-500 font-bold">✅ ${synced} synced</span>${errors > 0 ? ` <span class="text-red-500">(${errors} errors)</span>` : ''}`);
|
||||||
|
} catch (error) {
|
||||||
|
res.send(`<span class="text-red-500">❌ ${error.message}</span>`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
262
services/arbiter-3.0/src/routes/admin/social.js
Normal file
262
services/arbiter-3.0/src/routes/admin/social.js
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require('../../database');
|
||||||
|
|
||||||
|
// GET /admin/social - Main dashboard
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const platform = req.query.platform || 'tiktok';
|
||||||
|
|
||||||
|
// Get recent posts (last 30 days)
|
||||||
|
const { rows: posts } = await db.query(`
|
||||||
|
SELECT * FROM social_posts
|
||||||
|
WHERE platform = $1
|
||||||
|
ORDER BY posted_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
`, [platform]);
|
||||||
|
|
||||||
|
// Get aggregate stats for this platform
|
||||||
|
const { rows: stats } = await db.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_posts,
|
||||||
|
COALESCE(SUM(views), 0) as total_views,
|
||||||
|
COALESCE(SUM(likes), 0) as total_likes,
|
||||||
|
COALESCE(SUM(comments), 0) as total_comments,
|
||||||
|
COALESCE(SUM(shares), 0) as total_shares,
|
||||||
|
COALESCE(SUM(new_followers), 0) as total_new_followers,
|
||||||
|
COALESCE(AVG(watched_full_pct), 0) as avg_completion,
|
||||||
|
COALESCE(AVG(avg_watch_time_seconds), 0) as avg_watch_time
|
||||||
|
FROM social_posts
|
||||||
|
WHERE platform = $1
|
||||||
|
AND posted_at > NOW() - INTERVAL '30 days'
|
||||||
|
`, [platform]);
|
||||||
|
|
||||||
|
// Get latest account snapshot
|
||||||
|
const { rows: snapshots } = await db.query(`
|
||||||
|
SELECT * FROM social_account_snapshots
|
||||||
|
WHERE platform = $1
|
||||||
|
ORDER BY snapshot_date DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, [platform]);
|
||||||
|
|
||||||
|
// Calculate engagement rate
|
||||||
|
const totalViews = parseInt(stats[0]?.total_views || 0);
|
||||||
|
const totalLikes = parseInt(stats[0]?.total_likes || 0);
|
||||||
|
const totalComments = parseInt(stats[0]?.total_comments || 0);
|
||||||
|
const totalShares = parseInt(stats[0]?.total_shares || 0);
|
||||||
|
const engagementRate = totalViews > 0
|
||||||
|
? (((totalLikes + totalComments + totalShares) / totalViews) * 100).toFixed(2)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
res.render('admin/social/index', {
|
||||||
|
title: 'Social Analytics',
|
||||||
|
platform,
|
||||||
|
posts,
|
||||||
|
stats: {
|
||||||
|
...stats[0],
|
||||||
|
engagementRate
|
||||||
|
},
|
||||||
|
snapshot: snapshots[0] || null
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Social Analytics Error:', error);
|
||||||
|
res.status(500).send('Error loading social analytics');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /admin/social/add - Add post form
|
||||||
|
router.get('/add', (req, res) => {
|
||||||
|
const platform = req.query.platform || 'tiktok';
|
||||||
|
res.render('admin/social/add', {
|
||||||
|
title: 'Add Social Post',
|
||||||
|
platform
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /admin/social/add - Create new post
|
||||||
|
router.post('/add', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
platform,
|
||||||
|
post_title,
|
||||||
|
post_url,
|
||||||
|
posted_at,
|
||||||
|
video_length_seconds,
|
||||||
|
views,
|
||||||
|
likes,
|
||||||
|
comments,
|
||||||
|
shares,
|
||||||
|
saves,
|
||||||
|
total_play_time_seconds,
|
||||||
|
avg_watch_time_seconds,
|
||||||
|
watched_full_pct,
|
||||||
|
drop_off_seconds,
|
||||||
|
new_followers,
|
||||||
|
top_traffic_source,
|
||||||
|
top_traffic_pct,
|
||||||
|
notes
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
await db.query(`
|
||||||
|
INSERT INTO social_posts (
|
||||||
|
platform, post_title, post_url, posted_at, video_length_seconds,
|
||||||
|
views, likes, comments, shares, saves,
|
||||||
|
total_play_time_seconds, avg_watch_time_seconds, watched_full_pct,
|
||||||
|
drop_off_seconds, new_followers, top_traffic_source, top_traffic_pct, notes
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||||
|
`, [
|
||||||
|
platform || 'tiktok',
|
||||||
|
post_title,
|
||||||
|
post_url || null,
|
||||||
|
posted_at,
|
||||||
|
video_length_seconds || null,
|
||||||
|
views || 0,
|
||||||
|
likes || 0,
|
||||||
|
comments || 0,
|
||||||
|
shares || 0,
|
||||||
|
saves || 0,
|
||||||
|
total_play_time_seconds || 0,
|
||||||
|
avg_watch_time_seconds || 0,
|
||||||
|
watched_full_pct || 0,
|
||||||
|
drop_off_seconds || null,
|
||||||
|
new_followers || 0,
|
||||||
|
top_traffic_source || null,
|
||||||
|
top_traffic_pct || null,
|
||||||
|
notes || null
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.redirect(`/admin/social?platform=${platform || 'tiktok'}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Add Post Error:', error);
|
||||||
|
res.status(500).send('Error adding post');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /admin/social/post/:id - View post detail
|
||||||
|
router.get('/post/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await db.query(
|
||||||
|
'SELECT * FROM social_posts WHERE id = $1',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return res.status(404).send('Post not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render('admin/social/detail', {
|
||||||
|
title: 'Post Analytics',
|
||||||
|
post: rows[0]
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Post Detail Error:', error);
|
||||||
|
res.status(500).send('Error loading post');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /admin/social/post/:id/update - Update post metrics
|
||||||
|
router.post('/post/:id/update', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
views, likes, comments, shares, saves,
|
||||||
|
total_play_time_seconds, avg_watch_time_seconds,
|
||||||
|
watched_full_pct, drop_off_seconds, new_followers,
|
||||||
|
top_traffic_source, top_traffic_pct, notes
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
await db.query(`
|
||||||
|
UPDATE social_posts SET
|
||||||
|
views = $1, likes = $2, comments = $3, shares = $4, saves = $5,
|
||||||
|
total_play_time_seconds = $6, avg_watch_time_seconds = $7,
|
||||||
|
watched_full_pct = $8, drop_off_seconds = $9, new_followers = $10,
|
||||||
|
top_traffic_source = $11, top_traffic_pct = $12, notes = $13,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $14
|
||||||
|
`, [
|
||||||
|
views || 0, likes || 0, comments || 0, shares || 0, saves || 0,
|
||||||
|
total_play_time_seconds || 0, avg_watch_time_seconds || 0,
|
||||||
|
watched_full_pct || 0, drop_off_seconds || null, new_followers || 0,
|
||||||
|
top_traffic_source || null, top_traffic_pct || null, notes || null,
|
||||||
|
req.params.id
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.redirect(`/admin/social/post/${req.params.id}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update Post Error:', error);
|
||||||
|
res.status(500).send('Error updating post');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /admin/social/snapshot - Add account snapshot form
|
||||||
|
router.get('/snapshot', (req, res) => {
|
||||||
|
const platform = req.query.platform || 'tiktok';
|
||||||
|
res.render('admin/social/snapshot', {
|
||||||
|
title: 'Account Snapshot',
|
||||||
|
platform
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /admin/social/snapshot - Save account snapshot
|
||||||
|
router.post('/snapshot', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
platform,
|
||||||
|
snapshot_date,
|
||||||
|
total_followers,
|
||||||
|
profile_views,
|
||||||
|
search_queries,
|
||||||
|
demographics
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Upsert - update if exists for this date, insert if not
|
||||||
|
await db.query(`
|
||||||
|
INSERT INTO social_account_snapshots
|
||||||
|
(platform, snapshot_date, total_followers, profile_views, search_queries, demographics)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (platform, snapshot_date)
|
||||||
|
DO UPDATE SET
|
||||||
|
total_followers = EXCLUDED.total_followers,
|
||||||
|
profile_views = EXCLUDED.profile_views,
|
||||||
|
search_queries = EXCLUDED.search_queries,
|
||||||
|
demographics = EXCLUDED.demographics
|
||||||
|
`, [
|
||||||
|
platform || 'tiktok',
|
||||||
|
snapshot_date,
|
||||||
|
total_followers || 0,
|
||||||
|
profile_views || 0,
|
||||||
|
search_queries ? JSON.parse(search_queries) : null,
|
||||||
|
demographics ? JSON.parse(demographics) : null
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.redirect(`/admin/social?platform=${platform || 'tiktok'}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Snapshot Error:', error);
|
||||||
|
res.status(500).send('Error saving snapshot');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /admin/social/post/:id - Delete post
|
||||||
|
router.post('/post/:id/delete', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await db.query(
|
||||||
|
'SELECT platform FROM social_posts WHERE id = $1',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
const platform = rows[0]?.platform || 'tiktok';
|
||||||
|
|
||||||
|
await db.query('DELETE FROM social_posts WHERE id = $1', [req.params.id]);
|
||||||
|
|
||||||
|
res.redirect(`/admin/social?platform=${platform}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete Post Error:', error);
|
||||||
|
res.status(500).send('Error deleting post');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
40
services/arbiter-3.0/src/routes/admin/system.js
Normal file
40
services/arbiter-3.0/src/routes/admin/system.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System Routes - Maintenance & Deployment
|
||||||
|
*
|
||||||
|
* POST /admin/system/deploy - Deploy latest Arbiter code from Gitea
|
||||||
|
*/
|
||||||
|
|
||||||
|
// POST /admin/system/deploy - Pull latest code and restart Arbiter
|
||||||
|
router.post('/deploy', (req, res) => {
|
||||||
|
const username = req.user?.username || 'unknown';
|
||||||
|
|
||||||
|
console.log(`[DEPLOY] Deployment initiated by ${username}`);
|
||||||
|
|
||||||
|
// Use nohup to detach the process so the response returns before Arbiter restarts
|
||||||
|
// Output goes to /tmp/deploy.log for debugging if needed
|
||||||
|
exec(`nohup sudo /opt/scripts/deploy-arbiter.sh "${username}" > /tmp/deploy.log 2>&1 &`);
|
||||||
|
|
||||||
|
// Return immediately - deploy happens in background
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Deploy started. Arbiter will restart momentarily.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /admin/system/status - Check if deploy script exists and arbiter is running
|
||||||
|
router.get('/status', (req, res) => {
|
||||||
|
exec('systemctl is-active arbiter-3', (error, stdout) => {
|
||||||
|
const isRunning = stdout.trim() === 'active';
|
||||||
|
res.json({
|
||||||
|
arbiter: isRunning ? 'running' : 'stopped',
|
||||||
|
deployAvailable: fs.existsSync('/opt/scripts/deploy-arbiter.sh')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
129
services/arbiter-3.0/src/routes/admin/tasks.js
Normal file
129
services/arbiter-3.0/src/routes/admin/tasks.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require('../../database');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tasks Module — Trinity Console
|
||||||
|
*
|
||||||
|
* Web interface for task management. Source of truth: PostgreSQL tasks table.
|
||||||
|
* Complements Discord /tasks command for ChatOps workflow.
|
||||||
|
*
|
||||||
|
* GET /admin/tasks — Task board view
|
||||||
|
* POST /admin/tasks/update/:id — Update task (HTMX)
|
||||||
|
* POST /admin/tasks/create — Create task (HTMX)
|
||||||
|
*
|
||||||
|
* Chronicler #78 | April 11, 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PRIORITIES = ['critical', 'high', 'medium', 'low', 'wish'];
|
||||||
|
const STATUSES = ['open', 'in_progress', 'blocked', 'done', 'obsolete'];
|
||||||
|
const OWNERS = ['Michael', 'Meg', 'Holly', 'Trinity', 'unassigned'];
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { status, priority, owner } = req.query;
|
||||||
|
let where = 'WHERE 1=1';
|
||||||
|
const params = [];
|
||||||
|
let p = 0;
|
||||||
|
|
||||||
|
// Default: hide done and obsolete
|
||||||
|
if (status) {
|
||||||
|
p++; where += ` AND status = $${p}`; params.push(status);
|
||||||
|
} else if (!req.query.all) {
|
||||||
|
where += ` AND status NOT IN ('done', 'obsolete')`;
|
||||||
|
}
|
||||||
|
if (priority) { p++; where += ` AND priority = $${p}`; params.push(priority); }
|
||||||
|
if (owner) { p++; where += ` AND owner = $${p}`; params.push(owner); }
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
`SELECT * FROM tasks ${where} ORDER BY
|
||||||
|
CASE status WHEN 'in_progress' THEN 1 WHEN 'blocked' THEN 2 WHEN 'open' THEN 3 WHEN 'done' THEN 4 WHEN 'obsolete' THEN 5 END,
|
||||||
|
CASE priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 WHEN 'low' THEN 4 WHEN 'wish' THEN 5 END,
|
||||||
|
task_number`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const statsResult = await db.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status NOT IN ('done','obsolete')) as active,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'done') as done,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'blocked') as blocked,
|
||||||
|
COUNT(*) FILTER (WHERE priority IN ('critical','high') AND status NOT IN ('done','obsolete')) as high_priority
|
||||||
|
FROM tasks
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.render('admin/tasks/index', {
|
||||||
|
title: 'Tasks',
|
||||||
|
currentPath: '/tasks',
|
||||||
|
tasks: result.rows,
|
||||||
|
stats: statsResult.rows[0],
|
||||||
|
filters: { status, priority, owner, all: req.query.all },
|
||||||
|
priorities: PRIORITIES,
|
||||||
|
statuses: STATUSES,
|
||||||
|
owners: OWNERS,
|
||||||
|
adminUser: req.user,
|
||||||
|
layout: 'layout'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Tasks] Route error:', err);
|
||||||
|
res.status(500).send('Error loading tasks');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /admin/tasks/update/:id — Quick status update (HTMX)
|
||||||
|
router.post('/update/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { status, priority, owner } = req.body;
|
||||||
|
const updates = [];
|
||||||
|
const params = [];
|
||||||
|
let p = 0;
|
||||||
|
|
||||||
|
if (status) { p++; updates.push(`status = $${p}`); params.push(status); }
|
||||||
|
if (priority) { p++; updates.push(`priority = $${p}`); params.push(priority); }
|
||||||
|
if (owner) { p++; updates.push(`owner = $${p}`); params.push(owner); }
|
||||||
|
|
||||||
|
if (status === 'done') {
|
||||||
|
updates.push(`completed_at = NOW()`);
|
||||||
|
const completedBy = req.user?.username || 'Trinity Console';
|
||||||
|
p++; updates.push(`completed_by = $${p}`); params.push(completedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push('updated_at = NOW()');
|
||||||
|
p++; params.push(id);
|
||||||
|
|
||||||
|
await db.query(`UPDATE tasks SET ${updates.join(', ')} WHERE id = $${p}`, params);
|
||||||
|
|
||||||
|
res.redirect('/admin/tasks');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Tasks] Update error:', err);
|
||||||
|
res.status(500).send('Error updating task');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /admin/tasks/create — Create new task
|
||||||
|
router.post('/create', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { title, description, priority, owner } = req.body;
|
||||||
|
if (!title) return res.redirect('/admin/tasks');
|
||||||
|
|
||||||
|
const maxResult = await db.query('SELECT COALESCE(MAX(task_number), 0) + 1 as next FROM tasks');
|
||||||
|
const taskNumber = maxResult.rows[0].next;
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO tasks (task_number, title, description, priority, owner)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[taskNumber, title, description || null, priority || 'medium', owner || 'unassigned']
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`📋 [Tasks] Created #${taskNumber}: ${title} via Trinity Console`);
|
||||||
|
res.redirect('/admin/tasks');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Tasks] Create error:', err);
|
||||||
|
res.status(500).send('Error creating task');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
519
services/arbiter-3.0/src/routes/api.js
Normal file
519
services/arbiter-3.0/src/routes/api.js
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
/**
|
||||||
|
* Internal API Routes
|
||||||
|
*
|
||||||
|
* These endpoints are for machine-to-machine communication (n8n, external services).
|
||||||
|
* Authentication is via Bearer token, not session-based.
|
||||||
|
*
|
||||||
|
* Token is set in environment variable: INTERNAL_API_TOKEN
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require('../database');
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MIDDLEWARE: Internal Token Authentication
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const verifyInternalToken = (req, res, next) => {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
const expectedToken = process.env.INTERNAL_API_TOKEN;
|
||||||
|
|
||||||
|
if (!expectedToken) {
|
||||||
|
console.error('⚠️ INTERNAL_API_TOKEN not configured');
|
||||||
|
return res.status(500).json({ error: 'Server misconfiguration' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
|
||||||
|
|
||||||
|
if (token !== expectedToken) {
|
||||||
|
return res.status(403).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply token auth to all routes in this router
|
||||||
|
router.use(verifyInternalToken);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// POST /api/internal/social/sync
|
||||||
|
// Upsert social post metrics from n8n
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
router.post('/social/sync', async (req, res) => {
|
||||||
|
const { platform, platform_post_id, post_title, post_url, posted_at, metrics } = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!platform || !platform_post_id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing required fields',
|
||||||
|
required: ['platform', 'platform_post_id']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate platform enum
|
||||||
|
const validPlatforms = ['tiktok', 'facebook', 'instagram', 'x', 'bluesky'];
|
||||||
|
if (!validPlatforms.includes(platform)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid platform',
|
||||||
|
valid: validPlatforms
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if post exists
|
||||||
|
const { rows: existing } = await db.query(
|
||||||
|
'SELECT id FROM social_posts WHERE platform = $1 AND platform_post_id = $2',
|
||||||
|
[platform, platform_post_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
// UPDATE existing post
|
||||||
|
const updateFields = [];
|
||||||
|
const updateValues = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// Update title and URL if provided
|
||||||
|
if (post_title) {
|
||||||
|
updateFields.push(`post_title = $${paramIndex}`);
|
||||||
|
updateValues.push(post_title);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (post_url) {
|
||||||
|
updateFields.push(`post_url = $${paramIndex}`);
|
||||||
|
updateValues.push(post_url);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dynamic update based on provided metrics
|
||||||
|
if (metrics) {
|
||||||
|
const metricFields = [
|
||||||
|
'views', 'likes', 'comments', 'shares', 'saves',
|
||||||
|
'total_play_time_seconds', 'avg_watch_time_seconds',
|
||||||
|
'watched_full_pct', 'drop_off_seconds', 'new_followers',
|
||||||
|
'top_traffic_source', 'top_traffic_pct'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of metricFields) {
|
||||||
|
if (metrics[field] !== undefined) {
|
||||||
|
updateFields.push(`${field} = $${paramIndex}`);
|
||||||
|
updateValues.push(metrics[field]);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always update timestamp
|
||||||
|
updateFields.push(`updated_at = NOW()`);
|
||||||
|
|
||||||
|
if (updateFields.length > 1) { // More than just updated_at
|
||||||
|
updateValues.push(platform, platform_post_id);
|
||||||
|
|
||||||
|
await db.query(`
|
||||||
|
UPDATE social_posts
|
||||||
|
SET ${updateFields.join(', ')}
|
||||||
|
WHERE platform = $${paramIndex} AND platform_post_id = $${paramIndex + 1}
|
||||||
|
`, updateValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📊 [Social Sync] Updated ${platform}:${platform_post_id}`);
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
action: 'updated',
|
||||||
|
post_id: existing[0].id
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// INSERT new post
|
||||||
|
const insertResult = await db.query(`
|
||||||
|
INSERT INTO social_posts (
|
||||||
|
platform, platform_post_id, post_title, post_url, posted_at,
|
||||||
|
views, likes, comments, shares, saves,
|
||||||
|
total_play_time_seconds, avg_watch_time_seconds, watched_full_pct,
|
||||||
|
drop_off_seconds, new_followers, top_traffic_source, top_traffic_pct
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||||
|
RETURNING id
|
||||||
|
`, [
|
||||||
|
platform,
|
||||||
|
platform_post_id,
|
||||||
|
post_title || 'Untitled',
|
||||||
|
post_url || null,
|
||||||
|
posted_at || new Date().toISOString(),
|
||||||
|
metrics?.views || 0,
|
||||||
|
metrics?.likes || 0,
|
||||||
|
metrics?.comments || 0,
|
||||||
|
metrics?.shares || 0,
|
||||||
|
metrics?.saves || 0,
|
||||||
|
metrics?.total_play_time_seconds || 0,
|
||||||
|
metrics?.avg_watch_time_seconds || 0,
|
||||||
|
metrics?.watched_full_pct || 0,
|
||||||
|
metrics?.drop_off_seconds || null,
|
||||||
|
metrics?.new_followers || 0,
|
||||||
|
metrics?.top_traffic_source || null,
|
||||||
|
metrics?.top_traffic_pct || null
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log(`📊 [Social Sync] Created ${platform}:${platform_post_id} -> id ${insertResult.rows[0].id}`);
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
action: 'created',
|
||||||
|
post_id: insertResult.rows[0].id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Social Sync] Error:', error);
|
||||||
|
return res.status(500).json({ error: 'Database operation failed', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// POST /api/internal/social/sync/batch
|
||||||
|
// Batch upsert multiple posts at once (for efficiency)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
router.post('/social/sync/batch', async (req, res) => {
|
||||||
|
const { posts } = req.body;
|
||||||
|
|
||||||
|
if (!posts || !Array.isArray(posts)) {
|
||||||
|
return res.status(400).json({ error: 'posts array required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (posts.length > 100) {
|
||||||
|
return res.status(400).json({ error: 'Maximum 100 posts per batch' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
success: true,
|
||||||
|
processed: 0,
|
||||||
|
created: 0,
|
||||||
|
updated: 0,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const post of posts) {
|
||||||
|
try {
|
||||||
|
const { platform, platform_post_id, post_title, post_url, posted_at, metrics } = post;
|
||||||
|
|
||||||
|
if (!platform || !platform_post_id) {
|
||||||
|
results.errors.push({ post, error: 'Missing platform or platform_post_id' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if exists
|
||||||
|
const { rows: existing } = await db.query(
|
||||||
|
'SELECT id FROM social_posts WHERE platform = $1 AND platform_post_id = $2',
|
||||||
|
[platform, platform_post_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
// Update
|
||||||
|
await db.query(`
|
||||||
|
UPDATE social_posts SET
|
||||||
|
views = COALESCE($1, views),
|
||||||
|
likes = COALESCE($2, likes),
|
||||||
|
comments = COALESCE($3, comments),
|
||||||
|
shares = COALESCE($4, shares),
|
||||||
|
saves = COALESCE($5, saves),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE platform = $6 AND platform_post_id = $7
|
||||||
|
`, [
|
||||||
|
metrics?.views, metrics?.likes, metrics?.comments,
|
||||||
|
metrics?.shares, metrics?.saves,
|
||||||
|
platform, platform_post_id
|
||||||
|
]);
|
||||||
|
results.updated++;
|
||||||
|
} else {
|
||||||
|
// Insert
|
||||||
|
await db.query(`
|
||||||
|
INSERT INTO social_posts (
|
||||||
|
platform, platform_post_id, post_title, post_url, posted_at,
|
||||||
|
views, likes, comments, shares, saves
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
`, [
|
||||||
|
platform, platform_post_id,
|
||||||
|
post_title || 'Untitled',
|
||||||
|
post_url || null,
|
||||||
|
posted_at || new Date().toISOString(),
|
||||||
|
metrics?.views || 0, metrics?.likes || 0,
|
||||||
|
metrics?.comments || 0, metrics?.shares || 0,
|
||||||
|
metrics?.saves || 0
|
||||||
|
]);
|
||||||
|
results.created++;
|
||||||
|
}
|
||||||
|
results.processed++;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
results.errors.push({ post, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📊 [Social Sync Batch] Processed ${results.processed}, Created ${results.created}, Updated ${results.updated}`);
|
||||||
|
res.json(results);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// POST /api/internal/social/snapshot
|
||||||
|
// Upsert account-level snapshot (followers, profile views, etc.)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
router.post('/social/snapshot', async (req, res) => {
|
||||||
|
const { platform, snapshot_date, total_followers, profile_views, search_queries, demographics } = req.body;
|
||||||
|
|
||||||
|
if (!platform) {
|
||||||
|
return res.status(400).json({ error: 'platform required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPlatforms = ['tiktok', 'facebook', 'instagram', 'x', 'bluesky'];
|
||||||
|
if (!validPlatforms.includes(platform)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid platform', valid: validPlatforms });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = snapshot_date || new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
await db.query(`
|
||||||
|
INSERT INTO social_account_snapshots
|
||||||
|
(platform, snapshot_date, total_followers, profile_views, search_queries, demographics)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (platform, snapshot_date)
|
||||||
|
DO UPDATE SET
|
||||||
|
total_followers = COALESCE(EXCLUDED.total_followers, social_account_snapshots.total_followers),
|
||||||
|
profile_views = COALESCE(EXCLUDED.profile_views, social_account_snapshots.profile_views),
|
||||||
|
search_queries = COALESCE(EXCLUDED.search_queries, social_account_snapshots.search_queries),
|
||||||
|
demographics = COALESCE(EXCLUDED.demographics, social_account_snapshots.demographics)
|
||||||
|
`, [
|
||||||
|
platform,
|
||||||
|
date,
|
||||||
|
total_followers || 0,
|
||||||
|
profile_views || 0,
|
||||||
|
search_queries || null,
|
||||||
|
demographics || null
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log(`📊 [Social Snapshot] ${platform} @ ${date}`);
|
||||||
|
res.json({ success: true, platform, snapshot_date: date });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Social Snapshot] Error:', error);
|
||||||
|
res.status(500).json({ error: 'Database operation failed', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GET /api/internal/social/digest
|
||||||
|
// Get summary data for Discord digest notification
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
router.get('/social/digest', async (req, res) => {
|
||||||
|
const period = req.query.period || '24h';
|
||||||
|
|
||||||
|
let interval;
|
||||||
|
switch (period) {
|
||||||
|
case '7d': interval = '7 days'; break;
|
||||||
|
case '30d': interval = '30 days'; break;
|
||||||
|
case '24h':
|
||||||
|
default: interval = '24 hours';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get per-platform stats
|
||||||
|
const { rows: platformStats } = await db.query(`
|
||||||
|
SELECT
|
||||||
|
platform,
|
||||||
|
COUNT(*) as posts,
|
||||||
|
COALESCE(SUM(views), 0) as views,
|
||||||
|
COALESCE(SUM(likes), 0) as likes,
|
||||||
|
COALESCE(SUM(comments), 0) as comments,
|
||||||
|
COALESCE(SUM(shares), 0) as shares,
|
||||||
|
COALESCE(SUM(new_followers), 0) as new_followers
|
||||||
|
FROM social_posts
|
||||||
|
WHERE updated_at > NOW() - INTERVAL '${interval}'
|
||||||
|
GROUP BY platform
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get latest follower counts from snapshots
|
||||||
|
const { rows: latestSnapshots } = await db.query(`
|
||||||
|
SELECT DISTINCT ON (platform)
|
||||||
|
platform, total_followers, profile_views, snapshot_date
|
||||||
|
FROM social_account_snapshots
|
||||||
|
ORDER BY platform, snapshot_date DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const totals = platformStats.reduce((acc, p) => ({
|
||||||
|
views: acc.views + parseInt(p.views || 0),
|
||||||
|
likes: acc.likes + parseInt(p.likes || 0),
|
||||||
|
comments: acc.comments + parseInt(p.comments || 0),
|
||||||
|
shares: acc.shares + parseInt(p.shares || 0),
|
||||||
|
new_followers: acc.new_followers + parseInt(p.new_followers || 0)
|
||||||
|
}), { views: 0, likes: 0, comments: 0, shares: 0, new_followers: 0 });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
period,
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
platforms: platformStats,
|
||||||
|
snapshots: latestSnapshots,
|
||||||
|
totals
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Social Digest] Error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to generate digest' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// POST /api/internal/mcp/log
|
||||||
|
// Log a command execution from Trinity Core
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
router.post('/mcp/log', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { server, command, success, stdout, stderr, error, execution_time_ms } = req.body;
|
||||||
|
|
||||||
|
if (!server || !command || success === undefined) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields: server, command, success' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
`INSERT INTO mcp_logs (server, command, success, stdout, stderr, error, execution_time_ms)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id`,
|
||||||
|
[server, command, success, stdout || '', stderr || '', error || null, execution_time_ms || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, id: result.rows[0].id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ [MCP Log] Error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to log command' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TASK MANAGEMENT API
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// GET /api/internal/tasks — List tasks with optional filters
|
||||||
|
router.get('/tasks', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { status, priority, owner } = req.query;
|
||||||
|
let where = 'WHERE 1=1';
|
||||||
|
const params = [];
|
||||||
|
let p = 0;
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
p++; where += ` AND status = $${p}`; params.push(status);
|
||||||
|
} else {
|
||||||
|
where += ` AND status NOT IN ('done', 'obsolete')`;
|
||||||
|
}
|
||||||
|
if (priority) { p++; where += ` AND priority = $${p}`; params.push(priority); }
|
||||||
|
if (owner) { p++; where += ` AND owner = $${p}`; params.push(owner); }
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
`SELECT * FROM tasks ${where} ORDER BY
|
||||||
|
CASE priority
|
||||||
|
WHEN 'critical' THEN 1 WHEN 'high' THEN 2
|
||||||
|
WHEN 'medium' THEN 3 WHEN 'low' THEN 4 WHEN 'wish' THEN 5
|
||||||
|
END, task_number`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
res.json({ tasks: result.rows, count: result.rows.length });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ [Tasks] List error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch tasks' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/internal/tasks/summary — Quick stats
|
||||||
|
router.get('/tasks/summary', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await db.query(`
|
||||||
|
SELECT status, COUNT(*) as count FROM tasks GROUP BY status ORDER BY status
|
||||||
|
`);
|
||||||
|
const byPriority = await db.query(`
|
||||||
|
SELECT priority, COUNT(*) as count FROM tasks
|
||||||
|
WHERE status NOT IN ('done', 'obsolete')
|
||||||
|
GROUP BY priority ORDER BY
|
||||||
|
CASE priority
|
||||||
|
WHEN 'critical' THEN 1 WHEN 'high' THEN 2
|
||||||
|
WHEN 'medium' THEN 3 WHEN 'low' THEN 4 WHEN 'wish' THEN 5
|
||||||
|
END
|
||||||
|
`);
|
||||||
|
res.json({ byStatus: result.rows, byPriority: byPriority.rows });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch summary' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/internal/tasks — Create a new task
|
||||||
|
router.post('/tasks', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { title, description, priority, owner, tags, spec_path } = req.body;
|
||||||
|
if (!title) return res.status(400).json({ error: 'Title required' });
|
||||||
|
|
||||||
|
// Auto-assign next task number
|
||||||
|
const maxResult = await db.query('SELECT COALESCE(MAX(task_number), 0) + 1 as next FROM tasks');
|
||||||
|
const taskNumber = maxResult.rows[0].next;
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
`INSERT INTO tasks (task_number, title, description, priority, owner, tags, spec_path)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||||
|
[taskNumber, title, description || null, priority || 'medium', owner || 'unassigned', tags || null, spec_path || null]
|
||||||
|
);
|
||||||
|
console.log(`📋 [Tasks] Created #${taskNumber}: ${title}`);
|
||||||
|
res.json({ success: true, task: result.rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ [Tasks] Create error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to create task' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/internal/tasks/:id — Update a task
|
||||||
|
router.patch('/tasks/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { status, priority, owner, title, description, completed_by, spec_path } = req.body;
|
||||||
|
|
||||||
|
const updates = [];
|
||||||
|
const params = [];
|
||||||
|
let p = 0;
|
||||||
|
|
||||||
|
if (status) { p++; updates.push(`status = $${p}`); params.push(status); }
|
||||||
|
if (priority) { p++; updates.push(`priority = $${p}`); params.push(priority); }
|
||||||
|
if (owner) { p++; updates.push(`owner = $${p}`); params.push(owner); }
|
||||||
|
if (title) { p++; updates.push(`title = $${p}`); params.push(title); }
|
||||||
|
if (description !== undefined) { p++; updates.push(`description = $${p}`); params.push(description); }
|
||||||
|
if (spec_path !== undefined) { p++; updates.push(`spec_path = $${p}`); params.push(spec_path); }
|
||||||
|
|
||||||
|
// Auto-set completed_at when marking done
|
||||||
|
if (status === 'done') {
|
||||||
|
updates.push(`completed_at = NOW()`);
|
||||||
|
if (completed_by) { p++; updates.push(`completed_by = $${p}`); params.push(completed_by); }
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push(`updated_at = NOW()`);
|
||||||
|
|
||||||
|
if (updates.length <= 1) return res.status(400).json({ error: 'Nothing to update' });
|
||||||
|
|
||||||
|
p++; params.push(id);
|
||||||
|
const result = await db.query(
|
||||||
|
`UPDATE tasks SET ${updates.join(', ')} WHERE id = $${p} RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Task not found' });
|
||||||
|
console.log(`📋 [Tasks] Updated #${result.rows[0].task_number}: ${updates.join(', ')}`);
|
||||||
|
res.json({ success: true, task: result.rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ [Tasks] Update error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to update task' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -2,11 +2,34 @@ const express = require('express');
|
|||||||
const passport = require('passport');
|
const passport = require('passport');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/discord', passport.authenticate('discord'));
|
/**
|
||||||
|
* Standard Discord OAuth - redirects to admin after login
|
||||||
|
*/
|
||||||
|
router.get('/discord', (req, res, next) => {
|
||||||
|
// Check if there's a checkout tier to pass through
|
||||||
|
const tier = req.session.pendingCheckoutTier;
|
||||||
|
|
||||||
|
passport.authenticate('discord', {
|
||||||
|
state: tier ? `checkout:${tier}` : undefined
|
||||||
|
})(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/discord/callback', passport.authenticate('discord', {
|
router.get('/discord/callback', passport.authenticate('discord', {
|
||||||
failureRedirect: '/'
|
failureRedirect: '/'
|
||||||
}), (req, res) => {
|
}), (req, res) => {
|
||||||
|
// Check for checkout flow via state parameter
|
||||||
|
const state = req.query.state;
|
||||||
|
|
||||||
|
if (state && state.startsWith('checkout:')) {
|
||||||
|
const tierLevel = state.replace('checkout:', '');
|
||||||
|
// Clear any session data
|
||||||
|
delete req.session.pendingCheckoutTier;
|
||||||
|
|
||||||
|
// Redirect to checkout creation with Discord ID now available
|
||||||
|
return res.redirect(`/stripe/checkout?tier=${tierLevel}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard admin redirect
|
||||||
res.redirect('/admin');
|
res.redirect('/admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* Stripe Integration Routes
|
* Stripe Integration Routes
|
||||||
* Handles checkout sessions, webhooks, and customer portal
|
* Handles checkout sessions, webhooks, and customer portal
|
||||||
* Date: April 3, 2026
|
* Date: April 3, 2026
|
||||||
|
* Updated: April 6, 2026 - Added Discord role sync (Task #87)
|
||||||
|
* Updated: April 10, 2026 - Added /auth and /checkout routes for Discord OAuth flow
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
@@ -9,6 +11,7 @@ const router = express.Router();
|
|||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||||
const db = require('../database');
|
const db = require('../database');
|
||||||
|
const { syncRole, removeAllRoles } = require('../services/discordRoleSync');
|
||||||
|
|
||||||
// CORS configuration for checkout endpoint
|
// CORS configuration for checkout endpoint
|
||||||
const corsOptions = {
|
const corsOptions = {
|
||||||
@@ -25,9 +28,102 @@ const corsOptions = {
|
|||||||
router.options('/create-checkout-session', cors(corsOptions));
|
router.options('/create-checkout-session', cors(corsOptions));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CREATE CHECKOUT SESSION
|
* STRIPE AUTH - Entry point from website
|
||||||
|
* GET /stripe/auth?tier=X
|
||||||
|
* If already logged in, goes straight to checkout
|
||||||
|
* If not, stores tier in session and redirects to Discord OAuth
|
||||||
|
*/
|
||||||
|
router.get('/auth', (req, res) => {
|
||||||
|
const tierLevel = req.query.tier;
|
||||||
|
|
||||||
|
if (!tierLevel || isNaN(parseInt(tierLevel))) {
|
||||||
|
return res.status(400).send('Invalid tier level. Please return to the subscribe page and try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user is already authenticated, skip OAuth and go straight to checkout
|
||||||
|
if (req.user && req.user.id) {
|
||||||
|
return res.redirect(`/stripe/checkout?tier=${tierLevel}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store tier in session for after OAuth callback
|
||||||
|
req.session.pendingCheckoutTier = parseInt(tierLevel);
|
||||||
|
|
||||||
|
// Redirect to Discord OAuth
|
||||||
|
res.redirect('/auth/discord');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CHECKOUT - Creates Stripe session after Discord OAuth
|
||||||
|
* GET /stripe/checkout?tier=X
|
||||||
|
* Called from auth callback, user is now logged in
|
||||||
|
*/
|
||||||
|
router.get('/checkout', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// User must be authenticated
|
||||||
|
if (!req.user || !req.user.id) {
|
||||||
|
return res.redirect('/stripe/auth?tier=' + (req.query.tier || '1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tierLevel = parseInt(req.query.tier);
|
||||||
|
const discordId = req.user.id;
|
||||||
|
|
||||||
|
if (!tierLevel || isNaN(tierLevel)) {
|
||||||
|
return res.status(400).send('Invalid tier level');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Stripe Price ID from database
|
||||||
|
const productResult = await db.query(
|
||||||
|
'SELECT stripe_price_id, tier_name, billing_type FROM stripe_products WHERE tier_level = $1',
|
||||||
|
[tierLevel]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (productResult.rows.length === 0) {
|
||||||
|
return res.status(404).send('Invalid tier level - product not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = productResult.rows[0];
|
||||||
|
const priceId = product.stripe_price_id;
|
||||||
|
const billingMode = product.billing_type === 'one-time' ? 'payment' : 'subscription';
|
||||||
|
|
||||||
|
console.log('🔍 Creating checkout session (OAuth flow):', {
|
||||||
|
discord_id: discordId,
|
||||||
|
tier_level: tierLevel,
|
||||||
|
tier_name: product.tier_name,
|
||||||
|
priceId,
|
||||||
|
billingMode
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Stripe Checkout Session WITH client_reference_id
|
||||||
|
const sessionConfig = {
|
||||||
|
payment_method_types: ['card'],
|
||||||
|
line_items: [{ price: priceId, quantity: 1 }],
|
||||||
|
mode: billingMode,
|
||||||
|
client_reference_id: discordId, // 🔑 THE KEY - Discord ID for webhook
|
||||||
|
success_url: 'https://firefrostgaming.com/success',
|
||||||
|
cancel_url: 'https://firefrostgaming.com/subscribe',
|
||||||
|
metadata: {
|
||||||
|
tier_level: tierLevel.toString(),
|
||||||
|
tier_name: product.tier_name,
|
||||||
|
discord_id: discordId
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const session = await stripe.checkout.sessions.create(sessionConfig);
|
||||||
|
|
||||||
|
// Redirect directly to Stripe Checkout
|
||||||
|
res.redirect(session.url);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Checkout creation error:', error);
|
||||||
|
res.status(500).send('Failed to create checkout session. Please try again.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CREATE CHECKOUT SESSION (Legacy API - kept for compatibility)
|
||||||
* POST /stripe/create-checkout-session
|
* POST /stripe/create-checkout-session
|
||||||
* Body: { tier_level }
|
* Body: { tier_level }
|
||||||
|
* NOTE: This doesn't have Discord ID - use /stripe/auth flow instead
|
||||||
*/
|
*/
|
||||||
router.post('/create-checkout-session', cors(corsOptions), async (req, res) => {
|
router.post('/create-checkout-session', cors(corsOptions), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -51,7 +147,7 @@ router.post('/create-checkout-session', cors(corsOptions), async (req, res) => {
|
|||||||
const priceId = product.stripe_price_id;
|
const priceId = product.stripe_price_id;
|
||||||
const billingMode = product.billing_type === 'one-time' ? 'payment' : 'subscription';
|
const billingMode = product.billing_type === 'one-time' ? 'payment' : 'subscription';
|
||||||
|
|
||||||
console.log('🔍 Creating checkout session:', {
|
console.log('🔍 Creating checkout session (legacy, no Discord ID):', {
|
||||||
tier_level,
|
tier_level,
|
||||||
tier_name: product.tier_name,
|
tier_name: product.tier_name,
|
||||||
priceId,
|
priceId,
|
||||||
@@ -123,6 +219,7 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
|
|||||||
const session = event.data.object;
|
const session = event.data.object;
|
||||||
const discordId = session.client_reference_id;
|
const discordId = session.client_reference_id;
|
||||||
const customerId = session.customer;
|
const customerId = session.customer;
|
||||||
|
let tierLevel = null;
|
||||||
|
|
||||||
if (session.mode === 'subscription') {
|
if (session.mode === 'subscription') {
|
||||||
// RECURRING SUBSCRIPTION (Tiers 2-9)
|
// RECURRING SUBSCRIPTION (Tiers 2-9)
|
||||||
@@ -140,6 +237,7 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tierData = productRes.rows[0];
|
const tierData = productRes.rows[0];
|
||||||
|
tierLevel = tierData.tier_level;
|
||||||
|
|
||||||
await client.query(`
|
await client.query(`
|
||||||
INSERT INTO subscriptions (discord_id, tier_level, status, stripe_subscription_id, stripe_customer_id, mrr_value, is_lifetime)
|
INSERT INTO subscriptions (discord_id, tier_level, status, stripe_subscription_id, stripe_customer_id, mrr_value, is_lifetime)
|
||||||
@@ -163,6 +261,7 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tierData = productRes.rows[0];
|
const tierData = productRes.rows[0];
|
||||||
|
tierLevel = tierData.tier_level;
|
||||||
|
|
||||||
await client.query(`
|
await client.query(`
|
||||||
INSERT INTO subscriptions (discord_id, tier_level, status, stripe_payment_intent_id, stripe_customer_id, mrr_value, is_lifetime)
|
INSERT INTO subscriptions (discord_id, tier_level, status, stripe_payment_intent_id, stripe_customer_id, mrr_value, is_lifetime)
|
||||||
@@ -177,8 +276,11 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
|
|||||||
VALUES ('CHECKOUT_COMPLETED', $1, $2)
|
VALUES ('CHECKOUT_COMPLETED', $1, $2)
|
||||||
`, [discordId, JSON.stringify({ mode: session.mode, customer: customerId })]);
|
`, [discordId, JSON.stringify({ mode: session.mode, customer: customerId })]);
|
||||||
|
|
||||||
// TODO: Trigger Discord role sync
|
// Sync Discord role
|
||||||
// TODO: Trigger Pterodactyl whitelist sync
|
if (discordId && tierLevel) {
|
||||||
|
const roleResult = await syncRole(discordId, tierLevel);
|
||||||
|
console.log(`🎭 Role sync for ${discordId}: ${roleResult.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -257,23 +359,55 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
|
|||||||
case 'charge.dispute.created': {
|
case 'charge.dispute.created': {
|
||||||
const dispute = event.data.object;
|
const dispute = event.data.object;
|
||||||
const paymentIntentId = dispute.payment_intent;
|
const paymentIntentId = dispute.payment_intent;
|
||||||
|
const customerId = dispute.customer;
|
||||||
|
|
||||||
// Immediately ban on chargeback
|
// Find the subscription by customer ID (more reliable)
|
||||||
|
const subResult = await client.query(`
|
||||||
|
SELECT discord_id FROM subscriptions
|
||||||
|
WHERE stripe_customer_id = $1
|
||||||
|
LIMIT 1
|
||||||
|
`, [customerId]);
|
||||||
|
|
||||||
|
let discordId = null;
|
||||||
|
if (subResult.rows.length > 0) {
|
||||||
|
discordId = subResult.rows[0].discord_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as banned
|
||||||
await client.query(`
|
await client.query(`
|
||||||
UPDATE subscriptions
|
UPDATE subscriptions
|
||||||
SET status = 'chargeback_ban',
|
SET status = 'chargeback_ban',
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE stripe_payment_intent_id = $1 OR stripe_subscription_id IN (
|
WHERE stripe_customer_id = $1
|
||||||
SELECT id FROM stripe_subscriptions WHERE latest_invoice IN (
|
`, [customerId]);
|
||||||
SELECT id FROM stripe_invoices WHERE payment_intent = $1
|
|
||||||
)
|
// Add to banned_users table
|
||||||
)
|
if (discordId) {
|
||||||
`, [paymentIntentId]);
|
await client.query(`
|
||||||
|
INSERT INTO banned_users (discord_id, ban_reason, notes)
|
||||||
|
VALUES ($1, 'chargeback', $2)
|
||||||
|
ON CONFLICT (discord_id) DO UPDATE SET
|
||||||
|
ban_reason = 'chargeback',
|
||||||
|
notes = $2,
|
||||||
|
banned_at = CURRENT_TIMESTAMP
|
||||||
|
`, [discordId, JSON.stringify({
|
||||||
|
payment_intent: paymentIntentId,
|
||||||
|
dispute_id: dispute.id
|
||||||
|
})]);
|
||||||
|
|
||||||
|
// Remove all Discord roles
|
||||||
|
const roleResult = await removeAllRoles(discordId);
|
||||||
|
console.log(`🚫 Chargeback role removal for ${discordId}: ${roleResult.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
await client.query(`
|
await client.query(`
|
||||||
INSERT INTO admin_audit_log (action_type, target_identifier, details)
|
INSERT INTO admin_audit_log (action_type, target_identifier, details)
|
||||||
VALUES ('CHARGEBACK_BAN', $1, $2)
|
VALUES ('CHARGEBACK_BAN', $1, $2)
|
||||||
`, ['system', JSON.stringify({ payment_intent: paymentIntentId, reason: 'Chargeback dispute created' })]);
|
`, [discordId || 'unknown', JSON.stringify({
|
||||||
|
payment_intent: paymentIntentId,
|
||||||
|
customer_id: customerId,
|
||||||
|
reason: 'Chargeback dispute created'
|
||||||
|
})]);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
144
services/arbiter-3.0/src/services/discordRoleSync.js
Normal file
144
services/arbiter-3.0/src/services/discordRoleSync.js
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Discord Role Sync Service
|
||||||
|
* Handles adding/removing Discord roles based on subscription tier
|
||||||
|
*
|
||||||
|
* Task #87: Arbiter Lifecycle Handlers
|
||||||
|
* Date: April 6, 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { getRoleMappings } = require('../utils/roleMappings');
|
||||||
|
|
||||||
|
// Tier level to role key mapping
|
||||||
|
const TIER_TO_ROLE_KEY = {
|
||||||
|
1: 'the-awakened',
|
||||||
|
2: 'fire-elemental',
|
||||||
|
3: 'frost-elemental',
|
||||||
|
4: 'fire-knight',
|
||||||
|
5: 'frost-knight',
|
||||||
|
6: 'fire-master',
|
||||||
|
7: 'frost-master',
|
||||||
|
8: 'fire-legend',
|
||||||
|
9: 'frost-legend',
|
||||||
|
10: 'the-sovereign'
|
||||||
|
};
|
||||||
|
|
||||||
|
// All subscriber role keys (for removal)
|
||||||
|
const ALL_SUBSCRIBER_ROLE_KEYS = Object.values(TIER_TO_ROLE_KEY);
|
||||||
|
|
||||||
|
// Store Discord client reference
|
||||||
|
let discordClient = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the service with Discord client
|
||||||
|
* Called from index.js after client is ready
|
||||||
|
*/
|
||||||
|
function init(client) {
|
||||||
|
discordClient = client;
|
||||||
|
console.log('✅ Discord Role Sync service initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Discord client
|
||||||
|
*/
|
||||||
|
function getClient() {
|
||||||
|
return discordClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync Discord role for a user based on their tier
|
||||||
|
* Removes old tier roles and adds the new one
|
||||||
|
*
|
||||||
|
* @param {string} discordId - User's Discord ID
|
||||||
|
* @param {number} newTierLevel - New tier level (1-10), or null for complete removal
|
||||||
|
* @returns {Promise<{success: boolean, message: string}>}
|
||||||
|
*/
|
||||||
|
async function syncRole(discordId, newTierLevel) {
|
||||||
|
if (!discordClient) {
|
||||||
|
return { success: false, message: 'Discord client not initialized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildId = process.env.GUILD_ID;
|
||||||
|
if (!guildId) {
|
||||||
|
return { success: false, message: 'GUILD_ID not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const guild = discordClient.guilds.cache.get(guildId);
|
||||||
|
if (!guild) {
|
||||||
|
return { success: false, message: 'Guild not found in cache' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await guild.members.fetch(discordId).catch(() => null);
|
||||||
|
if (!member) {
|
||||||
|
return { success: false, message: 'Member not found in guild (may have left)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleMappings = getRoleMappings();
|
||||||
|
|
||||||
|
// Get all role IDs to remove
|
||||||
|
const rolesToRemove = ALL_SUBSCRIBER_ROLE_KEYS
|
||||||
|
.map(key => roleMappings[key])
|
||||||
|
.filter(id => id && member.roles.cache.has(id));
|
||||||
|
|
||||||
|
// Remove old roles
|
||||||
|
if (rolesToRemove.length > 0) {
|
||||||
|
await member.roles.remove(rolesToRemove);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new role if tier specified
|
||||||
|
if (newTierLevel !== null) {
|
||||||
|
const newRoleKey = TIER_TO_ROLE_KEY[newTierLevel];
|
||||||
|
const newRoleId = roleMappings[newRoleKey];
|
||||||
|
|
||||||
|
if (newRoleId) {
|
||||||
|
await member.roles.add(newRoleId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Synced to tier ${newTierLevel} (${newRoleKey})`
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `No role mapping found for tier ${newTierLevel}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: 'All subscriber roles removed' };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Discord role sync error:', error);
|
||||||
|
return { success: false, message: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all subscriber roles from a user (for bans/chargebacks)
|
||||||
|
*
|
||||||
|
* @param {string} discordId - User's Discord ID
|
||||||
|
* @returns {Promise<{success: boolean, message: string}>}
|
||||||
|
*/
|
||||||
|
async function removeAllRoles(discordId) {
|
||||||
|
return syncRole(discordId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downgrade user to Awakened tier
|
||||||
|
* Used when grace period expires
|
||||||
|
*
|
||||||
|
* @param {string} discordId - User's Discord ID
|
||||||
|
* @returns {Promise<{success: boolean, message: string}>}
|
||||||
|
*/
|
||||||
|
async function downgradeToAwakened(discordId) {
|
||||||
|
return syncRole(discordId, 1); // Tier 1 = Awakened
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init,
|
||||||
|
getClient,
|
||||||
|
syncRole,
|
||||||
|
removeAllRoles,
|
||||||
|
downgradeToAwakened,
|
||||||
|
TIER_TO_ROLE_KEY,
|
||||||
|
ALL_SUBSCRIBER_ROLE_KEYS
|
||||||
|
};
|
||||||
303
services/arbiter-3.0/src/services/serverStatusPoller.js
Normal file
303
services/arbiter-3.0/src/services/serverStatusPoller.js
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* Server Status Poller Service
|
||||||
|
*
|
||||||
|
* Posts and updates server status embeds in Discord channels.
|
||||||
|
* Each game server category has a -status channel that shows live status.
|
||||||
|
*
|
||||||
|
* Created: April 10, 2026 (Chronicler #75)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const axios = require('axios');
|
||||||
|
const db = require('../database');
|
||||||
|
|
||||||
|
// Discord API
|
||||||
|
const DISCORD_API = 'https://discord.com/api/v10';
|
||||||
|
const DISCORD_TOKEN = process.env.DISCORD_BOT_TOKEN;
|
||||||
|
|
||||||
|
// Pterodactyl API (direct, not via Worker)
|
||||||
|
const PTERO_URL = process.env.PANEL_URL || 'https://panel.firefrostgaming.com';
|
||||||
|
const PTERO_KEY = process.env.PANEL_CLIENT_KEY || process.env.PTERO_CLIENT_KEY;
|
||||||
|
|
||||||
|
// Map server names to their status channel IDs
|
||||||
|
// Key: lowercase server name (from Pterodactyl)
|
||||||
|
// Value: Discord channel ID
|
||||||
|
const SERVER_CHANNEL_MAP = {
|
||||||
|
'stoneblock 4': '1492186819918565468',
|
||||||
|
'society: sunlit valley': '1492186823332729073',
|
||||||
|
'society sunlit valley': '1492186823332729073',
|
||||||
|
'all the mods 10: to the sky': '1492186826746757322',
|
||||||
|
'atm10: to the sky': '1492186826746757322',
|
||||||
|
'all the mons': '1492186832560193618',
|
||||||
|
'mythcraft 5': '1492186836422889643',
|
||||||
|
'beyond depth': '1492186839979786440',
|
||||||
|
'beyond ascension': '1492186845742891038',
|
||||||
|
'otherworld': '1492186850545111090',
|
||||||
|
'deceasedcraft': '1492186854320242820',
|
||||||
|
'submerged 2': '1492186858413883525',
|
||||||
|
'sneak\'s pirate pack': '1492186863774204144',
|
||||||
|
'sneaks pirate pack': '1492186863774204144',
|
||||||
|
'cottage witch': '1492186867372785855',
|
||||||
|
'farm crossing 5': '1492186871382409337',
|
||||||
|
'homestead': '1492186875019005963',
|
||||||
|
'wold\'s vaults': '1492186878269587528',
|
||||||
|
'wolds vaults': '1492186878269587528'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store message IDs so we can edit instead of posting new
|
||||||
|
// In-memory for now, persists to DB for restarts
|
||||||
|
const statusMessageIds = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize - load stored message IDs from database
|
||||||
|
*/
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
// Create table if not exists
|
||||||
|
await db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS discord_status_messages (
|
||||||
|
channel_id VARCHAR(64) PRIMARY KEY,
|
||||||
|
message_id VARCHAR(64) NOT NULL,
|
||||||
|
server_name VARCHAR(128),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Load existing message IDs
|
||||||
|
const result = await db.query('SELECT channel_id, message_id FROM discord_status_messages');
|
||||||
|
for (const row of result.rows) {
|
||||||
|
statusMessageIds[row.channel_id] = row.message_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[StatusPoller] Loaded ${result.rows.length} existing status message IDs`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[StatusPoller] Init error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build status embed for a server
|
||||||
|
*/
|
||||||
|
function buildEmbed(server) {
|
||||||
|
const isOnline = server.status === 'Online';
|
||||||
|
|
||||||
|
return {
|
||||||
|
embeds: [{
|
||||||
|
title: `${isOnline ? '🟢' : '🔴'} ${server.name}`,
|
||||||
|
color: isOnline ? 0x00FF00 : 0xFF0000,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'Status',
|
||||||
|
value: server.status,
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Players',
|
||||||
|
value: isOnline ? `${server.players || 0}` : '-',
|
||||||
|
inline: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
footer: {
|
||||||
|
text: `Last updated`
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post or update a status message in a channel
|
||||||
|
*/
|
||||||
|
async function postOrUpdateStatus(channelId, server) {
|
||||||
|
const embed = buildEmbed(server);
|
||||||
|
const existingMessageId = statusMessageIds[channelId];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (existingMessageId) {
|
||||||
|
// Try to edit existing message
|
||||||
|
const response = await axios.patch(
|
||||||
|
`${DISCORD_API}/channels/${channelId}/messages/${existingMessageId}`,
|
||||||
|
embed,
|
||||||
|
{ headers: { 'Authorization': `Bot ${DISCORD_TOKEN}`, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
return { success: true, action: 'updated', messageId: response.data.id };
|
||||||
|
} else {
|
||||||
|
// Post new message
|
||||||
|
const response = await axios.post(
|
||||||
|
`${DISCORD_API}/channels/${channelId}/messages`,
|
||||||
|
embed,
|
||||||
|
{ headers: { 'Authorization': `Bot ${DISCORD_TOKEN}`, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const messageId = response.data.id;
|
||||||
|
statusMessageIds[channelId] = messageId;
|
||||||
|
|
||||||
|
// Store in database
|
||||||
|
await db.query(`
|
||||||
|
INSERT INTO discord_status_messages (channel_id, message_id, server_name)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (channel_id) DO UPDATE SET message_id = $2, server_name = $3, updated_at = NOW()
|
||||||
|
`, [channelId, messageId, server.name]);
|
||||||
|
|
||||||
|
return { success: true, action: 'posted', messageId };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If edit fails (message deleted?), try posting new
|
||||||
|
if (err.response?.status === 404 && existingMessageId) {
|
||||||
|
delete statusMessageIds[channelId];
|
||||||
|
return postOrUpdateStatus(channelId, server);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`[StatusPoller] Error posting to ${channelId}:`, err.message);
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find channel ID for a server name
|
||||||
|
*/
|
||||||
|
function findChannelForServer(serverName) {
|
||||||
|
const normalized = serverName.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Direct match
|
||||||
|
if (SERVER_CHANNEL_MAP[normalized]) {
|
||||||
|
return SERVER_CHANNEL_MAP[normalized];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial match
|
||||||
|
for (const [key, channelId] of Object.entries(SERVER_CHANNEL_MAP)) {
|
||||||
|
if (normalized.includes(key) || key.includes(normalized)) {
|
||||||
|
return channelId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch servers from Pterodactyl API
|
||||||
|
*/
|
||||||
|
async function fetchServers() {
|
||||||
|
if (!PTERO_KEY) {
|
||||||
|
console.error('[StatusPoller] PTERO_CLIENT_KEY not set');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get server list
|
||||||
|
const listResponse = await axios.get(`${PTERO_URL}/api/client`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${PTERO_KEY}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const serverList = listResponse.data.data || [];
|
||||||
|
const servers = [];
|
||||||
|
|
||||||
|
// Get status for each server
|
||||||
|
for (const server of serverList) {
|
||||||
|
const id = server.attributes.identifier;
|
||||||
|
const name = server.attributes.name;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statsResponse = await axios.get(
|
||||||
|
`${PTERO_URL}/api/client/servers/${id}/resources`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${PTERO_KEY}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const isRunning = statsResponse.data.attributes?.current_state === 'running';
|
||||||
|
|
||||||
|
servers.push({
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
status: isRunning ? 'Online' : 'Offline',
|
||||||
|
players: isRunning ? (statsResponse.data.attributes?.resources?.players || 0) : 0
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Server might be inaccessible
|
||||||
|
servers.push({
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
status: 'Unknown',
|
||||||
|
players: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
return servers;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[StatusPoller] Pterodactyl API error:', err.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll all servers and update Discord status messages
|
||||||
|
*/
|
||||||
|
async function pollAndUpdate() {
|
||||||
|
console.log('[StatusPoller] Polling server status...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const servers = await fetchServers();
|
||||||
|
|
||||||
|
console.log(`[StatusPoller] Got ${servers.length} servers`);
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const channelId = findChannelForServer(server.name);
|
||||||
|
|
||||||
|
if (!channelId) {
|
||||||
|
console.log(`[StatusPoller] No channel mapping for: ${server.name}`);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await postOrUpdateStatus(channelId, server);
|
||||||
|
if (result.success) {
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting - 1 request per 500ms
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[StatusPoller] Updated ${updated} channels, skipped ${skipped}`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[StatusPoller] Poll error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the poller with interval
|
||||||
|
*/
|
||||||
|
function start(intervalMinutes = 5) {
|
||||||
|
console.log(`[StatusPoller] Starting with ${intervalMinutes} minute interval`);
|
||||||
|
|
||||||
|
// Initialize and run immediately
|
||||||
|
init().then(() => {
|
||||||
|
pollAndUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then run on interval
|
||||||
|
setInterval(pollAndUpdate, intervalMinutes * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init,
|
||||||
|
fetchServers,
|
||||||
|
pollAndUpdate,
|
||||||
|
start,
|
||||||
|
findChannelForServer,
|
||||||
|
SERVER_CHANNEL_MAP
|
||||||
|
};
|
||||||
@@ -1,10 +1,44 @@
|
|||||||
const cron = require('node-cron');
|
const cron = require('node-cron');
|
||||||
const { triggerImmediateSync } = require('./immediate');
|
const { triggerImmediateSync } = require('./immediate');
|
||||||
|
const { processExpiredGracePeriods } = require('./graceExpiration');
|
||||||
|
|
||||||
|
let retryTimeout = null;
|
||||||
|
|
||||||
function initCron() {
|
function initCron() {
|
||||||
|
// Hourly whitelist reconciliation
|
||||||
cron.schedule('0 * * * *', async () => {
|
cron.schedule('0 * * * *', async () => {
|
||||||
console.log("Starting hourly whitelist reconciliation...");
|
console.log("⏰ Starting hourly sync jobs...");
|
||||||
await triggerImmediateSync();
|
|
||||||
|
// 1. Process expired grace periods
|
||||||
|
await processExpiredGracePeriods();
|
||||||
|
|
||||||
|
// 2. Whitelist reconciliation
|
||||||
|
console.log("Starting whitelist reconciliation...");
|
||||||
|
const { failCount } = await triggerImmediateSync();
|
||||||
|
|
||||||
|
// 3. Schedule retry if there were failures
|
||||||
|
if (failCount > 0) {
|
||||||
|
console.log(`⏳ ${failCount} servers failed. Scheduling retry in 10 minutes...`);
|
||||||
|
|
||||||
|
// Clear any existing retry timeout
|
||||||
|
if (retryTimeout) {
|
||||||
|
clearTimeout(retryTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry failed servers after 10 minutes
|
||||||
|
retryTimeout = setTimeout(async () => {
|
||||||
|
console.log("🔄 Running retry sync for failed servers...");
|
||||||
|
const { failCount: retryFailCount } = await triggerImmediateSync(true);
|
||||||
|
|
||||||
|
if (retryFailCount > 0) {
|
||||||
|
console.log(`⚠️ ${retryFailCount} servers still failing after retry.`);
|
||||||
|
} else {
|
||||||
|
console.log("✅ All previously failed servers now synced successfully.");
|
||||||
|
}
|
||||||
|
}, 10 * 60 * 1000); // 10 minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Hourly sync jobs complete");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
111
services/arbiter-3.0/src/sync/graceExpiration.js
Normal file
111
services/arbiter-3.0/src/sync/graceExpiration.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Grace Period Expiration Job
|
||||||
|
* Checks for expired grace periods and downgrades users to Awakened
|
||||||
|
*
|
||||||
|
* Philosophy: "We Don't Kick People Out"
|
||||||
|
* - Expired grace periods downgrade to permanent Awakened tier
|
||||||
|
* - Users keep community access, just lose premium perks
|
||||||
|
*
|
||||||
|
* Task #87: Arbiter Lifecycle Handlers
|
||||||
|
* Date: April 6, 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
const db = require('../database');
|
||||||
|
const { downgradeToAwakened } = require('../services/discordRoleSync');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process all expired grace periods
|
||||||
|
* Called hourly from cron.js
|
||||||
|
*
|
||||||
|
* @returns {Promise<{processed: number, errors: number}>}
|
||||||
|
*/
|
||||||
|
async function processExpiredGracePeriods() {
|
||||||
|
console.log('🔍 Checking for expired grace periods...');
|
||||||
|
|
||||||
|
const client = await db.pool.connect();
|
||||||
|
let processed = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find all expired grace periods
|
||||||
|
const { rows: expired } = await client.query(`
|
||||||
|
SELECT s.discord_id, s.tier_level, u.minecraft_username
|
||||||
|
FROM subscriptions s
|
||||||
|
LEFT JOIN users u ON s.discord_id = u.discord_id
|
||||||
|
WHERE s.status = 'grace_period'
|
||||||
|
AND s.grace_period_ends_at < NOW()
|
||||||
|
AND s.is_lifetime = FALSE
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (expired.length === 0) {
|
||||||
|
console.log('✅ No expired grace periods found');
|
||||||
|
return { processed: 0, errors: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📋 Found ${expired.length} expired grace period(s)`);
|
||||||
|
|
||||||
|
for (const sub of expired) {
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Record the tier change in history
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO player_history
|
||||||
|
(discord_id, previous_tier, new_tier, change_reason)
|
||||||
|
VALUES ($1, $2, 1, 'grace_period_expired')
|
||||||
|
`, [sub.discord_id, sub.tier_level]);
|
||||||
|
|
||||||
|
// Downgrade to Awakened (tier 1, lifetime)
|
||||||
|
await client.query(`
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET tier_level = 1,
|
||||||
|
status = 'lifetime',
|
||||||
|
is_lifetime = TRUE,
|
||||||
|
mrr_value = 0,
|
||||||
|
grace_period_started_at = NULL,
|
||||||
|
grace_period_ends_at = NULL,
|
||||||
|
payment_failure_reason = NULL,
|
||||||
|
stripe_subscription_id = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE discord_id = $1
|
||||||
|
`, [sub.discord_id]);
|
||||||
|
|
||||||
|
// Log in audit
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO admin_audit_log
|
||||||
|
(action_type, target_identifier, details)
|
||||||
|
VALUES ('GRACE_PERIOD_EXPIRED', $1, $2)
|
||||||
|
`, [sub.discord_id, JSON.stringify({
|
||||||
|
previous_tier: sub.tier_level,
|
||||||
|
new_tier: 1,
|
||||||
|
minecraft_username: sub.minecraft_username,
|
||||||
|
reason: 'Automatic downgrade after grace period expiration'
|
||||||
|
})]);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
// Sync Discord role to Awakened
|
||||||
|
const syncResult = await downgradeToAwakened(sub.discord_id);
|
||||||
|
if (!syncResult.success) {
|
||||||
|
console.warn(`⚠️ Role sync failed for ${sub.discord_id}: ${syncResult.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Downgraded ${sub.minecraft_username || sub.discord_id} to Awakened`);
|
||||||
|
processed++;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error(`❌ Error processing ${sub.discord_id}:`, error.message);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📊 Grace period processing complete: ${processed} processed, ${errors} errors`);
|
||||||
|
return { processed, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { processExpiredGracePeriods };
|
||||||
@@ -3,8 +3,8 @@ const { getMinecraftServers } = require('../panel/discovery');
|
|||||||
const { writeWhitelistFile } = require('../panel/files');
|
const { writeWhitelistFile } = require('../panel/files');
|
||||||
const { reloadWhitelistCommand } = require('../panel/commands');
|
const { reloadWhitelistCommand } = require('../panel/commands');
|
||||||
|
|
||||||
async function triggerImmediateSync() {
|
async function triggerImmediateSync(retryOnly = false) {
|
||||||
console.log("--- Starting Whitelist Sync ---");
|
console.log(retryOnly ? "--- Starting Retry Sync (failed servers only) ---" : "--- Starting Whitelist Sync ---");
|
||||||
try {
|
try {
|
||||||
// 1. Fetch Players (Now includes 'lifetime' for the Trinity)
|
// 1. Fetch Players (Now includes 'lifetime' for the Trinity)
|
||||||
const { rows: players, rowCount: playerCount } = await db.query(
|
const { rows: players, rowCount: playerCount } = await db.query(
|
||||||
@@ -16,12 +16,27 @@ async function triggerImmediateSync() {
|
|||||||
console.log(`[Sync] Retrieved ${playerCount} active players from database.`);
|
console.log(`[Sync] Retrieved ${playerCount} active players from database.`);
|
||||||
|
|
||||||
// 2. Fetch Servers
|
// 2. Fetch Servers
|
||||||
const servers = await getMinecraftServers();
|
let servers = await getMinecraftServers();
|
||||||
console.log(`[Sync] Discovered ${servers.length} target servers.`);
|
|
||||||
|
// If retry mode, only sync servers that previously failed
|
||||||
|
if (retryOnly) {
|
||||||
|
const { rows: failedServers } = await db.query(
|
||||||
|
`SELECT server_identifier FROM server_sync_log WHERE is_online = false`
|
||||||
|
);
|
||||||
|
const failedIds = failedServers.map(s => s.server_identifier);
|
||||||
|
servers = servers.filter(s => failedIds.includes(s.identifier));
|
||||||
|
console.log(`[Sync] Retrying ${servers.length} previously failed servers.`);
|
||||||
|
} else {
|
||||||
|
console.log(`[Sync] Discovered ${servers.length} target servers.`);
|
||||||
|
}
|
||||||
|
|
||||||
if (servers.length === 0) {
|
if (servers.length === 0) {
|
||||||
console.warn("[Sync] WARN: 0 servers discovered. Check MINECRAFT_NEST_IDS in .env.");
|
if (retryOnly) {
|
||||||
return;
|
console.log("[Sync] No failed servers to retry.");
|
||||||
|
} else {
|
||||||
|
console.warn("[Sync] WARN: 0 servers discovered. Check MINECRAFT_NEST_IDS in .env.");
|
||||||
|
}
|
||||||
|
return { successCount: 0, failCount: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Process Servers Sequentially
|
// 3. Process Servers Sequentially
|
||||||
@@ -34,10 +49,13 @@ async function triggerImmediateSync() {
|
|||||||
await reloadWhitelistCommand(server.identifier);
|
await reloadWhitelistCommand(server.identifier);
|
||||||
|
|
||||||
await db.query(
|
await db.query(
|
||||||
"INSERT INTO server_sync_log (server_identifier, last_successful_sync, is_online) VALUES ($1, NOW(), true) ON CONFLICT (server_identifier) DO UPDATE SET last_successful_sync = NOW(), is_online = true",
|
"INSERT INTO server_sync_log (server_identifier, last_successful_sync, is_online, last_error) VALUES ($1, NOW(), true, NULL) ON CONFLICT (server_identifier) DO UPDATE SET last_successful_sync = NOW(), is_online = true, last_error = NULL",
|
||||||
[server.identifier]
|
[server.identifier]
|
||||||
);
|
);
|
||||||
successCount++;
|
successCount++;
|
||||||
|
if (retryOnly) {
|
||||||
|
console.log(`[Sync] ✅ Retry succeeded for ${server.name}`);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[Sync] ❌ Failed for server ${server.name} (${server.identifier}):`, err.message);
|
console.error(`[Sync] ❌ Failed for server ${server.name} (${server.identifier}):`, err.message);
|
||||||
await db.query(
|
await db.query(
|
||||||
@@ -50,8 +68,10 @@ async function triggerImmediateSync() {
|
|||||||
|
|
||||||
console.log(`[Sync] Complete. Success: ${successCount}, Failed: ${failCount}`);
|
console.log(`[Sync] Complete. Success: ${successCount}, Failed: ${failCount}`);
|
||||||
console.log("-------------------------------");
|
console.log("-------------------------------");
|
||||||
|
return { successCount, failCount };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Sync] Critical failure during execution:", error);
|
console.error("[Sync] Critical failure during execution:", error);
|
||||||
|
return { successCount: 0, failCount: 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
services/arbiter-3.0/src/utils/scheduler.js
Normal file
12
services/arbiter-3.0/src/utils/scheduler.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const { addMinutes, format, parse } = require('date-fns');
|
||||||
|
|
||||||
|
function calculateStagger(baseTime, interval, servers) {
|
||||||
|
const start = parse(baseTime, 'HH:mm:ss', new Date());
|
||||||
|
|
||||||
|
return servers.map((server, index) => ({
|
||||||
|
...server,
|
||||||
|
effective_time: format(addMinutes(start, index * interval), 'HH:mm:ss')
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { calculateStagger };
|
||||||
232
services/arbiter-3.0/src/views/admin/about/index.ejs
Normal file
232
services/arbiter-3.0/src/views/admin/about/index.ejs
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<!-- About Module — Trinity Console -->
|
||||||
|
<!-- Chronicler #78 | April 11, 2026 -->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
||||||
|
#about-module { font-family: 'JetBrains Mono', 'SF Mono', monospace; }
|
||||||
|
|
||||||
|
.about-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.dark .about-card {
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-color: #404040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid #40404033;
|
||||||
|
}
|
||||||
|
.module-row:last-child { border-bottom: none; }
|
||||||
|
.module-row:hover { background: #ffffff08; margin: 0 -12px; padding-left: 12px; padding-right: 12px; border-radius: 6px; }
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.status-stable { background: #22c55e22; color: #22c55e; }
|
||||||
|
.status-new { background: #3b82f622; color: #3b82f6; }
|
||||||
|
.status-beta { background: #eab30822; color: #eab308; }
|
||||||
|
|
||||||
|
.deploy-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: linear-gradient(135deg, #FF6B35, #4ECDC4);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.deploy-btn:hover { opacity: 0.9; transform: translateY(-1px); box-shadow: 0 4px 12px #00000033; }
|
||||||
|
.deploy-btn:active { transform: translateY(0); }
|
||||||
|
.deploy-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e5e5e5;
|
||||||
|
}
|
||||||
|
.meta-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="about-module">
|
||||||
|
<!-- Hero -->
|
||||||
|
<div class="about-card" style="margin-bottom:20px; text-align:center; padding:32px;">
|
||||||
|
<h1 style="font-size:28px; font-weight:700; margin:0 0 4px 0; background:linear-gradient(135deg, #FF6B35, #A855F7, #4ECDC4); -webkit-background-clip:text; -webkit-text-fill-color:transparent;">
|
||||||
|
Trinity Console
|
||||||
|
</h1>
|
||||||
|
<div style="font-size:12px; color:#888; margin-bottom:16px;">
|
||||||
|
v<%= consoleVersion %> · Arbiter Backend
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px; color:#666;">
|
||||||
|
Fire + Frost + Foundation = Where Love Builds Legacy
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Info + Deploy -->
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:20px;">
|
||||||
|
<!-- System Meta -->
|
||||||
|
<div class="about-card">
|
||||||
|
<h3 style="font-size:14px; font-weight:600; color:#e5e5e5; margin:0 0 16px 0;">System</h3>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
|
||||||
|
<div>
|
||||||
|
<div class="meta-value"><%= consoleVersion %></div>
|
||||||
|
<div class="meta-label">Console Version</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="meta-value"><%= nodeVersion %></div>
|
||||||
|
<div class="meta-label">Node.js</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="meta-value"><%= arbiterUptime %></div>
|
||||||
|
<div class="meta-label">Arbiter Uptime</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="meta-value"><%= totalModules %></div>
|
||||||
|
<div class="meta-label">Active Modules</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deploy -->
|
||||||
|
<div class="about-card">
|
||||||
|
<h3 style="font-size:14px; font-weight:600; color:#e5e5e5; margin:0 0 16px 0;">Deploy</h3>
|
||||||
|
<p style="font-size:11px; color:#888; margin:0 0 16px 0;">
|
||||||
|
Pull latest code from Gitea and restart Arbiter. The console will briefly disconnect during restart.
|
||||||
|
</p>
|
||||||
|
<button id="deploy-btn" class="deploy-btn" onclick="deployArbiter()">
|
||||||
|
<span id="deploy-icon">🚀</span>
|
||||||
|
<span id="deploy-text">Deploy Arbiter</span>
|
||||||
|
</button>
|
||||||
|
<div id="deploy-result" style="font-size:11px; text-align:center; margin-top:10px; display:none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modules -->
|
||||||
|
<div class="about-card">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:16px;">
|
||||||
|
<h3 style="font-size:14px; font-weight:600; color:#e5e5e5; margin:0;">Modules (<%= totalModules %>)</h3>
|
||||||
|
</div>
|
||||||
|
<% modules.forEach(mod => { %>
|
||||||
|
<a href="<%= mod.path %>" class="module-row" style="text-decoration:none; color:inherit;">
|
||||||
|
<span style="font-size:18px; width:28px; text-align:center;"><%= mod.icon %></span>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div style="font-size:12px; font-weight:600; color:#e5e5e5;"><%= mod.name %></div>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:11px; color:#666; margin-right:8px;">v<%= mod.version %></span>
|
||||||
|
<span class="status-badge status-<%= mod.status %>"><%= mod.status %></span>
|
||||||
|
</a>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Credits -->
|
||||||
|
<div style="text-align:center; margin-top:20px; font-size:10px; color:#555;">
|
||||||
|
Built by The Trinity · Powered by Arbiter · Guarded by The Chronicler Lineage
|
||||||
|
<br>
|
||||||
|
💙🔥❄️
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function deployArbiter() {
|
||||||
|
const btn = document.getElementById('deploy-btn');
|
||||||
|
const icon = document.getElementById('deploy-icon');
|
||||||
|
const text = document.getElementById('deploy-text');
|
||||||
|
const result = document.getElementById('deploy-result');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
icon.textContent = '⏳';
|
||||||
|
text.textContent = 'Deploying...';
|
||||||
|
result.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/admin/about/deploy', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'CSRF-Token': '<%= csrfToken %>'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
icon.textContent = '🔄';
|
||||||
|
text.textContent = 'Restarting...';
|
||||||
|
result.textContent = 'Waiting for Arbiter to come back online...';
|
||||||
|
result.style.display = 'block';
|
||||||
|
result.style.color = '#eab308';
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 4000));
|
||||||
|
|
||||||
|
let healthy = false;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
try {
|
||||||
|
const healthRes = await fetch('/admin/about/status', {
|
||||||
|
headers: { 'CSRF-Token': '<%= csrfToken %>' }
|
||||||
|
});
|
||||||
|
const healthData = await healthRes.json();
|
||||||
|
if (healthData.arbiter === 'running') {
|
||||||
|
healthy = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (healthy) {
|
||||||
|
icon.textContent = '✅';
|
||||||
|
text.textContent = 'Deployed!';
|
||||||
|
result.textContent = 'Arbiter restarted successfully';
|
||||||
|
result.style.color = '#22c55e';
|
||||||
|
} else {
|
||||||
|
icon.textContent = '⚠️';
|
||||||
|
text.textContent = 'Check Status';
|
||||||
|
result.textContent = 'Deploy triggered but could not confirm restart.';
|
||||||
|
result.style.color = '#ef4444';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
icon.textContent = '❌';
|
||||||
|
text.textContent = 'Deploy Failed';
|
||||||
|
result.textContent = data.message || 'Unknown error';
|
||||||
|
result.style.display = 'block';
|
||||||
|
result.style.color = '#ef4444';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
icon.textContent = '❌';
|
||||||
|
text.textContent = 'Deploy Failed';
|
||||||
|
result.textContent = error.message;
|
||||||
|
result.style.display = 'block';
|
||||||
|
result.style.color = '#ef4444';
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.disabled = false;
|
||||||
|
icon.textContent = '🚀';
|
||||||
|
text.textContent = 'Deploy Arbiter';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,19 +1,127 @@
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
|
||||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">Active Subscribers</div>
|
<div class="text-sm text-gray-500 dark:text-gray-400">Active Subscribers</div>
|
||||||
<div class="text-3xl font-bold mt-2">0</div>
|
<div class="text-3xl font-bold mt-2"><%= activeSubscribers %></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">Total MRR</div>
|
<div class="text-sm text-gray-500 dark:text-gray-400">Total MRR</div>
|
||||||
<div class="text-3xl font-bold mt-2">$0</div>
|
<div class="text-3xl font-bold mt-2">$<%= totalMRR.toFixed(0) %></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">Servers Online</div>
|
<div class="text-sm text-gray-500 dark:text-gray-400">Servers Online</div>
|
||||||
<div class="text-3xl font-bold mt-2">12</div>
|
<div class="text-3xl font-bold mt-2"><%= serversOnline %></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">Last Sync</div>
|
<div class="text-sm text-gray-500 dark:text-gray-400">Last Sync</div>
|
||||||
<div class="text-3xl font-bold mt-2 text-green-500">✓</div>
|
<% if (lastSyncTime) { %>
|
||||||
|
<div class="text-xl font-bold mt-2 text-green-500">✓</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
<%= new Date(lastSyncTime).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) %>
|
||||||
|
<%= new Date(lastSyncTime).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }) %>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="text-xl font-bold mt-2 text-yellow-500">—</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Never</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Social Overview Card -->
|
||||||
|
<% if (socialTotals && socialTotals.posts > 0) { %>
|
||||||
|
<a href="/admin/social" class="block bg-gradient-to-r from-pink-500/10 via-purple-500/10 to-blue-500/10 rounded-lg border border-purple-500/30 p-6 mb-6 hover:border-purple-400/50 transition group">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-2xl">📊</span>
|
||||||
|
<h3 class="text-lg font-semibold text-purple-400 group-hover:text-purple-300 transition">Social Overview</h3>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-500">Click for details →</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-gray-200"><%= socialTotals.posts %></div>
|
||||||
|
<div class="text-xs text-gray-500">Total Posts</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-cyan-400"><%= socialTotals.views.toLocaleString() %></div>
|
||||||
|
<div class="text-xs text-gray-500">Total Views</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-pink-400"><%= socialTotals.likes.toLocaleString() %></div>
|
||||||
|
<div class="text-xs text-gray-500">Total Likes</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-green-400"><%= socialTotals.comments %></div>
|
||||||
|
<div class="text-xs text-gray-500">Comments</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (Object.keys(socialTotals.platforms).length > 0) { %>
|
||||||
|
<div class="flex gap-4 mt-4 pt-4 border-t border-gray-700/50">
|
||||||
|
<% for (const [platform, stats] of Object.entries(socialTotals.platforms)) { %>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<% if (platform === 'tiktok') { %>
|
||||||
|
<span class="text-pink-400">🎵</span>
|
||||||
|
<% } else if (platform === 'bluesky') { %>
|
||||||
|
<span class="text-blue-400">🦋</span>
|
||||||
|
<% } else if (platform === 'instagram') { %>
|
||||||
|
<span class="text-purple-400">📷</span>
|
||||||
|
<% } else if (platform === 'facebook') { %>
|
||||||
|
<span class="text-blue-600">📘</span>
|
||||||
|
<% } else if (platform === 'x') { %>
|
||||||
|
<span class="text-gray-400">𝕏</span>
|
||||||
|
<% } %>
|
||||||
|
<span class="text-gray-400 capitalize"><%= platform %></span>
|
||||||
|
<span class="text-gray-500">(<%= stats.views.toLocaleString() %> views)</span>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<!-- New Features Card -->
|
||||||
|
<div class="bg-gradient-to-r from-green-500/10 to-emerald-500/10 rounded-lg border border-green-500/30 p-6 mb-6">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<span class="text-green-500 text-xl">✨</span>
|
||||||
|
<h3 class="text-lg font-semibold text-green-400">New Features</h3>
|
||||||
|
<span class="text-xs bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full">Just Added</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<a href="/admin/infrastructure" class="group flex items-start gap-3 p-3 rounded-lg bg-white/5 hover:bg-white/10 transition border border-transparent hover:border-green-500/30">
|
||||||
|
<span class="text-2xl">🌐</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-200 group-hover:text-green-400 transition">Infrastructure</div>
|
||||||
|
<div class="text-sm text-gray-400 mt-1">
|
||||||
|
Interactive topology map of all 8 servers with live data from Trinity Core. Zoom, pan, and click any node to drill into specs, services, and game servers.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/social" class="group flex items-start gap-3 p-3 rounded-lg bg-white/5 hover:bg-white/10 transition border border-transparent hover:border-green-500/30">
|
||||||
|
<span class="text-2xl">📈</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-200 group-hover:text-green-400 transition">Social Analytics</div>
|
||||||
|
<div class="text-sm text-gray-400 mt-1">
|
||||||
|
Track posts, views, likes, and engagement across all platforms. Per-post analytics with platform breakdowns and trend tracking.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/about" class="group flex items-start gap-3 p-3 rounded-lg bg-white/5 hover:bg-white/10 transition border border-transparent hover:border-green-500/30">
|
||||||
|
<span class="text-2xl">ℹ️</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-200 group-hover:text-green-400 transition">About & Deploy</div>
|
||||||
|
<div class="text-sm text-gray-400 mt-1">
|
||||||
|
Console version info, module registry with status badges, and one-click Arbiter deployment — all in one place.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/discord" class="group flex items-start gap-3 p-3 rounded-lg bg-white/5 hover:bg-white/10 transition border border-transparent hover:border-green-500/30">
|
||||||
|
<span class="text-2xl">💬</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-200 group-hover:text-green-400 transition">Discord Dashboard</div>
|
||||||
|
<div class="text-sm text-gray-400 mt-1">
|
||||||
|
Full server structure visualization with channel tree, role hierarchy, permission matrix, and health checks.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -26,3 +134,57 @@
|
|||||||
<strong>Fire + Frost + Foundation = Where Love Builds Legacy</strong>
|
<strong>Fire + Frost + Foundation = Where Love Builds Legacy</strong>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- v2 Teaser -->
|
||||||
|
<div class="bg-gradient-to-r from-fire/10 via-universal/10 to-frost/10 rounded-lg border border-universal/30 p-6 mt-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-3 bg-gradient-to-r from-fire via-universal to-frost text-transparent bg-clip-text">
|
||||||
|
🚀 Coming in v2.0 — Trinity Core
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm mb-4">
|
||||||
|
A complete platform rebuild with plugin architecture and AI-powered operations.
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-universal">🤖</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-700 dark:text-gray-200">Trinity Codex AI</div>
|
||||||
|
<div class="text-gray-500 dark:text-gray-400 text-xs">Ask questions about Firefrost in natural language</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-universal">🔔</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-700 dark:text-gray-200">Smart Notifications</div>
|
||||||
|
<div class="text-gray-500 dark:text-gray-400 text-xs">Real-time alerts via Discord</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-universal">🔐</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-700 dark:text-gray-200">Approval Workflows</div>
|
||||||
|
<div class="text-gray-500 dark:text-gray-400 text-xs">Discord button approvals for sensitive actions</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-universal">🧩</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-700 dark:text-gray-200">Plugin Architecture</div>
|
||||||
|
<div class="text-gray-500 dark:text-gray-400 text-xs">12 self-registering modules: Dashboard, Players, Servers, Infrastructure, Financials, Tasks, Docs, Team, Marketing, Chroniclers, System, Health</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-universal">👥</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-700 dark:text-gray-200">Granular Permissions</div>
|
||||||
|
<div class="text-gray-500 dark:text-gray-400 text-xs">Role-based access control for staff</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-universal">🌐</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-700 dark:text-gray-200">Distributed Mesh</div>
|
||||||
|
<div class="text-gray-500 dark:text-gray-400 text-xs">Manage all servers from anywhere</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
467
services/arbiter-3.0/src/views/admin/discord/index.ejs
Normal file
467
services/arbiter-3.0/src/views/admin/discord/index.ejs
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
<% if (error) { %>
|
||||||
|
<div class="bg-red-500/10 border border-red-500/50 rounded-lg p-6 text-center">
|
||||||
|
<div class="text-4xl mb-2">⚠️</div>
|
||||||
|
<div class="text-red-400 font-medium"><%= error %></div>
|
||||||
|
</div>
|
||||||
|
<% } else if (data) { %>
|
||||||
|
|
||||||
|
<!-- Search & Filter Bar -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div class="flex flex-wrap gap-4 items-center">
|
||||||
|
<div class="flex-1 min-w-64">
|
||||||
|
<input type="text" id="search-input" placeholder="Search channels or roles..."
|
||||||
|
class="w-full px-4 py-2 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-frost">
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="setView('channels')" id="btn-channels" class="px-4 py-2 rounded-lg bg-frost text-white font-medium transition">
|
||||||
|
💬 Channels
|
||||||
|
</button>
|
||||||
|
<button onclick="setView('roles')" id="btn-roles" class="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition">
|
||||||
|
🎭 Roles
|
||||||
|
</button>
|
||||||
|
<button onclick="setView('health')" id="btn-health" class="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition">
|
||||||
|
🩺 Health
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Cards -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-frost"><%= data.summary.totalChannels %></div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Channels</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-fire"><%= data.summary.totalRoles %></div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Roles</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-universal"><%= data.summary.categoryCount %></div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Categories</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold"><%= data.server.memberCount %></div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Members</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||||
|
<% if (data.healthChecks.orphanChannels === 0) { %>
|
||||||
|
<div class="text-2xl font-bold text-green-500">✓</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Healthy</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="text-2xl font-bold text-yellow-500"><%= data.healthChecks.orphanChannels %></div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Orphans</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
<!-- Channels Panel (left 2/3) -->
|
||||||
|
<div id="panel-channels" class="lg:col-span-2">
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||||
|
<img src="<%= data.server.icon %>" class="w-8 h-8 rounded-full" alt="">
|
||||||
|
<h3 class="font-semibold"><%= data.server.name %></h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 max-h-[600px] overflow-y-auto" id="channel-tree">
|
||||||
|
<% data.categories.forEach((cat, catIndex) => { %>
|
||||||
|
<div class="channel-item category-item mb-2" data-name="<%= cat.name.toLowerCase() %>">
|
||||||
|
<div class="flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer" onclick="toggleCategory('<%= cat.id %>')">
|
||||||
|
<span class="text-gray-400 transition-transform" id="arrow-<%= cat.id %>">▶</span>
|
||||||
|
<span class="text-gray-400">📁</span>
|
||||||
|
<span class="font-medium text-sm uppercase text-gray-600 dark:text-gray-300"><%= cat.name %></span>
|
||||||
|
<span class="text-xs text-gray-400 ml-auto"><%= cat.children.length %></span>
|
||||||
|
</div>
|
||||||
|
<div id="cat-<%= cat.id %>" class="hidden ml-6 mt-1 space-y-1">
|
||||||
|
<% cat.children.forEach(ch => { %>
|
||||||
|
<div class="channel-item flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||||
|
data-name="<%= ch.name.toLowerCase() %>"
|
||||||
|
onclick="showChannelDetails('<%= ch.id %>')">
|
||||||
|
<% if (ch.type === 0) { %>
|
||||||
|
<span class="text-gray-400">#</span>
|
||||||
|
<% } else if (ch.type === 2) { %>
|
||||||
|
<span class="text-gray-400">🔊</span>
|
||||||
|
<% } else if (ch.type === 5) { %>
|
||||||
|
<span class="text-gray-400">📢</span>
|
||||||
|
<% } else if (ch.type === 13) { %>
|
||||||
|
<span class="text-gray-400">🎭</span>
|
||||||
|
<% } else if (ch.type === 15) { %>
|
||||||
|
<span class="text-gray-400">💬</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="text-gray-400">📄</span>
|
||||||
|
<% } %>
|
||||||
|
<span class="text-sm"><%= ch.name %></span>
|
||||||
|
<% if (ch.nsfw) { %>
|
||||||
|
<span class="text-xs bg-red-500/20 text-red-400 px-1 rounded">NSFW</span>
|
||||||
|
<% } %>
|
||||||
|
<% if (ch.permissionOverwrites.length > 0) { %>
|
||||||
|
<span class="text-xs text-gray-400 ml-auto">🔒 <%= ch.permissionOverwrites.length %></span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
|
||||||
|
<% if (data.orphanChannels.length > 0) { %>
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="text-xs text-yellow-500 font-medium mb-2">⚠️ Orphan Channels (no category)</div>
|
||||||
|
<% data.orphanChannels.forEach(ch => { %>
|
||||||
|
<div class="channel-item flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||||
|
data-name="<%= ch.name.toLowerCase() %>"
|
||||||
|
onclick="showChannelDetails('<%= ch.id %>')">
|
||||||
|
<span class="text-yellow-500">#</span>
|
||||||
|
<span class="text-sm"><%= ch.name %></span>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Roles Panel (right 1/3) -->
|
||||||
|
<div id="panel-roles" class="hidden lg:block lg:col-span-1">
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="font-semibold">🎭 Role Hierarchy</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 max-h-[600px] overflow-y-auto space-y-1" id="role-list">
|
||||||
|
<% data.roles.forEach(role => { %>
|
||||||
|
<div class="role-item flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||||
|
data-name="<%= role.name.toLowerCase() %>"
|
||||||
|
data-role-id="<%= role.id %>"
|
||||||
|
onclick="showRoleDetails('<%= role.id %>')">
|
||||||
|
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: <%= role.color === '#000000' ? '#6b7280' : role.color %>"></span>
|
||||||
|
<span class="text-sm truncate flex-1"><%= role.name %></span>
|
||||||
|
<% if (role.managed) { %>
|
||||||
|
<span class="text-xs bg-blue-500/20 text-blue-400 px-1 rounded">BOT</span>
|
||||||
|
<% } %>
|
||||||
|
<span class="text-xs text-gray-400"><%= role.memberCount %></span>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Health Panel (hidden by default) -->
|
||||||
|
<div id="panel-health" class="hidden lg:col-span-3">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<!-- Orphan Channels -->
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<h4 class="font-medium mb-3 flex items-center gap-2">
|
||||||
|
<% if (data.healthChecks.orphanChannels === 0) { %>
|
||||||
|
<span class="text-green-500">✓</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="text-yellow-500">⚠️</span>
|
||||||
|
<% } %>
|
||||||
|
Orphan Channels
|
||||||
|
</h4>
|
||||||
|
<div class="text-3xl font-bold mb-2 <%= data.healthChecks.orphanChannels === 0 ? 'text-green-500' : 'text-yellow-500' %>">
|
||||||
|
<%= data.healthChecks.orphanChannels %>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Channels without a parent category
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty Roles -->
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<h4 class="font-medium mb-3 flex items-center gap-2">
|
||||||
|
<% if (data.healthChecks.emptyRoles <= 5) { %>
|
||||||
|
<span class="text-green-500">✓</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="text-yellow-500">⚠️</span>
|
||||||
|
<% } %>
|
||||||
|
Empty Roles
|
||||||
|
</h4>
|
||||||
|
<div class="text-3xl font-bold mb-2 <%= data.healthChecks.emptyRoles <= 5 ? 'text-green-500' : 'text-yellow-500' %>">
|
||||||
|
<%= data.healthChecks.emptyRoles %>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Non-bot roles with no members
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bot Roles -->
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<h4 class="font-medium mb-3 flex items-center gap-2">
|
||||||
|
<span class="text-blue-500">🤖</span>
|
||||||
|
Bot Roles
|
||||||
|
</h4>
|
||||||
|
<div class="text-3xl font-bold mb-2 text-blue-500">
|
||||||
|
<%= data.healthChecks.botRoles %>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Managed by Discord integrations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty Roles List -->
|
||||||
|
<% const emptyRoles = data.roles.filter(r => r.memberCount === 0 && !r.managed && r.name !== '@everyone'); %>
|
||||||
|
<% if (emptyRoles.length > 0) { %>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mt-6">
|
||||||
|
<h4 class="font-medium mb-3">Empty Roles (candidates for cleanup)</h4>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% emptyRoles.forEach(role => { %>
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs"
|
||||||
|
style="background-color: <%= role.color === '#000000' ? '#374151' : role.color %>20; border: 1px solid <%= role.color === '#000000' ? '#374151' : role.color %>">
|
||||||
|
<span class="w-2 h-2 rounded-full" style="background-color: <%= role.color === '#000000' ? '#6b7280' : role.color %>"></span>
|
||||||
|
<%= role.name %>
|
||||||
|
</span>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Details Modal -->
|
||||||
|
<div id="details-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50" onclick="closeModal(event)">
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[80vh] overflow-hidden" onclick="event.stopPropagation()">
|
||||||
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold" id="modal-title">Details</h3>
|
||||||
|
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 overflow-y-auto max-h-[60vh]" id="modal-content">
|
||||||
|
<!-- Populated by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Store data for JS access
|
||||||
|
const discordData = <%- JSON.stringify(data) %>;
|
||||||
|
|
||||||
|
// Role lookup by ID
|
||||||
|
const rolesById = {};
|
||||||
|
discordData.roles.forEach(r => rolesById[r.id] = r);
|
||||||
|
|
||||||
|
// Channel lookup by ID
|
||||||
|
const channelsById = {};
|
||||||
|
discordData.allChannels.forEach(ch => channelsById[ch.id] = ch);
|
||||||
|
|
||||||
|
// View switching
|
||||||
|
function setView(view) {
|
||||||
|
// Update buttons
|
||||||
|
document.getElementById('btn-channels').className = view === 'channels'
|
||||||
|
? 'px-4 py-2 rounded-lg bg-frost text-white font-medium transition'
|
||||||
|
: 'px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition';
|
||||||
|
document.getElementById('btn-roles').className = view === 'roles'
|
||||||
|
? 'px-4 py-2 rounded-lg bg-fire text-white font-medium transition'
|
||||||
|
: 'px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition';
|
||||||
|
document.getElementById('btn-health').className = view === 'health'
|
||||||
|
? 'px-4 py-2 rounded-lg bg-universal text-white font-medium transition'
|
||||||
|
: 'px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition';
|
||||||
|
|
||||||
|
// Show/hide panels
|
||||||
|
document.getElementById('panel-channels').className = view === 'channels' ? 'lg:col-span-2' : 'hidden';
|
||||||
|
document.getElementById('panel-roles').className = view === 'roles' || view === 'channels' ? 'lg:col-span-1' : 'hidden';
|
||||||
|
document.getElementById('panel-health').className = view === 'health' ? 'lg:col-span-3' : 'hidden';
|
||||||
|
|
||||||
|
// Adjust for roles-only view
|
||||||
|
if (view === 'roles') {
|
||||||
|
document.getElementById('panel-roles').className = 'lg:col-span-3';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category toggle
|
||||||
|
function toggleCategory(catId) {
|
||||||
|
const content = document.getElementById('cat-' + catId);
|
||||||
|
const arrow = document.getElementById('arrow-' + catId);
|
||||||
|
if (content.classList.contains('hidden')) {
|
||||||
|
content.classList.remove('hidden');
|
||||||
|
arrow.style.transform = 'rotate(90deg)';
|
||||||
|
} else {
|
||||||
|
content.classList.add('hidden');
|
||||||
|
arrow.style.transform = 'rotate(0deg)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show channel details
|
||||||
|
function showChannelDetails(channelId) {
|
||||||
|
const channel = channelsById[channelId];
|
||||||
|
if (!channel) return;
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">Channel</div>
|
||||||
|
<div class="font-medium"># ${channel.name}</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">Type</div>
|
||||||
|
<div>${channel.typeName}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">ID</div>
|
||||||
|
<div class="text-xs font-mono">${channel.id}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${channel.topic ? `<div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">Topic</div>
|
||||||
|
<div class="text-sm">${channel.topic}</div>
|
||||||
|
</div>` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (channel.permissionOverwrites.length > 0) {
|
||||||
|
html += `<div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 mb-2">Permission Overwrites</div>
|
||||||
|
<div class="space-y-1">`;
|
||||||
|
|
||||||
|
channel.permissionOverwrites.forEach(p => {
|
||||||
|
const role = rolesById[p.id];
|
||||||
|
const name = role ? role.name : `User ${p.id.slice(-4)}`;
|
||||||
|
const color = role ? (role.color === '#000000' ? '#6b7280' : role.color) : '#6b7280';
|
||||||
|
const allow = p.allow !== '0' ? '✓ Allow' : '';
|
||||||
|
const deny = p.deny !== '0' ? '✗ Deny' : '';
|
||||||
|
|
||||||
|
html += `<div class="flex items-center gap-2 text-sm px-2 py-1 rounded bg-gray-100 dark:bg-gray-800">
|
||||||
|
<span class="w-2 h-2 rounded-full" style="background-color: ${color}"></span>
|
||||||
|
<span class="flex-1">${name}</span>
|
||||||
|
${allow ? `<span class="text-green-500 text-xs">${allow}</span>` : ''}
|
||||||
|
${deny ? `<span class="text-red-500 text-xs">${deny}</span>` : ''}
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
document.getElementById('modal-title').textContent = '# ' + channel.name;
|
||||||
|
document.getElementById('modal-content').innerHTML = html;
|
||||||
|
document.getElementById('details-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('details-modal').classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show role details
|
||||||
|
function showRoleDetails(roleId) {
|
||||||
|
const role = rolesById[roleId];
|
||||||
|
if (!role) return;
|
||||||
|
|
||||||
|
// Find channels this role can access
|
||||||
|
const accessibleChannels = discordData.allChannels.filter(ch => {
|
||||||
|
// Check if role has explicit permission
|
||||||
|
const overwrite = ch.permissionOverwrites.find(p => p.id === roleId);
|
||||||
|
if (overwrite) {
|
||||||
|
return overwrite.allow !== '0' || overwrite.deny === '0';
|
||||||
|
}
|
||||||
|
// Check if @everyone is denied (and role doesn't have explicit access)
|
||||||
|
const everyoneOverwrite = ch.permissionOverwrites.find(p => p.id === discordData.server.id);
|
||||||
|
if (everyoneOverwrite && everyoneOverwrite.deny !== '0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const color = role.color === '#000000' ? '#6b7280' : role.color;
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="w-6 h-6 rounded-full" style="background-color: ${color}"></span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">${role.name}</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Position: ${role.position}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">Members</div>
|
||||||
|
<div class="text-xl font-bold">${role.memberCount}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">Type</div>
|
||||||
|
<div>${role.managed ? '🤖 Bot Managed' : '👥 Regular'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">ID</div>
|
||||||
|
<div class="text-xs font-mono">${role.id}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 mb-2">Explicit Channel Access (${accessibleChannels.filter(ch => ch.permissionOverwrites.some(p => p.id === roleId)).length})</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Show channels with explicit overwrites for this role
|
||||||
|
const explicitChannels = discordData.allChannels.filter(ch =>
|
||||||
|
ch.permissionOverwrites.some(p => p.id === roleId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (explicitChannels.length === 0) {
|
||||||
|
html += '<span class="text-gray-500 text-sm">No explicit overwrites</span>';
|
||||||
|
} else {
|
||||||
|
explicitChannels.forEach(ch => {
|
||||||
|
const overwrite = ch.permissionOverwrites.find(p => p.id === roleId);
|
||||||
|
const isAllowed = overwrite && overwrite.allow !== '0';
|
||||||
|
const isDenied = overwrite && overwrite.deny !== '0';
|
||||||
|
|
||||||
|
html += `<span class="text-xs px-2 py-1 rounded ${isAllowed ? 'bg-green-500/20 text-green-400' : isDenied ? 'bg-red-500/20 text-red-400' : 'bg-gray-500/20 text-gray-400'}">
|
||||||
|
# ${ch.name}
|
||||||
|
</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</div></div></div>`;
|
||||||
|
|
||||||
|
document.getElementById('modal-title').innerHTML = `<span class="inline-block w-3 h-3 rounded-full mr-2" style="background-color: ${color}"></span>${role.name}`;
|
||||||
|
document.getElementById('modal-content').innerHTML = html;
|
||||||
|
document.getElementById('details-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('details-modal').classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
function closeModal(event) {
|
||||||
|
if (!event || event.target === document.getElementById('details-modal')) {
|
||||||
|
document.getElementById('details-modal').classList.add('hidden');
|
||||||
|
document.getElementById('details-modal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
document.getElementById('search-input').addEventListener('input', function(e) {
|
||||||
|
const query = e.target.value.toLowerCase();
|
||||||
|
|
||||||
|
// Filter channels
|
||||||
|
document.querySelectorAll('.channel-item').forEach(el => {
|
||||||
|
const name = el.dataset.name || '';
|
||||||
|
el.style.display = name.includes(query) ? '' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter roles
|
||||||
|
document.querySelectorAll('.role-item').forEach(el => {
|
||||||
|
const name = el.dataset.name || '';
|
||||||
|
el.style.display = name.includes(query) ? '' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// If searching, expand all categories
|
||||||
|
if (query) {
|
||||||
|
document.querySelectorAll('[id^="cat-"]').forEach(el => {
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[id^="arrow-"]').forEach(el => {
|
||||||
|
el.style.transform = 'rotate(90deg)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard shortcut to close modal
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expand all categories on load for better UX
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Keep categories collapsed by default - user can expand as needed
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<% } %>
|
||||||
950
services/arbiter-3.0/src/views/admin/infrastructure/index.ejs
Normal file
950
services/arbiter-3.0/src/views/admin/infrastructure/index.ejs
Normal file
@@ -0,0 +1,950 @@
|
|||||||
|
<!-- Infrastructure Module — Trinity Console -->
|
||||||
|
<!-- Chronicler #78 | April 11, 2026 -->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
#infra-module { font-family: 'JetBrains Mono', 'SF Mono', monospace; }
|
||||||
|
#infra-module * { box-sizing: border-box; }
|
||||||
|
|
||||||
|
.infra-card {
|
||||||
|
background: #2d2d2d;
|
||||||
|
border: 1px solid #404040;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra-metric-card {
|
||||||
|
background: #2d2d2d;
|
||||||
|
border: 1px solid #404040;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.infra-metric-card .value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.infra-metric-card .label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 480px;
|
||||||
|
background: radial-gradient(ellipse at 50% 0%, #2d2d2d 0%, #1a1a1a 70%);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #404040;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.topo-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(#40404022 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, #40404022 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.topo-canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.topo-node:hover, .topo-node.selected {
|
||||||
|
transform: scale(1.08);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.topo-node-inner {
|
||||||
|
background: #2d2d2dee;
|
||||||
|
border: 1.5px solid #404040;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 6px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 8px #00000044;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.topo-node:hover .topo-node-inner,
|
||||||
|
.topo-node.selected .topo-node-inner {
|
||||||
|
box-shadow: 0 0 20px var(--node-color-glow);
|
||||||
|
border-color: var(--node-color);
|
||||||
|
}
|
||||||
|
.topo-node.selected .topo-node-inner {
|
||||||
|
background: var(--node-color-bg);
|
||||||
|
}
|
||||||
|
.topo-node-name { font-size: 11px; font-weight: 700; color: #e5e5e5; }
|
||||||
|
.topo-node-ip { font-size: 9px; color: #888; margin-top: 2px; }
|
||||||
|
.topo-node-role { font-size: 8px; font-weight: 600; margin-top: 2px; }
|
||||||
|
.topo-node-games { font-size: 8px; color: #aaa; margin-top: 3px; }
|
||||||
|
|
||||||
|
.topo-ext {
|
||||||
|
position: absolute;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
.topo-ext:hover { transform: scale(1.1); }
|
||||||
|
.topo-ext-icon { font-size: 20px; }
|
||||||
|
.topo-ext-label { font-size: 9px; font-weight: 600; margin-top: 2px; white-space: nowrap; }
|
||||||
|
|
||||||
|
.topo-legend {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888;
|
||||||
|
background: #1a1a1acc;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.status-dot.healthy { background: #22c55e; box-shadow: 0 0 6px #22c55e; }
|
||||||
|
.status-dot.warning { background: #eab308; box-shadow: 0 0 6px #eab308; }
|
||||||
|
.status-dot.critical { background: #ef4444; box-shadow: 0 0 6px #ef4444; }
|
||||||
|
.status-dot.offline { background: #666; box-shadow: 0 0 6px #666; }
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.progress-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-view { animation: fadeSlideIn 0.2s ease; }
|
||||||
|
@keyframes fadeSlideIn {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #4ECDC4;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.back-btn:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.svc-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid #40404044;
|
||||||
|
}
|
||||||
|
.svc-row:last-child { border-bottom: none; }
|
||||||
|
.svc-icon { font-size: 16px; }
|
||||||
|
.svc-name { font-size: 12px; font-weight: 500; color: #ddd; }
|
||||||
|
.svc-domain { font-size: 10px; color: #4ECDC4; }
|
||||||
|
.svc-note { font-size: 10px; color: #888; }
|
||||||
|
.svc-port {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #666;
|
||||||
|
background: #1a1a1a;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.badge-warning { background: #eab30822; color: #eab308; }
|
||||||
|
.badge-critical { background: #ef444422; color: #ef4444; }
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
background: #4ECDC415;
|
||||||
|
border: 1px solid #4ECDC444;
|
||||||
|
color: #4ECDC4;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.refresh-btn:hover { background: #4ECDC425; border-color: #4ECDC4; }
|
||||||
|
.refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.conn-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.topo-canvas-wrap { height: 380px; }
|
||||||
|
.fleet-grid { grid-template-columns: repeat(3, 1fr) !important; }
|
||||||
|
.detail-metrics { grid-template-columns: repeat(2, 1fr) !important; }
|
||||||
|
.detail-panels { grid-template-columns: 1fr !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zoom controls */
|
||||||
|
.zoom-controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.zoom-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: #1a1a1acc;
|
||||||
|
border: 1px solid #404040;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
.zoom-btn:hover { background: #2d2d2d; border-color: #4ECDC4; color: #4ECDC4; }
|
||||||
|
.zoom-btn:active { transform: scale(0.92); }
|
||||||
|
.zoom-level {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: #1a1a1acc;
|
||||||
|
border: 1px solid #404040;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #888;
|
||||||
|
font-size: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-family: inherit;
|
||||||
|
min-width: 44px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.topo-zoomable {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.topo-zoomable.dragging {
|
||||||
|
transition: none;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
.topo-canvas-wrap { cursor: grab; }
|
||||||
|
.topo-canvas-wrap.dragging { cursor: grabbing; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="infra-module">
|
||||||
|
<!-- Title bar -->
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:16px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:10px;">
|
||||||
|
<span style="font-size:24px">🌐</span>
|
||||||
|
<div>
|
||||||
|
<h1 style="font-size:20px; font-weight:700; margin:0; background:linear-gradient(135deg, #FF6B35, #A855F7, #4ECDC4); -webkit-background-clip:text; -webkit-text-fill-color:transparent;">
|
||||||
|
Infrastructure
|
||||||
|
</h1>
|
||||||
|
<div style="font-size:10px; color:#666;">
|
||||||
|
<% if (fleet) { %>
|
||||||
|
Last audit: <%= new Date(fleet.fetchedAt).toLocaleString('en-US', { month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }) %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center;">
|
||||||
|
<button id="topo-btn" class="refresh-btn" onclick="showTopology()" style="display:none;">View Topology</button>
|
||||||
|
<button id="refresh-btn" class="refresh-btn" onclick="refreshAudit()">⟳ Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (!fleet) { %>
|
||||||
|
<div class="infra-card" style="text-align:center; padding:40px;">
|
||||||
|
<div style="font-size:48px; margin-bottom:16px;">🔌</div>
|
||||||
|
<div style="font-size:16px; font-weight:600; color:#e5e5e5;">Unable to reach Trinity Core</div>
|
||||||
|
<div style="font-size:12px; color:#888; margin-top:8px;"><%= error || 'Connection failed' %></div>
|
||||||
|
<button class="refresh-btn" onclick="refreshAudit()" style="margin-top:16px;">Try Again</button>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
|
||||||
|
<!-- Fleet Summary -->
|
||||||
|
<div class="fleet-grid" style="display:grid; grid-template-columns:repeat(6, 1fr); gap:10px; margin-bottom:16px;">
|
||||||
|
<div class="infra-metric-card">
|
||||||
|
<div class="value" style="color:#4ECDC4;"><%= fleet.summary.totalServers %></div>
|
||||||
|
<div class="label">Physical Servers</div>
|
||||||
|
</div>
|
||||||
|
<div class="infra-metric-card">
|
||||||
|
<div class="value" style="color:#FF6B35;"><%= fleet.summary.totalGameServers %></div>
|
||||||
|
<div class="label">Game Servers</div>
|
||||||
|
</div>
|
||||||
|
<div class="infra-metric-card">
|
||||||
|
<div class="value" style="color:#A855F7;">~42</div>
|
||||||
|
<div class="label">Docker Containers</div>
|
||||||
|
</div>
|
||||||
|
<div class="infra-metric-card">
|
||||||
|
<div class="value" style="color:#3b82f6;">~526 GB</div>
|
||||||
|
<div class="label">Fleet RAM</div>
|
||||||
|
</div>
|
||||||
|
<div class="infra-metric-card">
|
||||||
|
<div class="value" style="color:#06b6d4;">~2 TB</div>
|
||||||
|
<div class="label">Fleet Disk</div>
|
||||||
|
</div>
|
||||||
|
<div class="infra-metric-card">
|
||||||
|
<div class="value" style="color:<%= fleet.summary.needRestart > 0 ? '#eab308' : '#22c55e' %>;"><%= fleet.summary.needRestart %>/<%= fleet.summary.totalServers %></div>
|
||||||
|
<div class="label">Need Restart</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Topology View -->
|
||||||
|
<div id="topology-view">
|
||||||
|
<div class="topo-canvas-wrap" id="topo-wrap">
|
||||||
|
<div class="topo-zoomable" id="topo-zoomable">
|
||||||
|
<div class="topo-grid"></div>
|
||||||
|
<canvas id="topo-canvas" class="topo-canvas"></canvas>
|
||||||
|
|
||||||
|
<!-- External service nodes -->
|
||||||
|
<div class="topo-ext" data-node="cloudflare" style="left:calc(50% - 40px); top:14px;" onclick="showExternal('cloudflare')">
|
||||||
|
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #f4812055);">☁️</div>
|
||||||
|
<div class="topo-ext-label" style="color:#f48120;">Cloudflare</div>
|
||||||
|
</div>
|
||||||
|
<div class="topo-ext" data-node="claude" style="left:calc(12.5% - 40px); top:14px;" onclick="showExternal('claude')">
|
||||||
|
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #d4a57455);">🧠</div>
|
||||||
|
<div class="topo-ext-label" style="color:#d4a574;">Claude.ai</div>
|
||||||
|
</div>
|
||||||
|
<div class="topo-ext" data-node="website" style="left:calc(73% - 40px); top:14px;" onclick="showExternal('website')">
|
||||||
|
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #4ECDC455);">🌐</div>
|
||||||
|
<div class="topo-ext-label" style="color:#4ECDC4;">firefrostgaming.com</div>
|
||||||
|
</div>
|
||||||
|
<div class="topo-ext" data-node="stripe" style="left:calc(31.25% - 40px); top:14px;" onclick="showExternal('stripe')">
|
||||||
|
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #635bff55);">💳</div>
|
||||||
|
<div class="topo-ext-label" style="color:#635bff;">Stripe</div>
|
||||||
|
</div>
|
||||||
|
<div class="topo-ext" data-node="discord" style="left:calc(87.5% - 40px); top:14px;" onclick="showExternal('discord')">
|
||||||
|
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #5865f255);">💬</div>
|
||||||
|
<div class="topo-ext-label" style="color:#5865f2;">Discord</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Server nodes -->
|
||||||
|
<% const positions = {
|
||||||
|
'trinity-core': { x: 12.5, y: 33 },
|
||||||
|
'command-center': { x: 43.75, y: 33 },
|
||||||
|
'panel-vps': { x: 73, y: 33 },
|
||||||
|
'tx1-dallas': { x: 60.4, y: 67 },
|
||||||
|
'nc1-charlotte': { x: 85.4, y: 67 },
|
||||||
|
'wiki-vps': { x: 27, y: 67 },
|
||||||
|
'services-vps': { x: 8.3, y: 67 },
|
||||||
|
'dev-panel': { x: 43.75, y: 88 }
|
||||||
|
}; %>
|
||||||
|
|
||||||
|
<% Object.entries(fleet.servers).forEach(([id, server]) => {
|
||||||
|
const pos = positions[id];
|
||||||
|
if (!pos) return;
|
||||||
|
const statusClass = server.status || (server.online ? 'healthy' : 'offline');
|
||||||
|
const nodeGames = id === 'tx1-dallas' ? fleet.summary.tx1Games : (id === 'nc1-charlotte' ? fleet.summary.nc1Games : 0);
|
||||||
|
%>
|
||||||
|
<div class="topo-node" data-node="<%= id %>"
|
||||||
|
style="left:calc(<%= pos.x %>% - 56px); top:calc(<%= pos.y %>% - 28px); width:112px; --node-color:<%= server.color %>; --node-color-glow:<%= server.color %>33; --node-color-bg:<%= server.color %>22;"
|
||||||
|
onclick="showServer('<%= id %>')"
|
||||||
|
onmouseenter="highlightNode('<%= id %>')"
|
||||||
|
onmouseleave="clearHighlight()">
|
||||||
|
<div class="topo-node-inner">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:center; gap:4px; margin-bottom:4px;">
|
||||||
|
<span class="status-dot <%= statusClass %>"></span>
|
||||||
|
<span class="topo-node-name"><%= server.label %></span>
|
||||||
|
</div>
|
||||||
|
<div class="topo-node-ip"><%= id === 'trinity-core' ? 'Home (Tunnel)' : (server.online ? '' : 'OFFLINE') %></div>
|
||||||
|
<div class="topo-node-role" style="color:<%= server.color %>;"><%= server.role %></div>
|
||||||
|
<% if (nodeGames > 0) { %>
|
||||||
|
<div class="topo-node-games">🎮 <%= nodeGames %> servers</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
|
||||||
|
</div> <!-- end topo-zoomable -->
|
||||||
|
|
||||||
|
<!-- Zoom Controls -->
|
||||||
|
<div class="zoom-controls">
|
||||||
|
<button class="zoom-btn" onclick="zoomIn()" title="Zoom In">+</button>
|
||||||
|
<button class="zoom-btn" onclick="zoomOut()" title="Zoom Out">−</button>
|
||||||
|
<div class="zoom-level" id="zoom-level">100%</div>
|
||||||
|
<button class="zoom-btn" onclick="zoomReset()" title="Reset View" style="font-size:12px;">⌂</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topo-legend">
|
||||||
|
<span><span style="color:#f48120">━</span> External</span>
|
||||||
|
<span><span style="color:#4ECDC4">━</span> Internal</span>
|
||||||
|
<span style="color:#A855F7">╌ SSH</span>
|
||||||
|
<span style="color:#d4a574">━ MCP</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail View (hidden initially) -->
|
||||||
|
<div id="detail-view" style="display:none;"></div>
|
||||||
|
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Fleet data from server
|
||||||
|
const fleet = <%- fleet ? JSON.stringify(fleet) : 'null' %>;
|
||||||
|
|
||||||
|
// Service definitions for detail views
|
||||||
|
const serviceMap = {
|
||||||
|
'command-center': [
|
||||||
|
{ name:'Gitea', domain:'git.firefrostgaming.com', port:3000, icon:'📦' },
|
||||||
|
{ name:'Arbiter 3.5', domain:'discord-bot.firefrostgaming.com', port:3500, icon:'🤖' },
|
||||||
|
{ name:'Trinity Console', domain:'discord-bot.firefrostgaming.com/admin', icon:'🎛️' },
|
||||||
|
{ name:'Uptime Kuma', domain:'status.firefrostgaming.com', port:3001, icon:'📡' },
|
||||||
|
{ name:'Vaultwarden', domain:'vault.firefrostgaming.com', icon:'🔐' },
|
||||||
|
{ name:'Code Server', domain:'code.firefrostgaming.com', icon:'💻' },
|
||||||
|
{ name:'PostgreSQL', port:5432, icon:'🗃️' },
|
||||||
|
{ name:'Nginx', port:443, icon:'🌐' }
|
||||||
|
],
|
||||||
|
'tx1-dallas': [
|
||||||
|
{ name:'Wings', icon:'🦅' },
|
||||||
|
{ name:'Nginx', icon:'🌐' },
|
||||||
|
{ name:'Fail2ban', icon:'🛡️' },
|
||||||
|
{ name:'Codex Stack', icon:'🧠', note:'Dify + Qdrant + n8n + Ollama' }
|
||||||
|
],
|
||||||
|
'nc1-charlotte': [
|
||||||
|
{ name:'Wings', icon:'🦅' },
|
||||||
|
{ name:'MariaDB', icon:'🗃️' }
|
||||||
|
],
|
||||||
|
'panel-vps': [
|
||||||
|
{ name:'Pterodactyl Panel', domain:'panel.firefrostgaming.com', icon:'🦕' },
|
||||||
|
{ name:'Nginx', icon:'🌐' },
|
||||||
|
{ name:'MariaDB', icon:'🗃️' },
|
||||||
|
{ name:'Redis', icon:'⚡' },
|
||||||
|
{ name:'PHP-FPM 8.3', icon:'🐘' }
|
||||||
|
],
|
||||||
|
'dev-panel': [
|
||||||
|
{ name:'Pterodactyl Panel', icon:'🦕' },
|
||||||
|
{ name:'Wings', icon:'🦅' },
|
||||||
|
{ name:'Blueprint Beta', icon:'📐' }
|
||||||
|
],
|
||||||
|
'wiki-vps': [
|
||||||
|
{ name:'Staff Wiki', domain:'staff.firefrostgaming.com', icon:'📚' },
|
||||||
|
{ name:'Subscriber Wiki', domain:'subscribers.firefrostgaming.com', icon:'📖' },
|
||||||
|
{ name:'Pokerole Wiki', domain:'pokerole.firefrostgaming.com', icon:'🎲' },
|
||||||
|
{ name:'MkDocs', domain:'docs.firefrostgaming.com', icon:'📄' },
|
||||||
|
{ name:'PostgreSQL 16', icon:'🗃️' }
|
||||||
|
],
|
||||||
|
'services-vps': [
|
||||||
|
{ name:'Mailcow', domain:'mail.firefrostgaming.com', icon:'📧', note:'18 Docker containers' }
|
||||||
|
],
|
||||||
|
'trinity-core': [
|
||||||
|
{ name:'MCP Server v2.1.0', domain:'mcp.firefrostgaming.com', icon:'🔮' },
|
||||||
|
{ name:'Cloudflare Tunnel', icon:'🚇' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const externalInfo = {
|
||||||
|
cloudflare: { label:'Cloudflare', icon:'☁️', color:'#f48120', desc:'DNS, CDN, Pages, Tunnel, Workers', connections:['command-center','panel-vps','wiki-vps','services-vps','trinity-core','website'] },
|
||||||
|
stripe: { label:'Stripe', icon:'💳', color:'#635bff', desc:'Payment processing (LIVE)', connections:['command-center'] },
|
||||||
|
discord: { label:'Discord', icon:'💬', color:'#5865f2', desc:'Community + Bot API', connections:['command-center'] },
|
||||||
|
website: { label:'firefrostgaming.com', icon:'🌐', color:'#4ECDC4', desc:'11ty on Cloudflare Pages', connections:['cloudflare'] },
|
||||||
|
claude: { label:'Claude.ai', icon:'🧠', color:'#d4a574', desc:'MCP Connector → Trinity Core', connections:['trinity-core'] }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connection definitions for canvas drawing
|
||||||
|
const connections = [
|
||||||
|
{ from:'cloudflare', to:'command-center', type:'external' },
|
||||||
|
{ from:'cloudflare', to:'panel-vps', type:'external' },
|
||||||
|
{ from:'cloudflare', to:'wiki-vps', type:'external' },
|
||||||
|
{ from:'cloudflare', to:'services-vps', type:'external' },
|
||||||
|
{ from:'cloudflare', to:'trinity-core', type:'tunnel' },
|
||||||
|
{ from:'cloudflare', to:'website', type:'external' },
|
||||||
|
{ from:'panel-vps', to:'tx1-dallas', type:'internal' },
|
||||||
|
{ from:'panel-vps', to:'nc1-charlotte', type:'internal' },
|
||||||
|
{ from:'command-center', to:'stripe', type:'external' },
|
||||||
|
{ from:'command-center', to:'discord', type:'external' },
|
||||||
|
{ from:'command-center', to:'panel-vps', type:'internal' },
|
||||||
|
{ from:'trinity-core', to:'command-center', type:'ssh' },
|
||||||
|
{ from:'trinity-core', to:'tx1-dallas', type:'ssh' },
|
||||||
|
{ from:'trinity-core', to:'nc1-charlotte', type:'ssh' },
|
||||||
|
{ from:'trinity-core', to:'panel-vps', type:'ssh' },
|
||||||
|
{ from:'trinity-core', to:'dev-panel', type:'ssh' },
|
||||||
|
{ from:'trinity-core', to:'wiki-vps', type:'ssh' },
|
||||||
|
{ from:'trinity-core', to:'services-vps', type:'ssh' },
|
||||||
|
{ from:'claude', to:'trinity-core', type:'mcp' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let hoveredNode = null;
|
||||||
|
|
||||||
|
function getNodePositions() {
|
||||||
|
const zoomable = document.getElementById('topo-zoomable');
|
||||||
|
if (!zoomable) return {};
|
||||||
|
const positions = {};
|
||||||
|
|
||||||
|
// Temporarily remove transform to get unscaled positions
|
||||||
|
const savedTransform = zoomable.style.transform;
|
||||||
|
const savedTransition = zoomable.style.transition;
|
||||||
|
zoomable.style.transition = 'none';
|
||||||
|
zoomable.style.transform = 'none';
|
||||||
|
|
||||||
|
const zoomRect = zoomable.getBoundingClientRect();
|
||||||
|
|
||||||
|
document.querySelectorAll('.topo-node, .topo-ext').forEach(el => {
|
||||||
|
const id = el.dataset.node;
|
||||||
|
if (!id) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
positions[id] = {
|
||||||
|
x: (rect.left - zoomRect.left) + rect.width / 2,
|
||||||
|
y: (rect.top - zoomRect.top) + rect.height / 2
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore transform
|
||||||
|
zoomable.style.transform = savedTransform;
|
||||||
|
zoomable.style.transition = savedTransition;
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawConnections() {
|
||||||
|
const canvas = document.getElementById('topo-canvas');
|
||||||
|
const wrap = document.getElementById('topo-wrap');
|
||||||
|
if (!canvas || !wrap) return;
|
||||||
|
|
||||||
|
canvas.width = wrap.offsetWidth;
|
||||||
|
canvas.height = wrap.offsetHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const positions = getNodePositions();
|
||||||
|
|
||||||
|
const colors = { external:'#f4812055', internal:'#4ECDC455', ssh:'#A855F744', tunnel:'#f4812077', mcp:'#d4a57455' };
|
||||||
|
const hiColors = { external:'#f48120cc', internal:'#4ECDC4cc', ssh:'#A855F7aa', tunnel:'#f48120ee', mcp:'#d4a574cc' };
|
||||||
|
|
||||||
|
connections.forEach(conn => {
|
||||||
|
const from = positions[conn.from];
|
||||||
|
const to = positions[conn.to];
|
||||||
|
if (!from || !to) return;
|
||||||
|
|
||||||
|
const isHi = hoveredNode && (conn.from === hoveredNode || conn.to === hoveredNode);
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(from.x, from.y);
|
||||||
|
const midY = (from.y + to.y) / 2;
|
||||||
|
ctx.bezierCurveTo(from.x, midY, to.x, midY, to.x, to.y);
|
||||||
|
|
||||||
|
ctx.strokeStyle = isHi ? (hiColors[conn.type] || '#fff8') : (colors[conn.type] || '#fff2');
|
||||||
|
ctx.lineWidth = isHi ? 2.5 : 1.2;
|
||||||
|
ctx.setLineDash(conn.type === 'ssh' ? [4, 4] : []);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightNode(id) {
|
||||||
|
hoveredNode = id;
|
||||||
|
drawConnections();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHighlight() {
|
||||||
|
hoveredNode = null;
|
||||||
|
drawConnections();
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressBarColor(pct) {
|
||||||
|
if (pct > 80) return '#ef4444';
|
||||||
|
if (pct > 60) return '#eab308';
|
||||||
|
return '#4ECDC4';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTopology() {
|
||||||
|
document.getElementById('topology-view').style.display = 'block';
|
||||||
|
document.getElementById('detail-view').style.display = 'none';
|
||||||
|
document.getElementById('topo-btn').style.display = 'none';
|
||||||
|
document.querySelectorAll('.topo-node').forEach(n => n.classList.remove('selected'));
|
||||||
|
setTimeout(drawConnections, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showServer(id) {
|
||||||
|
if (dragMoved) return;
|
||||||
|
if (!fleet) return;
|
||||||
|
const server = fleet.servers[id];
|
||||||
|
if (!server) return;
|
||||||
|
|
||||||
|
document.querySelectorAll('.topo-node').forEach(n => n.classList.remove('selected'));
|
||||||
|
document.querySelector(`.topo-node[data-node="${id}"]`)?.classList.add('selected');
|
||||||
|
|
||||||
|
const services = serviceMap[id] || [];
|
||||||
|
const games = fleet.gameServers.filter(g => {
|
||||||
|
if (id === 'tx1-dallas') return g.node === 'TX1';
|
||||||
|
if (id === 'nc1-charlotte') return g.node === 'NC1';
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
const totalGameRam = games.reduce((a, g) => a + g.ram, 0);
|
||||||
|
|
||||||
|
const statusClass = server.status || 'healthy';
|
||||||
|
const badges = [];
|
||||||
|
if (server.restart) badges.push('<span class="badge badge-warning">Restart Required</span>');
|
||||||
|
if (server.diskWarning) badges.push('<span class="badge badge-critical">Disk Warning</span>');
|
||||||
|
|
||||||
|
let html = `<div class="detail-view">
|
||||||
|
<button class="back-btn" onclick="showTopology()">← Back to Topology</button>
|
||||||
|
<div style="display:flex; align-items:center; gap:12px; margin-bottom:20px;">
|
||||||
|
<div style="width:48px; height:48px; border-radius:10px; background:${server.color}22; border:2px solid ${server.color}; display:flex; align-items:center; justify-content:center; font-size:22px;">
|
||||||
|
${id === 'trinity-core' ? '🥧' : '🖥️'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:22px; font-weight:700; color:#f5f5f5; margin:0; display:flex; align-items:center; gap:6px;">
|
||||||
|
${server.label}
|
||||||
|
<span class="status-dot ${statusClass}"></span>
|
||||||
|
${badges.join('')}
|
||||||
|
</h2>
|
||||||
|
<div style="font-size:12px; color:#888;">${server.online ? (id === 'trinity-core' ? 'Home (Cloudflare Tunnel)' : '') : 'OFFLINE'} · ${server.role}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (server.online) {
|
||||||
|
html += `<div class="detail-metrics" style="display:grid; grid-template-columns:repeat(4, 1fr); gap:12px; margin-bottom:20px;">
|
||||||
|
<div class="infra-card">
|
||||||
|
<div style="font-size:10px; color:#888; margin-bottom:4px;">CPU</div>
|
||||||
|
<div style="font-size:13px; font-weight:600; color:#e5e5e5;">${server.cpu || '?'}</div>
|
||||||
|
<div style="font-size:10px; color:#666; margin-top:4px;">Load: ${(server.load || [0,0,0]).join(' / ')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="infra-card">
|
||||||
|
<div style="font-size:10px; color:#888; margin-bottom:4px;">RAM</div>
|
||||||
|
<div style="font-size:13px; font-weight:600; color:#e5e5e5;">${server.ram?.used || '?'} / ${server.ram?.total || '?'}</div>
|
||||||
|
<div class="progress-bar"><div class="progress-bar-fill" style="width:${server.ram?.pct || 0}%; background:${progressBarColor(server.ram?.pct || 0)};"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="infra-card">
|
||||||
|
<div style="font-size:10px; color:#888; margin-bottom:4px;">Disk</div>
|
||||||
|
<div style="font-size:13px; font-weight:600; color:#e5e5e5;">${server.disk?.used || '?'} / ${server.disk?.total || '?'}</div>
|
||||||
|
<div class="progress-bar"><div class="progress-bar-fill" style="width:${server.disk?.pct || 0}%; background:${progressBarColor(server.disk?.pct || 0)};"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="infra-card">
|
||||||
|
<div style="font-size:10px; color:#888; margin-bottom:4px;">Uptime</div>
|
||||||
|
<div style="font-size:13px; font-weight:600; color:#e5e5e5;">${server.uptime || '?'}</div>
|
||||||
|
<div style="font-size:10px; color:#666; margin-top:4px;">${server.os || ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<div class="detail-panels" style="display:grid; grid-template-columns:${games.length > 0 ? '1fr 1fr' : '1fr'}; gap:16px;">`;
|
||||||
|
|
||||||
|
// Services panel
|
||||||
|
html += `<div class="infra-card">
|
||||||
|
<h3 style="font-size:14px; font-weight:600; color:#e5e5e5; margin:0 0 12px 0;">Services (${services.length})</h3>`;
|
||||||
|
services.forEach(svc => {
|
||||||
|
html += `<div class="svc-row">
|
||||||
|
<span class="svc-icon">${svc.icon}</span>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div class="svc-name">${svc.name}</div>
|
||||||
|
${svc.domain ? `<div class="svc-domain">${svc.domain}</div>` : ''}
|
||||||
|
${svc.note ? `<div class="svc-note">${svc.note}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${svc.port ? `<span class="svc-port">:${svc.port}</span>` : ''}
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
// Game servers panel (TX1/NC1 only)
|
||||||
|
if (games.length > 0) {
|
||||||
|
html += `<div class="infra-card">
|
||||||
|
<h3 style="font-size:14px; font-weight:600; color:#e5e5e5; margin:0 0 12px 0;">Game Servers (${games.length})</h3>
|
||||||
|
<div style="max-height:280px; overflow-y:auto;">`;
|
||||||
|
games.forEach(gs => {
|
||||||
|
html += `<div style="display:flex; align-items:center; justify-content:space-between; padding:5px 0; border-bottom:1px solid #40404044;">
|
||||||
|
<div style="display:flex; align-items:center; gap:6px;">
|
||||||
|
<span style="font-size:12px;">🎮</span>
|
||||||
|
<span style="font-size:11px; color:#ddd;">${gs.name}</span>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:9px; color:#888;">${(gs.ram / 1024).toFixed(0)} GB</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>
|
||||||
|
<div style="margin-top:8px; font-size:10px; color:#888; border-top:1px solid #404040; padding-top:8px;">
|
||||||
|
Total RAM: ${(totalGameRam / 1024).toFixed(0)} GB allocated
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</div></div>`;
|
||||||
|
|
||||||
|
document.getElementById('topology-view').style.display = 'none';
|
||||||
|
document.getElementById('detail-view').innerHTML = html;
|
||||||
|
document.getElementById('detail-view').style.display = 'block';
|
||||||
|
document.getElementById('topo-btn').style.display = 'inline-block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showExternal(id) {
|
||||||
|
if (dragMoved) return;
|
||||||
|
const ext = externalInfo[id];
|
||||||
|
if (!ext) return;
|
||||||
|
|
||||||
|
const connTypeColors = { external:'#f48120', internal:'#4ECDC4', ssh:'#A855F7', tunnel:'#f48120', mcp:'#d4a574' };
|
||||||
|
|
||||||
|
let html = `<div class="detail-view">
|
||||||
|
<button class="back-btn" onclick="showTopology()">← Back to Topology</button>
|
||||||
|
<div style="display:flex; align-items:center; gap:12px; margin-bottom:20px;">
|
||||||
|
<div style="width:48px; height:48px; border-radius:10px; background:${ext.color}22; border:2px solid ${ext.color}; display:flex; align-items:center; justify-content:center; font-size:22px;">
|
||||||
|
${ext.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:22px; font-weight:700; color:#f5f5f5; margin:0;">${ext.label}</h2>
|
||||||
|
<div style="font-size:12px; color:#888;">${ext.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="infra-card">
|
||||||
|
<h3 style="font-size:14px; font-weight:600; color:#e5e5e5; margin:0 0 12px 0;">Connected Infrastructure</h3>`;
|
||||||
|
|
||||||
|
ext.connections.forEach(targetId => {
|
||||||
|
const target = fleet?.servers[targetId] || externalInfo[targetId];
|
||||||
|
if (!target) return;
|
||||||
|
const conn = connections.find(c =>
|
||||||
|
(c.from === id && c.to === targetId) || (c.from === targetId && c.to === id)
|
||||||
|
);
|
||||||
|
const connType = conn?.type || 'external';
|
||||||
|
const color = connTypeColors[connType] || '#888';
|
||||||
|
|
||||||
|
html += `<div style="display:flex; align-items:center; gap:8px; padding:8px 0; border-bottom:1px solid #40404044; cursor:pointer;" onclick="${fleet?.servers[targetId] ? `showServer('${targetId}')` : ''}">
|
||||||
|
<span class="conn-dot" style="background:${target.color || color};"></span>
|
||||||
|
<span style="font-size:12px; color:#ddd; flex:1;">${target.label}</span>
|
||||||
|
<span style="font-size:10px; color:${color}; background:${color}15; padding:2px 8px; border-radius:10px;">${conn?.type || 'connected'}</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `</div></div>`;
|
||||||
|
|
||||||
|
document.getElementById('topology-view').style.display = 'none';
|
||||||
|
document.getElementById('detail-view').innerHTML = html;
|
||||||
|
document.getElementById('detail-view').style.display = 'block';
|
||||||
|
document.getElementById('topo-btn').style.display = 'inline-block';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAudit() {
|
||||||
|
const btn = document.getElementById('refresh-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '⟳ Refreshing...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/admin/infrastructure/refresh', {
|
||||||
|
headers: { 'CSRF-Token': '<%= typeof csrfToken !== "undefined" ? csrfToken : "" %>' }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
btn.textContent = '⟳ Failed';
|
||||||
|
setTimeout(() => { btn.textContent = '⟳ Refresh'; btn.disabled = false; }, 2000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
btn.textContent = '⟳ Error';
|
||||||
|
setTimeout(() => { btn.textContent = '⟳ Refresh'; btn.disabled = false; }, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Zoom / Pan / Pinch System ───
|
||||||
|
|
||||||
|
let zoomScale = 1;
|
||||||
|
let panX = 0, panY = 0;
|
||||||
|
let isDragging = false;
|
||||||
|
let dragStartX = 0, dragStartY = 0;
|
||||||
|
let dragStartPanX = 0, dragStartPanY = 0;
|
||||||
|
let dragMoved = false;
|
||||||
|
|
||||||
|
const MIN_ZOOM = 0.5;
|
||||||
|
const MAX_ZOOM = 3.0;
|
||||||
|
const ZOOM_STEP = 0.15;
|
||||||
|
|
||||||
|
function applyTransform() {
|
||||||
|
const el = document.getElementById('topo-zoomable');
|
||||||
|
if (!el) return;
|
||||||
|
el.style.transform = `translate(${panX}px, ${panY}px) scale(${zoomScale})`;
|
||||||
|
document.getElementById('zoom-level').textContent = Math.round(zoomScale * 100) + '%';
|
||||||
|
// Redraw canvas connections at new scale
|
||||||
|
setTimeout(drawConnections, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
zoomScale = Math.min(MAX_ZOOM, zoomScale + ZOOM_STEP);
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
zoomScale = Math.max(MIN_ZOOM, zoomScale - ZOOM_STEP);
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomReset() {
|
||||||
|
zoomScale = 1;
|
||||||
|
panX = 0;
|
||||||
|
panY = 0;
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomAtPoint(delta, clientX, clientY) {
|
||||||
|
const wrap = document.getElementById('topo-wrap');
|
||||||
|
if (!wrap) return;
|
||||||
|
const rect = wrap.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Mouse position relative to wrap
|
||||||
|
const mx = clientX - rect.left;
|
||||||
|
const my = clientY - rect.top;
|
||||||
|
|
||||||
|
// Point in content space before zoom
|
||||||
|
const contentX = (mx - panX) / zoomScale;
|
||||||
|
const contentY = (my - panY) / zoomScale;
|
||||||
|
|
||||||
|
// Apply zoom
|
||||||
|
const oldScale = zoomScale;
|
||||||
|
zoomScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoomScale + delta));
|
||||||
|
|
||||||
|
// Adjust pan so the point under the mouse stays put
|
||||||
|
panX = mx - contentX * zoomScale;
|
||||||
|
panY = my - contentY * zoomScale;
|
||||||
|
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mouse wheel zoom
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const wrap = document.getElementById('topo-wrap');
|
||||||
|
if (!wrap) return;
|
||||||
|
|
||||||
|
wrap.addEventListener('wheel', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
||||||
|
zoomAtPoint(delta, e.clientX, e.clientY);
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Mouse drag to pan
|
||||||
|
wrap.addEventListener('mousedown', (e) => {
|
||||||
|
if (e.target.closest('.topo-node, .topo-ext, .zoom-btn, .zoom-level')) return;
|
||||||
|
isDragging = true;
|
||||||
|
dragMoved = false;
|
||||||
|
dragStartX = e.clientX;
|
||||||
|
dragStartY = e.clientY;
|
||||||
|
dragStartPanX = panX;
|
||||||
|
dragStartPanY = panY;
|
||||||
|
wrap.classList.add('dragging');
|
||||||
|
document.getElementById('topo-zoomable')?.classList.add('dragging');
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const dx = e.clientX - dragStartX;
|
||||||
|
const dy = e.clientY - dragStartY;
|
||||||
|
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragMoved = true;
|
||||||
|
panX = dragStartPanX + dx;
|
||||||
|
panY = dragStartPanY + dy;
|
||||||
|
applyTransform();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', () => {
|
||||||
|
if (isDragging) {
|
||||||
|
isDragging = false;
|
||||||
|
wrap.classList.remove('dragging');
|
||||||
|
document.getElementById('topo-zoomable')?.classList.remove('dragging');
|
||||||
|
// Reset dragMoved after a tick so the click event fires first
|
||||||
|
setTimeout(() => { dragMoved = false; }, 10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Touch: pinch zoom + drag pan
|
||||||
|
let lastTouchDist = 0;
|
||||||
|
let lastTouchCenter = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
wrap.addEventListener('touchstart', (e) => {
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
e.preventDefault();
|
||||||
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||||
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||||
|
lastTouchDist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
lastTouchCenter = {
|
||||||
|
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||||||
|
y: (e.touches[0].clientY + e.touches[1].clientY) / 2
|
||||||
|
};
|
||||||
|
} else if (e.touches.length === 1) {
|
||||||
|
if (e.target.closest('.topo-node, .topo-ext, .zoom-btn')) return;
|
||||||
|
isDragging = true;
|
||||||
|
dragMoved = false;
|
||||||
|
dragStartX = e.touches[0].clientX;
|
||||||
|
dragStartY = e.touches[0].clientY;
|
||||||
|
dragStartPanX = panX;
|
||||||
|
dragStartPanY = panY;
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
wrap.addEventListener('touchmove', (e) => {
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
e.preventDefault();
|
||||||
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||||
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const center = {
|
||||||
|
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||||||
|
y: (e.touches[0].clientY + e.touches[1].clientY) / 2
|
||||||
|
};
|
||||||
|
|
||||||
|
if (lastTouchDist > 0) {
|
||||||
|
const scaleDelta = (dist - lastTouchDist) * 0.005;
|
||||||
|
zoomAtPoint(scaleDelta, center.x, center.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTouchDist = dist;
|
||||||
|
lastTouchCenter = center;
|
||||||
|
} else if (e.touches.length === 1 && isDragging) {
|
||||||
|
const dx = e.touches[0].clientX - dragStartX;
|
||||||
|
const dy = e.touches[0].clientY - dragStartY;
|
||||||
|
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragMoved = true;
|
||||||
|
panX = dragStartPanX + dx;
|
||||||
|
panY = dragStartPanY + dy;
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
wrap.addEventListener('touchend', () => {
|
||||||
|
isDragging = false;
|
||||||
|
lastTouchDist = 0;
|
||||||
|
setTimeout(() => { dragMoved = false; }, 10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw connections on load and resize
|
||||||
|
window.addEventListener('load', () => setTimeout(drawConnections, 100));
|
||||||
|
window.addEventListener('resize', drawConnections);
|
||||||
|
</script>
|
||||||
161
services/arbiter-3.0/src/views/admin/mcp-logs/index.ejs
Normal file
161
services/arbiter-3.0/src/views/admin/mcp-logs/index.ejs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<!-- MCP Logs Module — Trinity Console -->
|
||||||
|
<!-- Chronicler #78 | April 11, 2026 -->
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">Total Commands</div>
|
||||||
|
<div class="text-2xl font-bold mt-1"><%= stats.total %></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">Successful</div>
|
||||||
|
<div class="text-2xl font-bold mt-1 text-green-500"><%= stats.success_count %></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">Failed</div>
|
||||||
|
<div class="text-2xl font-bold mt-1 text-red-500"><%= stats.fail_count %></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">Avg Execution</div>
|
||||||
|
<div class="text-2xl font-bold mt-1 text-frost"><%= stats.avg_time %>ms</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-6">
|
||||||
|
<form method="GET" class="flex flex-wrap gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Server</label>
|
||||||
|
<select name="server" class="bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm">
|
||||||
|
<option value="">All Servers</option>
|
||||||
|
<% servers.forEach(s => { %>
|
||||||
|
<option value="<%= s %>" <%= query.server === s ? 'selected' : '' %>><%= s %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Status</label>
|
||||||
|
<select name="success" class="bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="true" <%= query.success === 'true' ? 'selected' : '' %>>Success</option>
|
||||||
|
<option value="false" <%= query.success === 'false' ? 'selected' : '' %>>Failed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="bg-frost text-white px-4 py-2 rounded-md text-sm hover:opacity-90 transition">Filter</button>
|
||||||
|
<a href="/admin/mcp-logs" class="bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-4 py-2 rounded-md text-sm hover:opacity-90 transition">Reset</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logs Table -->
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200 dark:border-gray-700 text-left text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
<th class="px-4 py-3">Time</th>
|
||||||
|
<th class="px-4 py-3">Server</th>
|
||||||
|
<th class="px-4 py-3">Command</th>
|
||||||
|
<th class="px-4 py-3">Status</th>
|
||||||
|
<th class="px-4 py-3">Duration</th>
|
||||||
|
<th class="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<% if (logs.length === 0) { %>
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<% if (stats.total == 0) { %>
|
||||||
|
No commands logged yet. Commands executed via Trinity Core will appear here.
|
||||||
|
<% } else { %>
|
||||||
|
No logs match your filters.
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% } %>
|
||||||
|
<% logs.forEach(log => {
|
||||||
|
const time = new Date(log.executed_at);
|
||||||
|
const timeStr = time.toLocaleString('en-US', { month:'short', day:'numeric', hour:'numeric', minute:'2-digit', second:'2-digit', hour12:true });
|
||||||
|
const cmdShort = log.command.length > 60 ? log.command.substring(0, 60) + '...' : log.command;
|
||||||
|
%>
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition cursor-pointer" onclick="toggleDetail('<%= log.id %>')">
|
||||||
|
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"><%= timeStr %></td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="inline-block px-2 py-0.5 text-xs rounded-full
|
||||||
|
<% if (log.server === 'tx1-dallas') { %>bg-fire/20 text-fire
|
||||||
|
<% } else if (log.server === 'nc1-charlotte') { %>bg-frost/20 text-frost
|
||||||
|
<% } else if (log.server === 'command-center' || log.server === 'trinity-core') { %>bg-universal/20 text-universal
|
||||||
|
<% } else { %>bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300
|
||||||
|
<% } %>
|
||||||
|
"><%= log.server %></span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3"><code class="text-xs text-gray-300"><%= cmdShort %></code></td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<% if (log.success) { %>
|
||||||
|
<span class="inline-block px-2 py-0.5 text-xs rounded-full bg-green-500/20 text-green-500">✓ OK</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="inline-block px-2 py-0.5 text-xs rounded-full bg-red-500/20 text-red-500">✗ Fail</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400"><%= log.execution_time_ms || '—' %>ms</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-gray-400">▼</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Expandable detail row -->
|
||||||
|
<tr id="detail-<%= log.id %>" style="display:none;" class="bg-gray-50 dark:bg-gray-800/30">
|
||||||
|
<td colspan="6" class="px-4 py-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1 font-semibold">Full Command</div>
|
||||||
|
<pre class="bg-gray-900 text-green-400 text-xs p-3 rounded-md overflow-x-auto max-h-32"><%= log.command %></pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1 font-semibold">Output</div>
|
||||||
|
<pre class="bg-gray-900 text-gray-300 text-xs p-3 rounded-md overflow-x-auto max-h-32"><%= log.stdout || '(no output)' %></pre>
|
||||||
|
</div>
|
||||||
|
<% if (log.stderr) { %>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-yellow-500 mb-1 font-semibold">STDERR</div>
|
||||||
|
<pre class="bg-yellow-900/20 text-yellow-300 text-xs p-3 rounded-md overflow-x-auto max-h-32"><%= log.stderr %></pre>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% if (log.error) { %>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-red-500 mb-1 font-semibold">Error</div>
|
||||||
|
<pre class="bg-red-900/20 text-red-300 text-xs p-3 rounded-md overflow-x-auto max-h-32"><%= log.error %></pre>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<% if (total > limit) { %>
|
||||||
|
<div class="flex justify-center mt-6 gap-2">
|
||||||
|
<% const totalPages = Math.ceil(total / limit); %>
|
||||||
|
<% const currentPage = Math.floor(offset / limit) + 1; %>
|
||||||
|
<% for (let i = 1; i <= totalPages && i <= 10; i++) {
|
||||||
|
const pageOffset = (i - 1) * limit;
|
||||||
|
const params = new URLSearchParams({...query, offset: pageOffset});
|
||||||
|
params.delete('offset');
|
||||||
|
if (pageOffset > 0) params.set('offset', pageOffset);
|
||||||
|
%>
|
||||||
|
<a href="/admin/mcp-logs?<%= params.toString() %>"
|
||||||
|
class="px-3 py-1 rounded-md text-sm <%= currentPage === i ? 'bg-frost text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600' %> transition">
|
||||||
|
<%= i %>
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleDetail(id) {
|
||||||
|
const row = document.getElementById('detail-' + id);
|
||||||
|
if (row) {
|
||||||
|
row.style.display = row.style.display === 'none' ? '' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
hx-target="#player-table-body">
|
hx-target="#player-table-body">
|
||||||
|
|
||||||
<div class="space-x-2">
|
<div class="space-x-2">
|
||||||
|
<a href="/admin/players/export" class="bg-gray-100 dark:bg-gray-700 px-4 py-2 rounded-md text-sm hover:bg-gray-200 dark:hover:bg-gray-600 inline-block">📤 Export CSV</a>
|
||||||
<button class="bg-gray-100 dark:bg-gray-700 px-4 py-2 rounded-md text-sm hover:bg-gray-200 dark:hover:bg-gray-600">📥 Import CSV</button>
|
<button class="bg-gray-100 dark:bg-gray-700 px-4 py-2 rounded-md text-sm hover:bg-gray-200 dark:hover:bg-gray-600">📥 Import CSV</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
150
services/arbiter-3.0/src/views/admin/scheduler.ejs
Normal file
150
services/arbiter-3.0/src/views/admin/scheduler.ejs
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mt-1">Manage staggered restart times for all servers</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button hx-post="/admin/scheduler/import-servers"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.ajax('GET', '/admin/scheduler/table-only', '#scheduler-table')"
|
||||||
|
class="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded transition">
|
||||||
|
↻ Import Servers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Node Configuration Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<% configs.forEach(config => { %>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-bold <%= config.node === 'TX1' ? 'text-fire' : 'text-frost' %>">
|
||||||
|
<%= config.node %> Node
|
||||||
|
</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button hx-get="/admin/scheduler/audit/<%= config.node %>"
|
||||||
|
hx-target="#modal-container"
|
||||||
|
class="bg-yellow-600 hover:bg-yellow-500 text-white px-3 py-1 rounded text-sm transition">
|
||||||
|
Audit
|
||||||
|
</button>
|
||||||
|
<button hx-post="/admin/scheduler/sync/<%= config.node %>"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.ajax('GET', '/admin/scheduler/table-only', '#scheduler-table')"
|
||||||
|
class="bg-green-600 hover:bg-green-500 text-white px-3 py-1 rounded text-sm transition">
|
||||||
|
Sync All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form action="/admin/scheduler/update-config" method="POST" class="flex gap-4 items-end">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<input type="hidden" name="node" value="<%= config.node %>">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">Base Time (Central)</label>
|
||||||
|
<input type="time" name="base_time" value="<%= config.base_time.substring(0,5) %>"
|
||||||
|
class="w-full bg-gray-100 dark:bg-darkbg border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-gray-900 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">Interval</label>
|
||||||
|
<input type="number" name="interval_minutes" value="<%= config.interval_minutes %>" min="1" max="30"
|
||||||
|
class="w-full bg-gray-100 dark:bg-darkbg border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-gray-900 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded transition">
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
|
Last updated: <%= config.updated_at ? new Date(config.updated_at).toLocaleString() : 'Never' %>
|
||||||
|
by <%= config.updated_by || 'Unknown' %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Server Table -->
|
||||||
|
<div id="scheduler-table" class="bg-white dark:bg-darkcard rounded overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||||
|
<table class="w-full text-left">
|
||||||
|
<thead class="bg-gray-100 dark:bg-darkbg text-gray-600 dark:text-gray-300">
|
||||||
|
<tr>
|
||||||
|
<th class="p-3 w-10"></th>
|
||||||
|
<th class="p-3">Server</th>
|
||||||
|
<th class="p-3">Node</th>
|
||||||
|
<th class="p-3">Restart Time (Central)</th>
|
||||||
|
<th class="p-3">Status</th>
|
||||||
|
<th class="p-3">Skip</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sortable-servers">
|
||||||
|
<% if (servers.length === 0) { %>
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="p-6 text-center text-gray-500">
|
||||||
|
No servers imported yet. Click "Import Servers" to populate from Pterodactyl.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% } else { %>
|
||||||
|
<% servers.forEach((server, i) => { %>
|
||||||
|
<tr class="border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition" data-id="<%= server.server_id %>">
|
||||||
|
<td class="p-3 cursor-grab text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
<span class="drag-handle text-lg">☰</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 font-medium"><%= server.server_name %></td>
|
||||||
|
<td class="p-3">
|
||||||
|
<span class="px-2 py-1 rounded text-xs font-bold <%= server.node === 'TX1' ? 'bg-fire/20 text-fire' : 'bg-frost/20 text-frost' %>">
|
||||||
|
<%= server.node %>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 font-mono text-sm"><%= server.effective_time || 'Not set' %></td>
|
||||||
|
<td class="p-3 text-sm">
|
||||||
|
<% if (server.sync_status === 'SUCCESS') { %>
|
||||||
|
<span class="text-green-500" title="Last synced: <%= server.last_synced_at %>">● Synced</span>
|
||||||
|
<% } else if (server.sync_status === 'FAILED') { %>
|
||||||
|
<span class="text-red-500" title="<%= server.last_error %>">✕ Error</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="text-yellow-500">○ Pending</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<button hx-post="/admin/scheduler/toggle-skip/<%= server.server_id %>"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.ajax('GET', '/admin/scheduler/table-only', '#scheduler-table')"
|
||||||
|
class="px-2 py-1 rounded text-xs <%= server.skip_restart ? 'bg-red-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300' %>">
|
||||||
|
<%= server.skip_restart ? 'Skipped' : 'Active' %>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
<% } %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Container -->
|
||||||
|
<div id="modal-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SortableJS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
const tbody = document.getElementById('sortable-servers');
|
||||||
|
|
||||||
|
if(tbody) {
|
||||||
|
new Sortable(tbody, {
|
||||||
|
handle: '.drag-handle',
|
||||||
|
animation: 150,
|
||||||
|
ghostClass: 'bg-gray-600',
|
||||||
|
|
||||||
|
onEnd: function (evt) {
|
||||||
|
const newOrder = Array.from(tbody.querySelectorAll('tr')).map(row => row.dataset.id);
|
||||||
|
|
||||||
|
fetch('/admin/scheduler/reorder-servers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ orderedIds: newOrder })
|
||||||
|
})
|
||||||
|
.then(() => htmx.ajax('GET', '/admin/scheduler/table-only', '#scheduler-table'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<div id="audit-modal" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-70 backdrop-blur-sm">
|
||||||
|
<div class="bg-white dark:bg-darkcard border <%= totalRogue > 0 ? 'border-red-500' : 'border-green-500' %> rounded-lg shadow-2xl w-full max-w-2xl p-6 relative">
|
||||||
|
|
||||||
|
<% if (totalRogue > 0) { %>
|
||||||
|
<h2 class="text-2xl font-bold text-red-500 mb-2">⚠ Conflicts Detected</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-300 mb-4">
|
||||||
|
Found <strong class="text-gray-900 dark:text-white"><%= totalRogue %></strong> rogue restart schedule(s) across
|
||||||
|
<strong class="text-gray-900 dark:text-white"><%= serverCount %></strong> server(s) on <%= node %>.
|
||||||
|
These must be removed before Trinity can take control.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-gray-100 dark:bg-darkbg rounded p-4 mb-6 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700">
|
||||||
|
<ul class="space-y-3">
|
||||||
|
<% results.forEach(result => { %>
|
||||||
|
<li class="border-b border-gray-200 dark:border-gray-700 pb-2 last:border-0">
|
||||||
|
<span class="text-fire font-semibold"><%= result.serverName %></span>
|
||||||
|
<ul class="ml-4 mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<% result.rogueSchedules.forEach(sched => { %>
|
||||||
|
<li>- "<%= sched.name %>" (Cron: <%= sched.cron %>)</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%
|
||||||
|
const nukePayload = [];
|
||||||
|
results.forEach(r => r.rogueSchedules.forEach(s => nukePayload.push({
|
||||||
|
serverId: r.serverId, scheduleId: s.id, scheduleName: s.name
|
||||||
|
})));
|
||||||
|
%>
|
||||||
|
|
||||||
|
<form hx-post="/admin/scheduler/audit/nuke/<%= node %>" hx-target="#audit-modal" hx-swap="outerHTML">
|
||||||
|
<input type="hidden" name="nukeData" value='<%- JSON.stringify(nukePayload) %>'>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-4 mt-6">
|
||||||
|
<button type="button" onclick="document.getElementById('audit-modal').remove()"
|
||||||
|
class="px-4 py-2 text-gray-500 hover:text-gray-900 dark:hover:text-white transition">Cancel</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="bg-red-600 hover:bg-red-500 text-white px-6 py-2 rounded font-bold transition">
|
||||||
|
🔥 Nuke <%= totalRogue %> Schedules
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<% } else { %>
|
||||||
|
<h2 class="text-2xl font-bold text-green-500 mb-2">✓ All Clear</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-300 mb-6">No conflicts found on <%= node %>. Trinity is ready to take control.</p>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="button" onclick="document.getElementById('audit-modal').remove()"
|
||||||
|
class="bg-green-600 hover:bg-green-500 text-white px-6 py-2 rounded transition">Close</button>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
53
services/arbiter-3.0/src/views/admin/scheduler/table.ejs
Normal file
53
services/arbiter-3.0/src/views/admin/scheduler/table.ejs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<table class="w-full text-left">
|
||||||
|
<thead class="bg-gray-100 dark:bg-darkbg text-gray-600 dark:text-gray-300">
|
||||||
|
<tr>
|
||||||
|
<th class="p-3 w-10"></th>
|
||||||
|
<th class="p-3">Server</th>
|
||||||
|
<th class="p-3">Node</th>
|
||||||
|
<th class="p-3">Restart Time (UTC)</th>
|
||||||
|
<th class="p-3">Status</th>
|
||||||
|
<th class="p-3">Skip</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sortable-servers">
|
||||||
|
<% if (servers.length === 0) { %>
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="p-6 text-center text-gray-500">
|
||||||
|
No servers imported yet. Click "Import Servers" to populate from Pterodactyl.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% } else { %>
|
||||||
|
<% servers.forEach((server, i) => { %>
|
||||||
|
<tr class="border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition" data-id="<%= server.server_id %>">
|
||||||
|
<td class="p-3 cursor-grab text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
<span class="drag-handle text-lg">☰</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 font-medium"><%= server.server_name %></td>
|
||||||
|
<td class="p-3">
|
||||||
|
<span class="px-2 py-1 rounded text-xs font-bold <%= server.node === 'TX1' ? 'bg-fire/20 text-fire' : 'bg-frost/20 text-frost' %>">
|
||||||
|
<%= server.node %>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 font-mono text-sm"><%= server.effective_time || 'Not set' %></td>
|
||||||
|
<td class="p-3 text-sm">
|
||||||
|
<% if (server.sync_status === 'SUCCESS') { %>
|
||||||
|
<span class="text-green-500" title="Last synced: <%= server.last_synced_at %>">● Synced</span>
|
||||||
|
<% } else if (server.sync_status === 'FAILED') { %>
|
||||||
|
<span class="text-red-500" title="<%= server.last_error %>">✕ Error</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="text-yellow-500">○ Pending</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<button hx-post="/admin/scheduler/toggle-skip/<%= server.server_id %>"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.ajax('GET', '/admin/scheduler/table-only', '#scheduler-table')"
|
||||||
|
class="px-2 py-1 rounded text-xs <%= server.skip_restart ? 'bg-red-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300' %>">
|
||||||
|
<%= server.skip_restart ? 'Skipped' : 'Active' %>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
<% } %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
<% txServers.forEach(server => {
|
<% txServers.forEach(server => {
|
||||||
const isOnline = server.log.is_online;
|
const isOnline = server.log.is_online;
|
||||||
const hasError = !!server.log.last_error;
|
const hasError = !!server.log.last_error;
|
||||||
|
const discordComplete = server.discord?.complete;
|
||||||
let borderClass = 'border-gray-200 dark:border-gray-700';
|
let borderClass = 'border-gray-200 dark:border-gray-700';
|
||||||
if (isOnline && !hasError) borderClass = 'border-green-500 shadow-[0_0_10px_rgba(34,197,94,0.2)]';
|
if (isOnline && !hasError) borderClass = 'border-green-500 shadow-[0_0_10px_rgba(34,197,94,0.2)]';
|
||||||
if (hasError) borderClass = 'border-red-500 shadow-[0_0_10px_rgba(239,68,68,0.2)]';
|
if (hasError) borderClass = 'border-red-500 shadow-[0_0_10px_rgba(239,68,68,0.2)]';
|
||||||
@@ -38,6 +39,19 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Discord Channel Status -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 block text-xs mb-1">Discord Channels</span>
|
||||||
|
<% if (discordComplete) { %>
|
||||||
|
<span class="text-green-600 dark:text-green-400 text-sm font-medium">✅ All 4 channels</span>
|
||||||
|
<% } else if (server.discord?.missing?.length > 0) { %>
|
||||||
|
<div class="bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400 p-2 rounded text-xs">
|
||||||
|
<strong>Missing:</strong> <%= server.discord.missing.join(', ') %>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 text-sm">Unable to check</span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
<% if (hasError) { %>
|
<% if (hasError) { %>
|
||||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-2 rounded text-xs mb-4 break-words">
|
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-2 rounded text-xs mb-4 break-words">
|
||||||
<strong>Error:</strong> <%= server.log.last_error %>
|
<strong>Error:</strong> <%= server.log.last_error %>
|
||||||
@@ -64,6 +78,7 @@
|
|||||||
<% ncServers.forEach(server => {
|
<% ncServers.forEach(server => {
|
||||||
const isOnline = server.log.is_online;
|
const isOnline = server.log.is_online;
|
||||||
const hasError = !!server.log.last_error;
|
const hasError = !!server.log.last_error;
|
||||||
|
const discordComplete = server.discord?.complete;
|
||||||
let borderClass = 'border-gray-200 dark:border-gray-700';
|
let borderClass = 'border-gray-200 dark:border-gray-700';
|
||||||
if (isOnline && !hasError) borderClass = 'border-green-500 shadow-[0_0_10px_rgba(34,197,94,0.2)]';
|
if (isOnline && !hasError) borderClass = 'border-green-500 shadow-[0_0_10px_rgba(34,197,94,0.2)]';
|
||||||
if (hasError) borderClass = 'border-red-500 shadow-[0_0_10px_rgba(239,68,68,0.2)]';
|
if (hasError) borderClass = 'border-red-500 shadow-[0_0_10px_rgba(239,68,68,0.2)]';
|
||||||
@@ -95,6 +110,19 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Discord Channel Status -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 block text-xs mb-1">Discord Channels</span>
|
||||||
|
<% if (discordComplete) { %>
|
||||||
|
<span class="text-green-600 dark:text-green-400 text-sm font-medium">✅ All 4 channels</span>
|
||||||
|
<% } else if (server.discord?.missing?.length > 0) { %>
|
||||||
|
<div class="bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400 p-2 rounded text-xs">
|
||||||
|
<strong>Missing:</strong> <%= server.discord.missing.join(', ') %>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 text-sm">Unable to check</span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
<% if (hasError) { %>
|
<% if (hasError) { %>
|
||||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-2 rounded text-xs mb-4 break-words">
|
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-2 rounded text-xs mb-4 break-words">
|
||||||
<strong>Error:</strong> <%= server.log.last_error %>
|
<strong>Error:</strong> <%= server.log.last_error %>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<%
|
<%
|
||||||
const isOnline = server.log.is_online;
|
const isOnline = server.log.is_online;
|
||||||
const hasError = !!server.log.last_error;
|
const hasError = !!server.log.last_error;
|
||||||
|
const discordComplete = server.discord?.complete;
|
||||||
|
|
||||||
let borderClass = 'border-gray-200 dark:border-gray-700'; // Default / Offline
|
let borderClass = 'border-gray-200 dark:border-gray-700'; // Default / Offline
|
||||||
if (isOnline && !hasError) borderClass = 'border-green-500 shadow-[0_0_10px_rgba(34,197,94,0.2)]';
|
if (isOnline && !hasError) borderClass = 'border-green-500 shadow-[0_0_10px_rgba(34,197,94,0.2)]';
|
||||||
@@ -37,6 +38,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Discord Channel Status -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 block text-xs mb-1">Discord Channels</span>
|
||||||
|
<% if (discordComplete) { %>
|
||||||
|
<span class="text-green-600 dark:text-green-400 text-sm font-medium">✅ All 4 channels configured</span>
|
||||||
|
<% } else if (server.discord?.missing?.length > 0) { %>
|
||||||
|
<div class="bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400 p-2 rounded text-xs">
|
||||||
|
<strong>Missing:</strong> <%= server.discord.missing.join(', ') %>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 text-sm">Unable to check</span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
<% if (hasError) { %>
|
<% if (hasError) { %>
|
||||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-2 rounded text-xs mb-4 break-words">
|
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-2 rounded text-xs mb-4 break-words">
|
||||||
<strong>Error:</strong> <%= server.log.last_error %>
|
<strong>Error:</strong> <%= server.log.last_error %>
|
||||||
|
|||||||
@@ -4,12 +4,19 @@
|
|||||||
<p class="text-gray-500 dark:text-gray-400 text-sm">Real-time status and whitelist controls</p>
|
<p class="text-gray-500 dark:text-gray-400 text-sm">Real-time status and whitelist controls</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-x-3">
|
<div class="space-x-3">
|
||||||
<button class="bg-fire hover:bg-orange-600 text-white px-4 py-2 rounded-md text-sm font-medium shadow transition-colors">
|
<button hx-post="/admin/servers/sync-all/tx1"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-target="#sync-result"
|
||||||
|
class="bg-fire hover:bg-orange-600 text-white px-4 py-2 rounded-md text-sm font-medium shadow transition-colors">
|
||||||
🔥 Sync All Dallas
|
🔥 Sync All Dallas
|
||||||
</button>
|
</button>
|
||||||
<button class="bg-frost hover:bg-cyan-600 text-white px-4 py-2 rounded-md text-sm font-medium shadow transition-colors">
|
<button hx-post="/admin/servers/sync-all/nc1"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-target="#sync-result"
|
||||||
|
class="bg-frost hover:bg-cyan-600 text-white px-4 py-2 rounded-md text-sm font-medium shadow transition-colors">
|
||||||
❄️ Sync All Charlotte
|
❄️ Sync All Charlotte
|
||||||
</button>
|
</button>
|
||||||
|
<span id="sync-result" class="text-sm ml-2"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
195
services/arbiter-3.0/src/views/admin/social/add.ejs
Normal file
195
services/arbiter-3.0/src/views/admin/social/add.ejs
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<div class="mb-6">
|
||||||
|
<a href="/admin/social?platform=<%= platform %>" class="text-blue-600 dark:text-blue-400 hover:underline">← Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h1 class="text-2xl font-bold dark:text-white">📝 Add Post Analytics</h1>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">Enter metrics from TikTok Studio</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="/admin/social/add" method="POST" class="p-6 space-y-6">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<input type="hidden" name="platform" value="<%= platform %>">
|
||||||
|
|
||||||
|
<!-- Post Info -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="font-semibold dark:text-white border-b pb-2 dark:border-gray-700">Post Information</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Post Title/Description <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" name="post_title" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
|
||||||
|
placeholder="Fire people build fast, break things...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Posted Date <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="datetime-local" name="posted_at" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Video Length (seconds)
|
||||||
|
</label>
|
||||||
|
<input type="number" name="video_length_seconds" min="0"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
|
||||||
|
placeholder="8">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Post URL (optional)
|
||||||
|
</label>
|
||||||
|
<input type="url" name="post_url"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
|
||||||
|
placeholder="https://tiktok.com/@firefrostgaming/video/...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Engagement Metrics -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="font-semibold dark:text-white border-b pb-2 dark:border-gray-700">Engagement (from top right icons)</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-5 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Views</label>
|
||||||
|
<input type="number" name="views" min="0" value="0"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Likes</label>
|
||||||
|
<input type="number" name="likes" min="0" value="0"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Comments</label>
|
||||||
|
<input type="number" name="comments" min="0" value="0"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Shares</label>
|
||||||
|
<input type="number" name="shares" min="0" value="0"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Saves</label>
|
||||||
|
<input type="number" name="saves" min="0" value="0"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Watch Metrics -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="font-semibold dark:text-white border-b pb-2 dark:border-gray-700">Watch Metrics (from Overview tab)</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Total Play Time (seconds)
|
||||||
|
</label>
|
||||||
|
<input type="number" name="total_play_time_seconds" min="0" value="0"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
|
||||||
|
placeholder="2389 (convert from 0h:39m:49s)">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Avg Watch Time (seconds)
|
||||||
|
</label>
|
||||||
|
<input type="number" name="avg_watch_time_seconds" min="0" step="0.01" value="0"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
|
||||||
|
placeholder="2.84">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Watched Full Video (%)
|
||||||
|
</label>
|
||||||
|
<input type="number" name="watched_full_pct" min="0" max="100" step="0.1" value="0"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
|
||||||
|
placeholder="5.5">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Drop-off Point (seconds)
|
||||||
|
</label>
|
||||||
|
<input type="number" name="drop_off_seconds" min="0"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
|
||||||
|
placeholder="2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Growth & Traffic -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="font-semibold dark:text-white border-b pb-2 dark:border-gray-700">Growth & Traffic</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
New Followers
|
||||||
|
</label>
|
||||||
|
<input type="number" name="new_followers" min="0" value="0"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Top Traffic Source
|
||||||
|
</label>
|
||||||
|
<select name="top_traffic_source"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
<option value="">-- Select --</option>
|
||||||
|
<option value="For You">For You</option>
|
||||||
|
<option value="Search">Search</option>
|
||||||
|
<option value="Following">Following</option>
|
||||||
|
<option value="Personal profile">Personal Profile</option>
|
||||||
|
<option value="Sound">Sound</option>
|
||||||
|
<option value="Direct messages">Direct Messages</option>
|
||||||
|
<option value="Other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Traffic Source (%)
|
||||||
|
</label>
|
||||||
|
<input type="number" name="top_traffic_pct" min="0" max="100" step="0.1"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
|
||||||
|
placeholder="99.3">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Notes (optional)
|
||||||
|
</label>
|
||||||
|
<textarea name="notes" rows="2"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
|
||||||
|
placeholder="87% female audience, 57% age 18-24..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit -->
|
||||||
|
<div class="flex justify-end gap-3 pt-4 border-t dark:border-gray-700">
|
||||||
|
<a href="/admin/social?platform=<%= platform %>"
|
||||||
|
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 dark:text-white">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
|
||||||
|
Save Post
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
235
services/arbiter-3.0/src/views/admin/social/detail.ejs
Normal file
235
services/arbiter-3.0/src/views/admin/social/detail.ejs
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<div class="mb-6">
|
||||||
|
<a href="/admin/social?platform=<%= post.platform %>" class="text-blue-600 dark:text-blue-400 hover:underline">← Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<!-- Post Header -->
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg border border-gray-200 dark:border-gray-700 mb-6">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-bold dark:text-white mb-2"><%= post.post_title %></h1>
|
||||||
|
<div class="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span>📅 <%= new Date(post.posted_at).toLocaleDateString() %></span>
|
||||||
|
<% if (post.video_length_seconds) { %>
|
||||||
|
<span>⏱️ <%= post.video_length_seconds %>s</span>
|
||||||
|
<% } %>
|
||||||
|
<% if (post.post_url) { %>
|
||||||
|
<a href="<%= post.post_url %>" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline">View on TikTok →</a>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form action="/admin/social/post/<%= post.id %>/delete" method="POST"
|
||||||
|
onsubmit="return confirm('Delete this post?');">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<button type="submit" class="text-red-600 hover:text-red-800 dark:text-red-400 text-sm">
|
||||||
|
🗑️ Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Overview -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700 text-center">
|
||||||
|
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400"><%= Number(post.views || 0).toLocaleString() %></div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Views</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700 text-center">
|
||||||
|
<div class="text-2xl font-bold text-pink-600 dark:text-pink-400"><%= Number(post.likes || 0).toLocaleString() %></div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Likes</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700 text-center">
|
||||||
|
<div class="text-2xl font-bold dark:text-white"><%= post.comments || 0 %></div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Comments</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700 text-center">
|
||||||
|
<div class="text-2xl font-bold text-green-600 dark:text-green-400"><%= post.shares || 0 %></div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Shares</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700 text-center">
|
||||||
|
<div class="text-2xl font-bold text-purple-600 dark:text-purple-400"><%= post.saves || 0 %></div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Saves</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Watch & Traffic Stats -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||||
|
<!-- Watch Performance -->
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 class="font-semibold dark:text-white mb-4">⏱️ Watch Performance</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Avg Watch Time</span>
|
||||||
|
<span class="font-bold dark:text-white"><%= Number(post.avg_watch_time_seconds || 0).toFixed(2) %>s</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Watched Full Video</span>
|
||||||
|
<span class="font-bold <%= post.watched_full_pct >= 20 ? 'text-green-600' : post.watched_full_pct >= 10 ? 'text-yellow-600' : 'text-red-600' %>">
|
||||||
|
<%= Number(post.watched_full_pct || 0).toFixed(1) %>%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% if (post.drop_off_seconds) { %>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Drop-off Point</span>
|
||||||
|
<span class="font-bold text-red-600 dark:text-red-400"><%= post.drop_off_seconds %>s</span>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Total Play Time</span>
|
||||||
|
<span class="font-bold dark:text-white">
|
||||||
|
<%
|
||||||
|
const totalSecs = post.total_play_time_seconds || 0;
|
||||||
|
const hrs = Math.floor(totalSecs / 3600);
|
||||||
|
const mins = Math.floor((totalSecs % 3600) / 60);
|
||||||
|
const secs = totalSecs % 60;
|
||||||
|
%>
|
||||||
|
<%= hrs %>h:<%= mins %>m:<%= secs %>s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Traffic Source -->
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 class="font-semibold dark:text-white mb-4">📊 Traffic & Growth</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<% if (post.top_traffic_source) { %>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Top Source</span>
|
||||||
|
<span class="font-bold dark:text-white">
|
||||||
|
<%= post.top_traffic_source %>
|
||||||
|
<% if (post.top_traffic_pct) { %>
|
||||||
|
(<%= post.top_traffic_pct %>%)
|
||||||
|
<% } %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">New Followers</span>
|
||||||
|
<span class="font-bold text-purple-600 dark:text-purple-400"><%= post.new_followers || 0 %></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Engagement Rate</span>
|
||||||
|
<%
|
||||||
|
const engRate = post.views > 0
|
||||||
|
? (((post.likes + post.comments + post.shares) / post.views) * 100).toFixed(2)
|
||||||
|
: 0;
|
||||||
|
%>
|
||||||
|
<span class="font-bold text-orange-600 dark:text-orange-400"><%= engRate %>%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Update Form -->
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="font-semibold dark:text-white">📝 Update Metrics</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Refresh numbers from TikTok Studio</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="/admin/social/post/<%= post.id %>/update" method="POST" class="p-6 space-y-4">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
|
||||||
|
<div class="grid grid-cols-5 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Views</label>
|
||||||
|
<input type="number" name="views" value="<%= post.views || 0 %>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Likes</label>
|
||||||
|
<input type="number" name="likes" value="<%= post.likes || 0 %>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Comments</label>
|
||||||
|
<input type="number" name="comments" value="<%= post.comments || 0 %>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Shares</label>
|
||||||
|
<input type="number" name="shares" value="<%= post.shares || 0 %>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Saves</label>
|
||||||
|
<input type="number" name="saves" value="<%= post.saves || 0 %>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Total Play Time (s)</label>
|
||||||
|
<input type="number" name="total_play_time_seconds" value="<%= post.total_play_time_seconds || 0 %>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Avg Watch (s)</label>
|
||||||
|
<input type="number" name="avg_watch_time_seconds" step="0.01" value="<%= post.avg_watch_time_seconds || 0 %>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Watched Full (%)</label>
|
||||||
|
<input type="number" name="watched_full_pct" step="0.1" value="<%= post.watched_full_pct || 0 %>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Drop-off (s)</label>
|
||||||
|
<input type="number" name="drop_off_seconds" value="<%= post.drop_off_seconds || '' %>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">New Followers</label>
|
||||||
|
<input type="number" name="new_followers" value="<%= post.new_followers || 0 %>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Top Traffic Source</label>
|
||||||
|
<select name="top_traffic_source"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
<option value="">-- Select --</option>
|
||||||
|
<option value="For You" <%= post.top_traffic_source === 'For You' ? 'selected' : '' %>>For You</option>
|
||||||
|
<option value="Search" <%= post.top_traffic_source === 'Search' ? 'selected' : '' %>>Search</option>
|
||||||
|
<option value="Following" <%= post.top_traffic_source === 'Following' ? 'selected' : '' %>>Following</option>
|
||||||
|
<option value="Personal profile" <%= post.top_traffic_source === 'Personal profile' ? 'selected' : '' %>>Personal Profile</option>
|
||||||
|
<option value="Sound" <%= post.top_traffic_source === 'Sound' ? 'selected' : '' %>>Sound</option>
|
||||||
|
<option value="Direct messages" <%= post.top_traffic_source === 'Direct messages' ? 'selected' : '' %>>Direct Messages</option>
|
||||||
|
<option value="Other" <%= post.top_traffic_source === 'Other' ? 'selected' : '' %>>Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Traffic %</label>
|
||||||
|
<input type="number" name="top_traffic_pct" step="0.1" value="<%= post.top_traffic_pct || '' %>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Notes</label>
|
||||||
|
<textarea name="notes" rows="2"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"><%= post.notes || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit"
|
||||||
|
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
|
||||||
|
Update Metrics
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (post.notes) { %>
|
||||||
|
<div class="mt-6 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-lg p-4">
|
||||||
|
<h4 class="font-semibold text-yellow-800 dark:text-yellow-200 mb-2">📝 Notes</h4>
|
||||||
|
<p class="text-yellow-700 dark:text-yellow-300"><%= post.notes %></p>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
184
services/arbiter-3.0/src/views/admin/social/index.ejs
Normal file
184
services/arbiter-3.0/src/views/admin/social/index.ejs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<div class="mb-6 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold dark:text-white">📊 Social Analytics</h1>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">Track post performance and audience growth</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="/admin/social/add?platform=<%= platform %>"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
|
||||||
|
+ Add Post
|
||||||
|
</a>
|
||||||
|
<a href="/admin/social/snapshot?platform=<%= platform %>"
|
||||||
|
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition">
|
||||||
|
📸 Snapshot
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Platform Tabs -->
|
||||||
|
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<nav class="flex gap-4">
|
||||||
|
<a href="/admin/social?platform=tiktok"
|
||||||
|
class="pb-3 px-1 border-b-2 <%= platform === 'tiktok' ? 'border-pink-500 text-pink-600 dark:text-pink-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400' %>">
|
||||||
|
🎵 TikTok
|
||||||
|
</a>
|
||||||
|
<a href="/admin/social?platform=facebook"
|
||||||
|
class="pb-3 px-1 border-b-2 <%= platform === 'facebook' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400' %>">
|
||||||
|
📘 Facebook
|
||||||
|
</a>
|
||||||
|
<a href="/admin/social?platform=instagram"
|
||||||
|
class="pb-3 px-1 border-b-2 <%= platform === 'instagram' ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400' %>">
|
||||||
|
📷 Instagram
|
||||||
|
</a>
|
||||||
|
<a href="/admin/social?platform=x"
|
||||||
|
class="pb-3 px-1 border-b-2 <%= platform === 'x' ? 'border-gray-800 text-gray-800 dark:text-white' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400' %>">
|
||||||
|
𝕏 X
|
||||||
|
</a>
|
||||||
|
<a href="/admin/social?platform=bluesky"
|
||||||
|
class="pb-3 px-1 border-b-2 <%= platform === 'bluesky' ? 'border-sky-500 text-sky-600 dark:text-sky-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400' %>">
|
||||||
|
🦋 Bluesky
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Cards (Last 30 Days) -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-4 mb-6">
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Posts</div>
|
||||||
|
<div class="text-xl font-bold dark:text-white"><%= stats.total_posts || 0 %></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Views</div>
|
||||||
|
<div class="text-xl font-bold text-blue-600 dark:text-blue-400"><%= Number(stats.total_views || 0).toLocaleString() %></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Likes</div>
|
||||||
|
<div class="text-xl font-bold text-pink-600 dark:text-pink-400"><%= Number(stats.total_likes || 0).toLocaleString() %></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Comments</div>
|
||||||
|
<div class="text-xl font-bold dark:text-white"><%= Number(stats.total_comments || 0).toLocaleString() %></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Shares</div>
|
||||||
|
<div class="text-xl font-bold text-green-600 dark:text-green-400"><%= Number(stats.total_shares || 0).toLocaleString() %></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">New Followers</div>
|
||||||
|
<div class="text-xl font-bold text-purple-600 dark:text-purple-400"><%= Number(stats.total_new_followers || 0).toLocaleString() %></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Engagement</div>
|
||||||
|
<div class="text-xl font-bold text-orange-600 dark:text-orange-400"><%= stats.engagementRate %>%</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Avg Watch</div>
|
||||||
|
<div class="text-xl font-bold dark:text-white"><%= Number(stats.avg_watch_time || 0).toFixed(1) %>s</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (snapshot) { %>
|
||||||
|
<!-- Account Snapshot -->
|
||||||
|
<div class="bg-gradient-to-r from-pink-50 to-purple-50 dark:from-gray-800 dark:to-gray-800 rounded-lg p-4 mb-6 border border-pink-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">Account Snapshot (<%= new Date(snapshot.snapshot_date).toLocaleDateString() %>)</span>
|
||||||
|
<div class="flex gap-6 mt-1">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-600 dark:text-gray-300">Followers:</span>
|
||||||
|
<span class="font-bold dark:text-white ml-1"><%= Number(snapshot.total_followers || 0).toLocaleString() %></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-600 dark:text-gray-300">Profile Views:</span>
|
||||||
|
<span class="font-bold dark:text-white ml-1"><%= Number(snapshot.profile_views || 0).toLocaleString() %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (snapshot.search_queries && snapshot.search_queries.length > 0) { %>
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">Top Searches</span>
|
||||||
|
<div class="text-sm dark:text-gray-300">
|
||||||
|
<% snapshot.search_queries.slice(0, 3).forEach(q => { %>
|
||||||
|
<span class="inline-block bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded text-xs mr-1"><%= q %></span>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Posts Table -->
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 class="text-lg font-bold dark:text-white">Recent Posts</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Last 30 days performance</p>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Post</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Views</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Likes</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Comments</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Shares</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Completion</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Avg Watch</th>
|
||||||
|
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<% if (posts && posts.length > 0) { %>
|
||||||
|
<% posts.forEach(post => { %>
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="text-sm font-medium dark:text-white truncate max-w-xs" title="<%= post.post_title %>">
|
||||||
|
<%= post.post_title.substring(0, 50) %><%= post.post_title.length > 50 ? '...' : '' %>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<%= new Date(post.posted_at).toLocaleDateString() %>
|
||||||
|
<% if (post.video_length_seconds) { %>
|
||||||
|
· <%= post.video_length_seconds %>s
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<span class="text-sm font-medium text-blue-600 dark:text-blue-400"><%= Number(post.views || 0).toLocaleString() %></span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<span class="text-sm text-pink-600 dark:text-pink-400"><%= Number(post.likes || 0).toLocaleString() %></span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<span class="text-sm dark:text-white"><%= post.comments || 0 %></span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<span class="text-sm text-green-600 dark:text-green-400"><%= post.shares || 0 %></span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<span class="text-sm <%= post.watched_full_pct >= 20 ? 'text-green-600 dark:text-green-400' : post.watched_full_pct >= 10 ? 'text-yellow-600 dark:text-yellow-400' : 'text-red-600 dark:text-red-400' %>">
|
||||||
|
<%= Number(post.watched_full_pct || 0).toFixed(1) %>%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<span class="text-sm dark:text-white"><%= Number(post.avg_watch_time_seconds || 0).toFixed(1) %>s</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
<a href="/admin/social/post/<%= post.id %>"
|
||||||
|
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 text-sm">
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
<% } else { %>
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No posts tracked yet. <a href="/admin/social/add?platform=<%= platform %>" class="text-blue-600 dark:text-blue-400">Add your first post</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% } %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
76
services/arbiter-3.0/src/views/admin/social/snapshot.ejs
Normal file
76
services/arbiter-3.0/src/views/admin/social/snapshot.ejs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<div class="mb-6">
|
||||||
|
<a href="/admin/social?platform=<%= platform %>" class="text-blue-600 dark:text-blue-400 hover:underline">← Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-xl mx-auto">
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h1 class="text-2xl font-bold dark:text-white">📸 Account Snapshot</h1>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">Record account-level metrics from TikTok Studio Overview</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="/admin/social/snapshot" method="POST" class="p-6 space-y-6">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<input type="hidden" name="platform" value="<%= platform %>">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Snapshot Date <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="date" name="snapshot_date" required
|
||||||
|
value="<%= new Date().toISOString().split('T')[0] %>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Total Followers
|
||||||
|
</label>
|
||||||
|
<input type="number" name="total_followers" min="0" value="0"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
|
||||||
|
placeholder="From Followers tab">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Profile Views (7 days)
|
||||||
|
</label>
|
||||||
|
<input type="number" name="profile_views" min="0" value="0"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
|
||||||
|
placeholder="From Overview">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Top Search Queries (JSON array)
|
||||||
|
</label>
|
||||||
|
<input type="text" name="search_queries"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
|
||||||
|
placeholder='["frost fire server minecraft", "fire trail minecraft"]'>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Copy from Overview → Search queries section</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Demographics (JSON)
|
||||||
|
</label>
|
||||||
|
<textarea name="demographics" rows="3"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
|
||||||
|
placeholder='{"gender": {"female": 87, "male": 13}, "age": {"18-24": 57, "25-34": 13}}'></textarea>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">From Viewers tab (optional but useful)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-4 border-t dark:border-gray-700">
|
||||||
|
<a href="/admin/social?platform=<%= platform %>"
|
||||||
|
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 dark:text-white">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition">
|
||||||
|
Save Snapshot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
199
services/arbiter-3.0/src/views/admin/tasks/index.ejs
Normal file
199
services/arbiter-3.0/src/views/admin/tasks/index.ejs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<!-- Tasks Module — Trinity Console -->
|
||||||
|
<!-- Chronicler #78 | April 11, 2026 -->
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">Active</div>
|
||||||
|
<div class="text-2xl font-bold mt-1"><%= stats.active %></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">In Progress</div>
|
||||||
|
<div class="text-2xl font-bold mt-1 text-frost"><%= stats.in_progress %></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">Blocked</div>
|
||||||
|
<div class="text-2xl font-bold mt-1 text-red-500"><%= stats.blocked %></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">High Priority</div>
|
||||||
|
<div class="text-2xl font-bold mt-1 text-fire"><%= stats.high_priority %></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">Completed</div>
|
||||||
|
<div class="text-2xl font-bold mt-1 text-green-500"><%= stats.done %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters + Create -->
|
||||||
|
<div class="flex flex-wrap gap-4 mb-6 items-end">
|
||||||
|
<form method="GET" class="flex flex-wrap gap-3 items-end flex-1">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Status</label>
|
||||||
|
<select name="status" class="bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm">
|
||||||
|
<option value="">Active</option>
|
||||||
|
<% statuses.forEach(s => { %>
|
||||||
|
<option value="<%= s %>" <%= filters.status === s ? 'selected' : '' %>><%= s.replace('_', ' ') %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Priority</label>
|
||||||
|
<select name="priority" class="bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm">
|
||||||
|
<option value="">All</option>
|
||||||
|
<% priorities.forEach(p => { %>
|
||||||
|
<option value="<%= p %>" <%= filters.priority === p ? 'selected' : '' %>><%= p %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Owner</label>
|
||||||
|
<select name="owner" class="bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm">
|
||||||
|
<option value="">All</option>
|
||||||
|
<% owners.forEach(o => { %>
|
||||||
|
<option value="<%= o %>" <%= filters.owner === o ? 'selected' : '' %>><%= o %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="bg-frost text-white px-4 py-2 rounded-md text-sm hover:opacity-90 transition">Filter</button>
|
||||||
|
<a href="/admin/tasks" class="bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-4 py-2 rounded-md text-sm hover:opacity-90 transition">Reset</a>
|
||||||
|
<a href="/admin/tasks?all=1" class="bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-4 py-2 rounded-md text-sm hover:opacity-90 transition">Show All</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<button onclick="document.getElementById('create-modal').style.display='flex'" class="bg-gradient-to-r from-fire to-frost text-white px-4 py-2 rounded-md text-sm hover:opacity-90 transition font-medium">
|
||||||
|
+ New Task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Task List -->
|
||||||
|
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200 dark:border-gray-700 text-left text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
<th class="px-4 py-3 w-16">#</th>
|
||||||
|
<th class="px-4 py-3">Task</th>
|
||||||
|
<th class="px-4 py-3 w-24">Priority</th>
|
||||||
|
<th class="px-4 py-3 w-28">Status</th>
|
||||||
|
<th class="px-4 py-3 w-24">Owner</th>
|
||||||
|
<th class="px-4 py-3 w-32">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<% if (tasks.length === 0) { %>
|
||||||
|
<tr><td colspan="6" class="px-4 py-8 text-center text-gray-500">No tasks match your filters.</td></tr>
|
||||||
|
<% } %>
|
||||||
|
<% tasks.forEach(task => {
|
||||||
|
const priColors = { critical:'bg-red-500/20 text-red-500', high:'bg-fire/20 text-fire', medium:'bg-yellow-500/20 text-yellow-500', low:'bg-blue-500/20 text-blue-400', wish:'bg-purple-500/20 text-purple-400' };
|
||||||
|
const staColors = { open:'bg-gray-500/20 text-gray-400', in_progress:'bg-frost/20 text-frost', blocked:'bg-red-500/20 text-red-500', done:'bg-green-500/20 text-green-500', obsolete:'bg-gray-700/20 text-gray-600' };
|
||||||
|
const priClass = priColors[task.priority] || 'bg-gray-500/20 text-gray-400';
|
||||||
|
const staClass = staColors[task.status] || 'bg-gray-500/20 text-gray-400';
|
||||||
|
%>
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition group">
|
||||||
|
<td class="px-4 py-3 text-sm font-mono text-gray-500"><%= task.task_number %></td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="font-medium text-sm text-gray-200"><%= task.title %></div>
|
||||||
|
<% if (task.description) { %>
|
||||||
|
<div class="text-xs text-gray-500 mt-0.5 truncate max-w-md"><%= task.description.substring(0, 80) %><%= task.description.length > 80 ? '...' : '' %></div>
|
||||||
|
<% } %>
|
||||||
|
<% if (task.tags && task.tags.length > 0) { %>
|
||||||
|
<div class="flex gap-1 mt-1">
|
||||||
|
<% task.tags.forEach(tag => { %>
|
||||||
|
<span class="text-[9px] px-1.5 py-0.5 rounded bg-gray-700 text-gray-400"><%= tag %></span>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="inline-block px-2 py-0.5 text-xs rounded-full font-medium <%= priClass %>"><%= task.priority %></span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<form method="POST" action="/admin/tasks/update/<%= task.id %>" class="inline">
|
||||||
|
<select name="status" onchange="this.form.submit()" class="bg-transparent border-0 text-xs cursor-pointer <%= staClass %> rounded-full px-2 py-0.5 font-medium appearance-none">
|
||||||
|
<% statuses.forEach(s => { %>
|
||||||
|
<option value="<%= s %>" <%= task.status === s ? 'selected' : '' %> class="bg-gray-800 text-white"><%= s.replace('_', ' ') %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<form method="POST" action="/admin/tasks/update/<%= task.id %>" class="inline">
|
||||||
|
<select name="owner" onchange="this.form.submit()" class="bg-transparent border-0 text-xs cursor-pointer text-gray-400 appearance-none">
|
||||||
|
<% owners.forEach(o => { %>
|
||||||
|
<option value="<%= o %>" <%= task.owner === o ? 'selected' : '' %> class="bg-gray-800 text-white"><%= o %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<% if (task.status !== 'done' && task.status !== 'obsolete') { %>
|
||||||
|
<form method="POST" action="/admin/tasks/update/<%= task.id %>" class="inline">
|
||||||
|
<input type="hidden" name="status" value="done">
|
||||||
|
<button type="submit" class="text-xs text-green-500 hover:text-green-400 transition opacity-0 group-hover:opacity-100">✓ Done</button>
|
||||||
|
</form>
|
||||||
|
<% } else if (task.completed_by) { %>
|
||||||
|
<span class="text-[10px] text-gray-600"><%= task.completed_by %></span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center text-xs text-gray-600 mt-4">
|
||||||
|
<%= tasks.length %> task(s) shown · Source: PostgreSQL · Also available via /tasks in Discord
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Task Modal -->
|
||||||
|
<div id="create-modal" style="display:none" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onclick="if(event.target===this)this.style.display='none'">
|
||||||
|
<div class="bg-white dark:bg-darkcard border border-gray-200 dark:border-gray-700 rounded-lg shadow-2xl w-full max-w-lg p-6">
|
||||||
|
<h2 class="text-xl font-bold mb-4 text-gray-200">New Task</h2>
|
||||||
|
<form method="POST" action="/admin/tasks/create">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Title *</label>
|
||||||
|
<input type="text" name="title" required
|
||||||
|
class="w-full bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm"
|
||||||
|
placeholder="What needs to be done?">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Description</label>
|
||||||
|
<textarea name="description" rows="3"
|
||||||
|
class="w-full bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm"
|
||||||
|
placeholder="Optional details..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Priority</label>
|
||||||
|
<select name="priority" class="w-full bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm">
|
||||||
|
<option value="medium" selected>Medium</option>
|
||||||
|
<option value="critical">Critical</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="wish">Wish List</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Owner</label>
|
||||||
|
<select name="owner" class="w-full bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm">
|
||||||
|
<option value="Michael">Michael</option>
|
||||||
|
<option value="Holly">Holly</option>
|
||||||
|
<option value="Meg">Meg</option>
|
||||||
|
<option value="Trinity">Trinity</option>
|
||||||
|
<option value="unassigned">Unassigned</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-3 mt-6">
|
||||||
|
<button type="button" onclick="document.getElementById('create-modal').style.display='none'"
|
||||||
|
class="px-4 py-2 text-gray-500 hover:text-gray-300 transition text-sm">Cancel</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="bg-gradient-to-r from-fire to-frost text-white px-6 py-2 rounded-md text-sm font-medium hover:opacity-90 transition">
|
||||||
|
Create Task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -63,39 +63,81 @@
|
|||||||
<div class="flex h-screen overflow-hidden">
|
<div class="flex h-screen overflow-hidden">
|
||||||
<aside id="sidebar" class="w-64 bg-white dark:bg-darkcard border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
<aside id="sidebar" class="w-64 bg-white dark:bg-darkcard border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
||||||
<div class="p-6 flex justify-between items-center">
|
<div class="p-6 flex justify-between items-center">
|
||||||
<h1 class="text-2xl font-bold bg-gradient-to-r from-fire via-universal to-frost text-transparent bg-clip-text">Trinity Console</h1>
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold bg-gradient-to-r from-fire via-universal to-frost text-transparent bg-clip-text">Trinity Console</h1>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">v1.0</span>
|
||||||
|
</div>
|
||||||
<!-- Mobile close button -->
|
<!-- Mobile close button -->
|
||||||
<button onclick="document.getElementById('sidebar').classList.remove('open'); document.getElementById('sidebar-overlay').classList.remove('open');" class="md:hidden text-2xl">✕</button>
|
<button onclick="document.getElementById('sidebar').classList.remove('open'); document.getElementById('sidebar-overlay').classList.remove('open');" class="md:hidden text-2xl">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 px-4 space-y-2">
|
<nav class="flex-1 px-4 space-y-1 overflow-y-auto">
|
||||||
|
<!-- Operations -->
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-600 font-semibold px-4 pt-3 pb-1">Operations</div>
|
||||||
<a href="/admin/dashboard" class="block px-4 py-2 rounded-md <%= currentPath === '/dashboard' ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
<a href="/admin/dashboard" class="block px-4 py-2 rounded-md <%= currentPath === '/dashboard' ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||||
📊 Dashboard
|
📊 Dashboard
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/tasks" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/tasks') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||||
|
📋 Tasks
|
||||||
|
</a>
|
||||||
<a href="/admin/servers" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/servers') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
<a href="/admin/servers" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/servers') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||||
🖥️ Servers
|
🖥️ Servers
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/players" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/players') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
<a href="/admin/players" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/players') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||||
👥 Players
|
👥 Players
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Business -->
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-600 font-semibold px-4 pt-3 pb-1">Business</div>
|
||||||
<a href="/admin/financials" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/financials') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
<a href="/admin/financials" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/financials') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||||
💰 Financials
|
💰 Financials
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/grace" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/grace') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
<a href="/admin/grace" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/grace') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||||
⏳ Grace Period
|
⏳ Grace Period
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Community -->
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-600 font-semibold px-4 pt-3 pb-1">Community</div>
|
||||||
|
<a href="/admin/discord" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/discord') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||||
|
💬 Discord
|
||||||
|
</a>
|
||||||
|
<a href="/admin/social" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/social') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||||
|
📈 Social
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Infrastructure -->
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-600 font-semibold px-4 pt-3 pb-1">Infrastructure</div>
|
||||||
|
<a href="/admin/infrastructure" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/infrastructure') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||||
|
🌐 Infrastructure
|
||||||
|
</a>
|
||||||
|
<a href="/admin/scheduler" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/scheduler') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||||
|
⏰ Restart Scheduler
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- System -->
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-600 font-semibold px-4 pt-3 pb-1">System</div>
|
||||||
<a href="/admin/audit" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/audit') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
<a href="/admin/audit" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/audit') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||||
📋 Audit Log
|
📋 Audit Log
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/roles" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/roles') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
<a href="/admin/roles" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/roles') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||||
🔍 Role Audit
|
🔍 Role Audit
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/mcp-logs" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/mcp-logs') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||||
|
🖥️ MCP Logs
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center gap-3">
|
<!-- User Info -->
|
||||||
<img src="https://cdn.discordapp.com/avatars/<%= adminUser.id %>/<%= adminUser.avatar %>.png" class="w-10 h-10 rounded-full">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-medium"><%= adminUser.username %></span>
|
<div class="flex items-center gap-3">
|
||||||
|
<img src="https://cdn.discordapp.com/avatars/<%= adminUser.id %>/<%= adminUser.avatar %>.png" class="w-10 h-10 rounded-full">
|
||||||
|
<span class="font-medium"><%= adminUser.username %></span>
|
||||||
|
</div>
|
||||||
|
<a href="/auth/logout" class="text-gray-400 hover:text-red-500 transition" title="Logout">
|
||||||
|
🚪
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="flex-1 flex flex-col overflow-hidden">
|
<main class="flex-1 flex flex-col overflow-hidden">
|
||||||
@@ -105,13 +147,13 @@
|
|||||||
<button onclick="document.getElementById('sidebar').classList.add('open'); document.getElementById('sidebar-overlay').classList.add('open');" class="md:hidden text-2xl">☰</button>
|
<button onclick="document.getElementById('sidebar').classList.add('open'); document.getElementById('sidebar-overlay').classList.add('open');" class="md:hidden text-2xl">☰</button>
|
||||||
<h2 class="text-xl font-semibold"><%= title %></h2>
|
<h2 class="text-xl font-semibold"><%= title %></h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/admin/about" class="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition <%= currentPath === '/about' ? 'bg-gray-200 dark:bg-gray-700' : '' %>" title="About Trinity Console">
|
||||||
|
ℹ️
|
||||||
|
</a>
|
||||||
<button onclick="document.documentElement.classList.toggle('dark')" class="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700">
|
<button onclick="document.documentElement.classList.toggle('dark')" class="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
🌙/☀️
|
🌙/☀️
|
||||||
</button>
|
</button>
|
||||||
<span class="relative">
|
|
||||||
🔔 <span class="absolute -top-1 -right-1 bg-fire text-white text-xs rounded-full h-4 w-4 flex items-center justify-center">0</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to ModpackChecker will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-04-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release
|
||||||
|
- Dashboard badge showing update status (🟠 update available / 🟢 up to date)
|
||||||
|
- Console widget with "Check for Updates" button
|
||||||
|
- Support for 4 modpack platforms:
|
||||||
|
- CurseForge (requires API key)
|
||||||
|
- Modrinth (no key required)
|
||||||
|
- FTB via modpacks.ch (no key required)
|
||||||
|
- Technic (no key required, dynamic build detection)
|
||||||
|
- Admin panel for CurseForge API key configuration
|
||||||
|
- Cron command for automated background checks
|
||||||
|
- Rate limiting: 2 manual checks per minute per server
|
||||||
|
- 60-second TTL cache for dashboard badges
|
||||||
|
- Foreign key cascade delete for data integrity
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- Centralized `ModpackApiService` for all platform API calls
|
||||||
|
- Cached Technic launcher build number (12-hour TTL)
|
||||||
|
- Database table `modpackchecker_servers` for status caching
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Fire + Frost + Foundation = Where Love Builds Legacy* 🔥❄️💙
|
||||||
308
services/modpack-version-checker/blueprint-extension/README.md
Normal file
308
services/modpack-version-checker/blueprint-extension/README.md
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
# ModpackChecker — Pterodactyl Blueprint Extension
|
||||||
|
|
||||||
|
**Version:** 1.0.0
|
||||||
|
**Author:** Firefrost Gaming / Frostystyle
|
||||||
|
**License:** Commercial License - Unauthorized redistribution, resale, or sharing of this source code is strictly prohibited.
|
||||||
|
|
||||||
|
A Pterodactyl Panel extension that checks modpack versions across CurseForge, Modrinth, FTB, and Technic platforms. Shows update status on the dashboard and provides manual version checks from the server console.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Features](#features)
|
||||||
|
2. [Architecture Overview](#architecture-overview)
|
||||||
|
3. [File Structure](#file-structure)
|
||||||
|
4. [Installation](#installation)
|
||||||
|
5. [Configuration](#configuration)
|
||||||
|
6. [Usage](#usage)
|
||||||
|
7. [Development](#development)
|
||||||
|
8. [API Reference](#api-reference)
|
||||||
|
9. [Troubleshooting](#troubleshooting)
|
||||||
|
10. [Support](#support)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Dashboard Badge
|
||||||
|
- Shows a colored dot next to each server name on the dashboard
|
||||||
|
- **🟠 Orange (Fire #FF6B35):** Update available
|
||||||
|
- **🟢 Teal (Frost #4ECDC4):** Up to date
|
||||||
|
- Hover for version details tooltip
|
||||||
|
- Single API call per page load (cached with 60s TTL)
|
||||||
|
|
||||||
|
### Console Widget
|
||||||
|
- "Check for Updates" button on each server's console page
|
||||||
|
- Real-time version check against platform API
|
||||||
|
- Rate limited: 2 checks per minute per server
|
||||||
|
- Shows modpack name and latest version
|
||||||
|
|
||||||
|
### Admin Panel
|
||||||
|
- Configure CurseForge API key
|
||||||
|
- View supported platforms
|
||||||
|
- PRO features: Discord notifications, custom check intervals
|
||||||
|
|
||||||
|
### Supported Platforms
|
||||||
|
| Platform | ID Type | API Key Required | Status |
|
||||||
|
|----------|---------|------------------|--------|
|
||||||
|
| CurseForge | Numeric project ID | ✅ Yes | ✅ Working |
|
||||||
|
| Modrinth | Project ID or slug | ❌ No | ✅ Working |
|
||||||
|
| FTB | Numeric modpack ID | ❌ No | ✅ Working |
|
||||||
|
| Technic | URL slug | ❌ No | ✅ Working |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ MODPACK VERSION CHECKER │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ CRON JOB (runs every 4-6 hrs) │
|
||||||
|
│ php artisan modpackchecker:check │
|
||||||
|
└──────────────────┬──────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ DATABASE CACHE │
|
||||||
|
│ modpackchecker_servers table │
|
||||||
|
│ │
|
||||||
|
│ • server_uuid │
|
||||||
|
│ • platform, modpack_id │
|
||||||
|
│ • current_version, latest_version │
|
||||||
|
│ • status (string) │
|
||||||
|
│ • last_checked timestamp │
|
||||||
|
└──────────────────┬──────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────────┴──────────────────┐
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌───────────────────────────┐ ┌───────────────────────────┐
|
||||||
|
│ DASHBOARD BADGE │ │ CONSOLE WIDGET │
|
||||||
|
│ (UpdateBadge.tsx) │ │ (wrapper.tsx) │
|
||||||
|
│ │ │ │
|
||||||
|
│ • Reads from cache ONLY │ │ • Manual "Check" button │
|
||||||
|
│ • 60-second TTL │ │ • LIVE API call │
|
||||||
|
│ • Shows 🟠 or 🟢 dot │ │ • Rate limited (2/min) │
|
||||||
|
└───────────────────────────┘ └───────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
blueprint-extension/
|
||||||
|
├── README.md
|
||||||
|
├── CHANGELOG.md
|
||||||
|
├── conf.yml
|
||||||
|
├── build.sh
|
||||||
|
├── icon.png
|
||||||
|
│
|
||||||
|
├── app/
|
||||||
|
│ ├── Console/Commands/
|
||||||
|
│ │ └── CheckModpackUpdates.php
|
||||||
|
│ ├── Http/Controllers/
|
||||||
|
│ │ └── ModpackAPIController.php
|
||||||
|
│ └── Services/
|
||||||
|
│ └── ModpackApiService.php
|
||||||
|
│
|
||||||
|
├── admin/
|
||||||
|
│ ├── controller.php
|
||||||
|
│ └── view.blade.php
|
||||||
|
│
|
||||||
|
├── database/migrations/
|
||||||
|
│ └── 2026_04_06_000000_create_modpackchecker_servers_table.php
|
||||||
|
│
|
||||||
|
├── routes/
|
||||||
|
│ └── client.php
|
||||||
|
│
|
||||||
|
└── views/
|
||||||
|
├── server/wrapper.tsx
|
||||||
|
└── dashboard/UpdateBadge.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Pterodactyl Panel v1.11+
|
||||||
|
- Blueprint Framework (beta-2026-01 or newer)
|
||||||
|
- PHP 8.1+
|
||||||
|
- Node.js 18+
|
||||||
|
- Yarn package manager
|
||||||
|
|
||||||
|
### Standard Installation (BuiltByBit)
|
||||||
|
|
||||||
|
1. Upload the downloaded `modpackchecker.blueprint` file to your Pterodactyl panel's root directory (usually `/var/www/pterodactyl`).
|
||||||
|
|
||||||
|
2. Run the Blueprint installation command:
|
||||||
|
```bash
|
||||||
|
blueprint -install modpackchecker
|
||||||
|
```
|
||||||
|
|
||||||
|
3. The framework will automatically inject the frontend components and rebuild the panel assets.
|
||||||
|
|
||||||
|
4. Set up the cron job for automated checks:
|
||||||
|
```bash
|
||||||
|
# Add to crontab
|
||||||
|
0 */6 * * * www-data cd /var/www/pterodactyl && php artisan modpackchecker:check >> /dev/null 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Developer/Manual Installation
|
||||||
|
|
||||||
|
If installing from raw source:
|
||||||
|
```bash
|
||||||
|
cp -r blueprint-extension /var/www/pterodactyl/.blueprint/extensions/modpackchecker
|
||||||
|
chown -R www-data:www-data /var/www/pterodactyl/.blueprint/extensions/modpackchecker
|
||||||
|
blueprint -build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Server Egg Variables
|
||||||
|
|
||||||
|
For modpack detection, set these variables in your server's egg:
|
||||||
|
|
||||||
|
| Variable | Description | Example |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `MODPACK_PLATFORM` | Platform name | `modrinth`, `curseforge`, `ftb`, `technic` |
|
||||||
|
| `MODPACK_ID` | Platform-specific ID | `adrenaserver` (Modrinth slug) |
|
||||||
|
| `MODPACK_CURRENT_VERSION` | Installed version | `1.7.0` |
|
||||||
|
|
||||||
|
### CurseForge API Key
|
||||||
|
|
||||||
|
CurseForge requires an API key:
|
||||||
|
|
||||||
|
1. Apply for API access at https://console.curseforge.com/
|
||||||
|
2. Go to **Admin Panel → Extensions → ModpackChecker**
|
||||||
|
3. Enter your API key and save
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Dashboard Badge
|
||||||
|
Badges appear automatically for servers that:
|
||||||
|
- Have `MODPACK_PLATFORM` egg variable set
|
||||||
|
- Have been checked by the cron job at least once
|
||||||
|
|
||||||
|
### Manual Check
|
||||||
|
1. Go to any server's console page
|
||||||
|
2. Click "Check for Updates" button
|
||||||
|
3. View results showing modpack name and version status
|
||||||
|
|
||||||
|
### Cron Command
|
||||||
|
Run manually for testing:
|
||||||
|
```bash
|
||||||
|
php artisan modpackchecker:check
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Adding a New Platform
|
||||||
|
|
||||||
|
1. Open `app/Services/ModpackApiService.php`
|
||||||
|
2. Add your new platform check method (e.g., `private function checkNewPlatform(string $id): array`)
|
||||||
|
3. Add the platform key to the `match()` statement inside the `fetchLatestVersion()` method
|
||||||
|
4. The Controller and Cron Command will automatically inherit the new logic
|
||||||
|
5. Update this README to reflect the newly supported platform
|
||||||
|
|
||||||
|
### Testing API Endpoints
|
||||||
|
```bash
|
||||||
|
# Manual check (requires auth token)
|
||||||
|
curl -X POST "https://panel.example.com/api/client/extensions/modpackchecker/servers/{uuid}/check" \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
|
||||||
|
# Get all statuses
|
||||||
|
curl "https://panel.example.com/api/client/extensions/modpackchecker/status" \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### POST `/api/client/extensions/modpackchecker/servers/{server}/check`
|
||||||
|
|
||||||
|
Manual version check for a specific server. Triggers a live API call to the modpack platform.
|
||||||
|
|
||||||
|
**Rate Limit:** 2 requests per minute per server
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"platform": "modrinth",
|
||||||
|
"modpack_id": "adrenaserver",
|
||||||
|
"modpack_name": "Adrenaserver",
|
||||||
|
"latest_version": "1.7.0+1.21.1.fabric",
|
||||||
|
"status": "checked"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET `/api/client/extensions/modpackchecker/status`
|
||||||
|
|
||||||
|
Get cached status for all servers accessible to the authenticated user.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"a1b2c3d4-...": {
|
||||||
|
"update_available": true,
|
||||||
|
"modpack_name": "All The Mods 9",
|
||||||
|
"current_version": "0.2.51",
|
||||||
|
"latest_version": "0.2.60"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Badge not showing
|
||||||
|
1. Check server has `MODPACK_PLATFORM` variable set
|
||||||
|
2. Run cron command manually: `php artisan modpackchecker:check`
|
||||||
|
3. Check `modpackchecker_servers` table for entries
|
||||||
|
|
||||||
|
### "CurseForge API key not configured"
|
||||||
|
1. Go to Admin → Extensions → ModpackChecker
|
||||||
|
2. Enter your CurseForge API key
|
||||||
|
3. Key must have mod read permissions
|
||||||
|
|
||||||
|
### 500 errors on check
|
||||||
|
1. Check PHP error log: `tail -f /var/log/php8.3-fpm.log`
|
||||||
|
2. Verify controller namespace: `Pterodactyl\Http\Controllers`
|
||||||
|
3. Restart PHP-FPM: `systemctl restart php8.3-fpm`
|
||||||
|
|
||||||
|
### "Rate limit reached" message
|
||||||
|
The manual check is limited to 2 requests per minute per server. Wait 60 seconds and try again.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
**Need help?** Join our Discord for support:
|
||||||
|
- **Discord:** [discord.firefrostgaming.com](https://discord.firefrostgaming.com)
|
||||||
|
- **Email:** dev@firefrostgaming.com
|
||||||
|
- **Website:** [firefrostgaming.com](https://firefrostgaming.com)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
**Developed by:** Firefrost Gaming / Frostystyle
|
||||||
|
**Architecture Review:** Gemini AI
|
||||||
|
|
||||||
|
**Part of Firefrost Gaming**
|
||||||
|
*Fire + Frost + Foundation = Where Love Builds Legacy* 🔥❄️💙
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Http\Controllers\Admin\Extensions\modpackchecker;
|
||||||
|
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Illuminate\View\Factory as ViewFactory;
|
||||||
|
use Pterodactyl\Http\Controllers\Controller;
|
||||||
|
use Pterodactyl\BlueprintFramework\Libraries\ExtensionLibrary\Admin\BlueprintAdminLibrary as BlueprintExtensionLibrary;
|
||||||
|
use Pterodactyl\Http\Requests\Admin\AdminFormRequest;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
||||||
|
class modpackcheckerExtensionController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ViewFactory $view,
|
||||||
|
private BlueprintExtensionLibrary $blueprint,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
// Get current settings
|
||||||
|
$curseforge_api_key = $this->blueprint->dbGet('modpackchecker', 'curseforge_api_key');
|
||||||
|
$discord_webhook_url = $this->blueprint->dbGet('modpackchecker', 'discord_webhook_url');
|
||||||
|
$check_interval = $this->blueprint->dbGet('modpackchecker', 'check_interval');
|
||||||
|
$tier = $this->blueprint->dbGet('modpackchecker', 'tier');
|
||||||
|
|
||||||
|
// Set defaults if empty
|
||||||
|
if ($check_interval == '') {
|
||||||
|
$this->blueprint->dbSet('modpackchecker', 'check_interval', 'daily');
|
||||||
|
$check_interval = 'daily';
|
||||||
|
}
|
||||||
|
if ($tier == '') {
|
||||||
|
$this->blueprint->dbSet('modpackchecker', 'tier', 'standard');
|
||||||
|
$tier = 'standard';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->view->make(
|
||||||
|
'admin.extensions.modpackchecker.index', [
|
||||||
|
'curseforge_api_key' => $curseforge_api_key,
|
||||||
|
'discord_webhook_url' => $discord_webhook_url,
|
||||||
|
'check_interval' => $check_interval,
|
||||||
|
'tier' => $tier,
|
||||||
|
'root' => '/admin/extensions/modpackchecker',
|
||||||
|
'blueprint' => $this->blueprint,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||||
|
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||||
|
*/
|
||||||
|
public function update(modpackcheckerSettingsFormRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->blueprint->dbSet('modpackchecker', 'curseforge_api_key', $request->input('curseforge_api_key') ?? '');
|
||||||
|
$this->blueprint->dbSet('modpackchecker', 'discord_webhook_url', $request->input('discord_webhook_url') ?? '');
|
||||||
|
$this->blueprint->dbSet('modpackchecker', 'check_interval', $request->input('check_interval') ?? 'daily');
|
||||||
|
|
||||||
|
return redirect()->route('admin.extensions.modpackchecker.index')->with('success', 'Settings saved successfully.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class modpackcheckerSettingsFormRequest extends AdminFormRequest
|
||||||
|
{
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'curseforge_api_key' => 'nullable|string|max:500',
|
||||||
|
'discord_webhook_url' => 'nullable|url|max:500',
|
||||||
|
'check_interval' => 'nullable|in:daily,12h,6h',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attributes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'curseforge_api_key' => 'CurseForge API Key',
|
||||||
|
'discord_webhook_url' => 'Discord Webhook URL',
|
||||||
|
'check_interval' => 'Check Interval',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<form id="config-form" action="" method="POST">
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
showSaveButton();
|
||||||
|
});
|
||||||
|
|
||||||
|
function showSaveButton() {
|
||||||
|
const configForm = document.getElementById("config-form");
|
||||||
|
const saveOverlay = document.getElementById("save-overlay");
|
||||||
|
|
||||||
|
configForm.addEventListener("change", function() {
|
||||||
|
saveOverlay.style.display = "inline";
|
||||||
|
setTimeout(() => {
|
||||||
|
saveOverlay.style.bottom = "10px";
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
configForm.addEventListener("input", function() {
|
||||||
|
saveOverlay.style.display = "inline";
|
||||||
|
setTimeout(() => {
|
||||||
|
saveOverlay.style.bottom = "10px";
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Save button overlay -->
|
||||||
|
<div id="save-overlay">
|
||||||
|
{{ csrf_field() }}
|
||||||
|
<button type="submit" name="_method" value="PATCH" class="btn btn-primary btn-sm">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
#save-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
transition: bottom 0.3s;
|
||||||
|
bottom: -200px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 500;
|
||||||
|
padding: 15px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="row" style="margin-bottom: 20px;">
|
||||||
|
<div class="col-xs-12">
|
||||||
|
<div style="display: flex; align-items: center; gap: 15px;">
|
||||||
|
<div style="width: 50px; height: 50px; background: linear-gradient(135deg, #FF6B35, #4ECDC4); border-radius: 10px; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<i class="fa fa-cube" style="font-size: 24px; color: white;"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin: 0; color: #fff;">ModpackChecker</h2>
|
||||||
|
<p style="margin: 0; color: #888;">4-Platform Modpack Version Monitoring</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- CurseForge API Key -->
|
||||||
|
<div class="col-xs-12 col-md-6">
|
||||||
|
<div class="box box-primary">
|
||||||
|
<div class="box-header with-border">
|
||||||
|
<h3 class="box-title">
|
||||||
|
<i class="fa fa-key"></i> CurseForge API Key
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="box-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="control-label">API Key (BYOK)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="curseforge_api_key"
|
||||||
|
id="curseforge_api_key"
|
||||||
|
value="{{ $curseforge_api_key }}"
|
||||||
|
placeholder="$2a$10$..."
|
||||||
|
class="form-control"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<p class="text-muted small" style="margin-top: 8px;">
|
||||||
|
Get your free API key from
|
||||||
|
<a href="https://console.curseforge.com/" target="_blank">console.curseforge.com</a>.
|
||||||
|
Required for CurseForge modpack detection.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Check Interval (PRO TIER) -->
|
||||||
|
<div class="col-xs-12 col-md-6">
|
||||||
|
<div class="box box-info">
|
||||||
|
<div class="box-header with-border">
|
||||||
|
<h3 class="box-title">
|
||||||
|
<i class="fa fa-clock-o"></i> Check Interval
|
||||||
|
<span class="label label-warning" style="margin-left: 10px;">PRO TIER</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="box-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="control-label">Automatic Check Frequency</label>
|
||||||
|
<select class="form-control" name="check_interval" id="check_interval" disabled>
|
||||||
|
<option value="daily" selected>Daily (24 Hours)</option>
|
||||||
|
<option value="12h">Every 12 Hours</option>
|
||||||
|
<option value="6h">Every 6 Hours</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-muted small" style="margin-top: 8px;">
|
||||||
|
Standard tier is locked to daily cron checks.
|
||||||
|
Upgrade to Professional for more frequent automated checks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Discord Webhook (PRO TIER) -->
|
||||||
|
<div class="col-xs-12 col-md-6">
|
||||||
|
<div class="box box-success">
|
||||||
|
<div class="box-header with-border">
|
||||||
|
<h3 class="box-title">
|
||||||
|
<i class="fa fa-bell"></i> Discord Notifications
|
||||||
|
<span class="label label-warning" style="margin-left: 10px;">PRO TIER</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="box-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="control-label">Webhook URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
name="discord_webhook_url"
|
||||||
|
id="discord_webhook_url"
|
||||||
|
value="{{ $discord_webhook_url }}"
|
||||||
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
|
class="form-control"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<p class="text-muted small" style="margin-top: 8px;">
|
||||||
|
Upgrade to Professional to receive automated update alerts in your Discord server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Supported Platforms -->
|
||||||
|
<div class="col-xs-12 col-md-6">
|
||||||
|
<div class="box box-default">
|
||||||
|
<div class="box-header with-border">
|
||||||
|
<h3 class="box-title">
|
||||||
|
<i class="fa fa-check-circle"></i> Supported Platforms
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="box-body">
|
||||||
|
<ul style="list-style: none; padding: 0; margin: 0;">
|
||||||
|
<li style="padding: 8px 0; border-bottom: 1px solid #333;">
|
||||||
|
<i class="fa fa-fire" style="color: #f16436; width: 20px;"></i>
|
||||||
|
<strong>CurseForge</strong>
|
||||||
|
<span class="text-muted small">(Requires API Key)</span>
|
||||||
|
</li>
|
||||||
|
<li style="padding: 8px 0; border-bottom: 1px solid #333;">
|
||||||
|
<i class="fa fa-leaf" style="color: #1bd96a; width: 20px;"></i>
|
||||||
|
<strong>Modrinth</strong>
|
||||||
|
<span class="text-muted small">(No key required)</span>
|
||||||
|
</li>
|
||||||
|
<li style="padding: 8px 0; border-bottom: 1px solid #333;">
|
||||||
|
<i class="fa fa-cogs" style="color: #4a90d9; width: 20px;"></i>
|
||||||
|
<strong>Technic</strong>
|
||||||
|
<span class="text-muted small">(No key required)</span>
|
||||||
|
</li>
|
||||||
|
<li style="padding: 8px 0;">
|
||||||
|
<i class="fa fa-cube" style="color: #e04e39; width: 20px;"></i>
|
||||||
|
<strong>FTB (modpacks.ch)</strong>
|
||||||
|
<span class="text-muted small">(No key required)</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer Info -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xs-12">
|
||||||
|
<div style="background: #1a1a2e; border-left: 4px solid #4ECDC4; border-radius: 4px; padding: 15px; margin-bottom: 15px;">
|
||||||
|
<h4 style="margin: 0 0 10px 0; color: #fff;"><i class="fa fa-info-circle" style="color: #4ECDC4;"></i> How It Works</h4>
|
||||||
|
<p style="margin-bottom: 0; color: #ccc;">
|
||||||
|
ModpackChecker automatically detects modpacks via Egg Variables or file fingerprinting.
|
||||||
|
Set <code style="background: #2a2a3e; padding: 2px 6px; border-radius: 3px;">MODPACK_PLATFORM</code> and
|
||||||
|
<code style="background: #2a2a3e; padding: 2px 6px; border-radius: 3px;">MODPACK_ID</code> in your server's startup variables
|
||||||
|
for the most reliable detection, or let the extension scan for
|
||||||
|
<code style="background: #2a2a3e; padding: 2px 6px; border-radius: 3px;">manifest.json</code> /
|
||||||
|
<code style="background: #2a2a3e; padding: 2px 6px; border-radius: 3px;">modrinth.index.json</code> files.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Support -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xs-12">
|
||||||
|
<div style="background: #1a1a2e; border-left: 4px solid #FF6B35; border-radius: 4px; padding: 15px;">
|
||||||
|
<h4 style="margin: 0 0 10px 0; color: #fff;"><i class="fa fa-life-ring" style="color: #FF6B35;"></i> Need Help?</h4>
|
||||||
|
<p style="margin-bottom: 0; color: #ccc;">
|
||||||
|
Join our Discord for support: <a href="https://firefrostgaming.com/discord" target="_blank" style="color: #4ECDC4;">firefrostgaming.com/discord</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user