diff --git a/DEV_GUIDE.md b/DEV_GUIDE.md index 86e4e41..7a82c0e 100644 --- a/DEV_GUIDE.md +++ b/DEV_GUIDE.md @@ -59,7 +59,7 @@ The `./scripts/dev.sh` script provides comprehensive development automation: ### Docker Operations ```bash -./scripts/dev.sh build # Build Docker images +./scripts/dev.sh build # Build Docker images (bot + dashboard) ``` ## ๐Ÿณ Development Services @@ -76,6 +76,30 @@ When you run `./scripts/dev.sh up`, the following services are available: | Redis Commander | http://localhost:8081 | Redis administration | | MailHog | http://localhost:8025 | Email testing | +## ๐Ÿ–ฅ๏ธ Dashboard Frontend + +The dashboard backend serves static assets from `dashboard/frontend/dist`. + +Build the static assets: +```bash +cd dashboard/frontend +npm install +npm run build +``` + +Or run the Vite dev server for UI iteration: +```bash +cd dashboard/frontend +npm install +npm run dev +``` + +The Vite dev server runs at `http://localhost:5173`. + +The Vite dev server proxies `/api` and `/auth` to `http://localhost:8000`. If you're +using the Docker dev stack (dashboard at `http://localhost:8080`), either run the +dashboard backend locally on port 8000 or update the proxy target. + ## ๐Ÿงช Testing ### Running Tests @@ -284,4 +308,4 @@ pytest tests/test_config.py::TestSettingsValidation::test_discord_token_validati --- -Happy coding! ๐ŸŽ‰ \ No newline at end of file +Happy coding! ๐ŸŽ‰ diff --git a/README.md b/README.md index dd3863e..d50487f 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,13 @@ GuardDen is a comprehensive Discord moderation bot designed to protect your comm - Ban/unban events - All moderation actions +### Web Dashboard +- Servers overview with plan status and quick config links +- Users view with cross-guild search and strike totals +- Chats view for moderated message logs with filters +- Moderation logs, analytics, and configuration updates +- Config export for backups + ## Quick Start ### Prerequisites @@ -100,6 +107,7 @@ GuardDen is a comprehensive Discord moderation bot designed to protect your comm ```bash docker compose up -d ``` +4. Open the dashboard (if configured): `http://localhost:8080` ### Local Development @@ -260,12 +268,15 @@ Managed wordlists are synced weekly by default. You can override sources with ## Dashboard -The dashboard provides read-only visibility into moderation logs across all servers. +The dashboard provides owner-only visibility and configuration across all servers, including +servers, users, chats, moderation logs, analytics, and settings. 1. Configure Entra + Discord OAuth credentials in `.env`. -2. Build the frontend: `cd dashboard/frontend && npm install && npm run build`. -3. Run with Docker: `docker compose up dashboard`. -4. OAuth callbacks: +2. Run with Docker: `docker compose up -d dashboard` (builds the dashboard UI). +3. For local development without Docker, build the frontend: + `cd dashboard/frontend && npm install && npm run build` +4. Start the dashboard: `python -m guardden.dashboard` +5. OAuth callbacks: - Entra: `http://localhost:8080/auth/entra/callback` - Discord: `http://localhost:8080/auth/discord/callback` @@ -405,4 +416,4 @@ MIT License - see LICENSE file for details. - [x] Verification/captcha system - [x] Rate limiting - [ ] Voice channel moderation -- [ ] Web dashboard +- [x] Web dashboard diff --git a/dashboard/frontend/src/App.tsx b/dashboard/frontend/src/App.tsx index 9d27914..857f121 100644 --- a/dashboard/frontend/src/App.tsx +++ b/dashboard/frontend/src/App.tsx @@ -6,7 +6,9 @@ import { Routes, Route } from "react-router-dom"; import { Layout } from "./components/Layout"; import { Dashboard } from "./pages/Dashboard"; import { Analytics } from "./pages/Analytics"; +import { Servers } from "./pages/Servers"; import { Users } from "./pages/Users"; +import { Chats } from "./pages/Chats"; import { Moderation } from "./pages/Moderation"; import { Settings } from "./pages/Settings"; @@ -15,8 +17,10 @@ export default function App() { }> } /> + } /> } /> } /> + } /> } /> } /> diff --git a/dashboard/frontend/src/components/Layout.tsx b/dashboard/frontend/src/components/Layout.tsx index c2e89da..803900b 100644 --- a/dashboard/frontend/src/components/Layout.tsx +++ b/dashboard/frontend/src/components/Layout.tsx @@ -2,22 +2,24 @@ * Main dashboard layout with navigation */ -import { Link, Outlet, useLocation } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; -import { authApi } from '../services/api'; +import { Link, Outlet, useLocation } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { authApi } from "../services/api"; const navigation = [ - { name: 'Dashboard', href: '/' }, - { name: 'Analytics', href: '/analytics' }, - { name: 'Users', href: '/users' }, - { name: 'Moderation', href: '/moderation' }, - { name: 'Settings', href: '/settings' }, + { name: "Dashboard", href: "/" }, + { name: "Servers", href: "/servers" }, + { name: "Users", href: "/users" }, + { name: "Chats", href: "/chats" }, + { name: "Moderation", href: "/moderation" }, + { name: "Analytics", href: "/analytics" }, + { name: "Settings", href: "/settings" }, ]; export function Layout() { const location = useLocation(); const { data: me } = useQuery({ - queryKey: ['me'], + queryKey: ["me"], queryFn: authApi.getMe, }); @@ -38,8 +40,8 @@ export function Layout() { to={item.href} className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${ isActive - ? 'bg-gray-100 text-gray-900' - : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' + ? "bg-gray-100 text-gray-900" + : "text-gray-600 hover:bg-gray-50 hover:text-gray-900" }`} > {item.name} @@ -53,7 +55,7 @@ export function Layout() { {me?.owner ? (
- {me.entra ? 'โœ“ Entra' : ''} {me.discord ? 'โœ“ Discord' : ''} + {me.entra ? "โœ“ Entra" : ""} {me.discord ? "โœ“ Discord" : ""}

- Please authenticate with both Entra ID and Discord to access the dashboard. + Please authenticate with both Entra ID and Discord to access the + dashboard.

diff --git a/dashboard/frontend/src/pages/Chats.tsx b/dashboard/frontend/src/pages/Chats.tsx new file mode 100644 index 0000000..85e01a0 --- /dev/null +++ b/dashboard/frontend/src/pages/Chats.tsx @@ -0,0 +1,236 @@ +/** + * Message logs page + */ + +import { useQuery } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; +import { format } from 'date-fns'; +import { guildsApi, moderationApi } from '../services/api'; + +const ACTION_OPTIONS = [ + { value: '', label: 'All Actions' }, + { value: 'delete', label: 'Delete' }, + { value: 'warn', label: 'Warn' }, + { value: 'strike', label: 'Strike' }, + { value: 'timeout', label: 'Timeout' }, + { value: 'kick', label: 'Kick' }, + { value: 'ban', label: 'Ban' }, +]; + +export function Chats() { + const [selectedGuildId, setSelectedGuildId] = useState(); + const [searchTerm, setSearchTerm] = useState(''); + const [actionFilter, setActionFilter] = useState(''); + const [page, setPage] = useState(0); + const limit = 50; + + const { data: guilds } = useQuery({ + queryKey: ['guilds'], + queryFn: guildsApi.list, + }); + + const guildMap = useMemo(() => { + return new Map((guilds ?? []).map((guild) => [guild.id, guild.name])); + }, [guilds]); + + const { data: logs, isLoading } = useQuery({ + queryKey: ['chat-logs', selectedGuildId, page, searchTerm, actionFilter], + queryFn: () => + moderationApi.getLogs({ + guildId: selectedGuildId, + limit, + offset: page * limit, + messageOnly: true, + search: searchTerm || undefined, + action: actionFilter || undefined, + }), + }); + + const totalPages = logs ? Math.ceil(logs.total / limit) : 0; + const showGuildColumn = !selectedGuildId; + + return ( +
+ {/* Header */} +
+
+

Chats

+

Messages captured by moderation actions

+
+ +
+ + {/* Filters */} +
+
+
+ + { + setSearchTerm(e.target.value); + setPage(0); + }} + placeholder="Search message content, user, or reason..." + className="input" + /> +
+
+ + +
+
+
+ + {/* Table */} +
+ {isLoading ? ( +
Loading...
+ ) : logs && logs.items.length > 0 ? ( + <> +
+ + + + + {showGuildColumn && ( + + )} + + + + + + + + + {logs.items.map((log) => { + const messageLink = + log.channel_id && log.message_id + ? `https://discord.com/channels/${log.guild_id}/${log.channel_id}/${log.message_id}` + : null; + const guildName = guildMap.get(log.guild_id) ?? `Guild ${log.guild_id}`; + + return ( + + + {showGuildColumn && ( + + )} + + + + + + + ); + })} + +
TimeGuildUserActionMessageReasonType
+ {format(new Date(log.created_at), 'MMM d, yyyy HH:mm')} + {guildName}{log.target_name} + + {log.action} + + +
+ {log.message_content} +
+
+ {log.channel_id ? `Channel ${log.channel_id}` : 'Channel unknown'} + {messageLink ? ( + <> + {' '} + ยท{' '} + + Open in Discord + + + ) : null} +
+
+ {log.reason || 'โ€”'} + + + {log.is_automatic ? 'Auto' : 'Manual'} + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {page + 1} of {totalPages} + + +
+ )} + + ) : ( +
No chat logs found
+ )} +
+
+ ); +} diff --git a/dashboard/frontend/src/pages/Moderation.tsx b/dashboard/frontend/src/pages/Moderation.tsx index 8d78074..dbbc79c 100644 --- a/dashboard/frontend/src/pages/Moderation.tsx +++ b/dashboard/frontend/src/pages/Moderation.tsx @@ -2,10 +2,10 @@ * Moderation logs page (enhanced version of original) */ -import { useQuery } from '@tanstack/react-query'; -import { moderationApi, guildsApi } from '../services/api'; -import { useState } from 'react'; -import { format } from 'date-fns'; +import { useQuery } from "@tanstack/react-query"; +import { moderationApi, guildsApi } from "../services/api"; +import { useState } from "react"; +import { format } from "date-fns"; export function Moderation() { const [selectedGuildId, setSelectedGuildId] = useState(); @@ -13,13 +13,18 @@ export function Moderation() { const limit = 50; const { data: guilds } = useQuery({ - queryKey: ['guilds'], + queryKey: ["guilds"], queryFn: guildsApi.list, }); const { data: logs, isLoading } = useQuery({ - queryKey: ['moderation-logs', selectedGuildId, page], - queryFn: () => moderationApi.getLogs(selectedGuildId, limit, page * limit), + queryKey: ["moderation-logs", selectedGuildId, page], + queryFn: () => + moderationApi.getLogs({ + guildId: selectedGuildId, + limit, + offset: page * limit, + }), }); const totalPages = logs ? Math.ceil(logs.total / limit) : 0; @@ -35,9 +40,11 @@ export function Moderation() {

setSelectedGuildId(e.target.value ? Number(e.target.value) : undefined)} + value={selectedGuildId || ""} + onChange={(e) => + setSelectedGuildId( + e.target.value ? Number(e.target.value) : undefined, + ) + } className="input max-w-xs" > @@ -102,7 +139,9 @@ export function Settings() { {!selectedGuildId ? (
-

Please select a guild to configure settings

+

+ Please select a guild to configure settings +

) : ( <> @@ -110,16 +149,23 @@ export function Settings() {

General Settings

-
-
+
@@ -193,11 +247,14 @@ export function Settings() { {/* Automod Configuration */}

Automod Rules

-
+