Update dashboard and Docker compose
Some checks failed
CI/CD Pipeline / Security Scanning (push) Has been cancelled
CI/CD Pipeline / Tests (3.11) (push) Has been cancelled
CI/CD Pipeline / Tests (3.12) (push) Has been cancelled
CI/CD Pipeline / Build Docker Image (push) Has been cancelled
CI/CD Pipeline / Code Quality Checks (push) Has been cancelled

This commit is contained in:
2026-01-24 19:14:00 +01:00
parent a5811113f0
commit 574a07d127
17 changed files with 838 additions and 252 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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() {
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="servers" element={<Servers />} />
<Route path="analytics" element={<Analytics />} />
<Route path="users" element={<Users />} />
<Route path="chats" element={<Chats />} />
<Route path="moderation" element={<Moderation />} />
<Route path="settings" element={<Settings />} />
</Route>

View File

@@ -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 ? (
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">
{me.entra ? '✓ Entra' : ''} {me.discord ? '✓ Discord' : ''}
{me.entra ? "✓ Entra" : ""} {me.discord ? "✓ Discord" : ""}
</span>
<a
href="/auth/logout"
@@ -85,7 +87,8 @@ export function Layout() {
Authentication Required
</h2>
<p className="text-gray-600 mb-6">
Please authenticate with both Entra ID and Discord to access the dashboard.
Please authenticate with both Entra ID and Discord to access the
dashboard.
</p>
<div className="flex justify-center space-x-4">
<a href="/auth/entra/login" className="btn-secondary">

View File

@@ -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<number | undefined>();
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Chats</h1>
<p className="text-gray-600 mt-1">Messages captured by moderation actions</p>
</div>
<select
value={selectedGuildId || ''}
onChange={(e) => {
setSelectedGuildId(e.target.value ? Number(e.target.value) : undefined);
setPage(0);
}}
className="input max-w-xs"
>
<option value="">All Guilds</option>
{guilds?.map((guild) => (
<option key={guild.id} value={guild.id}>
{guild.name}
</option>
))}
</select>
</div>
{/* Filters */}
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Search Messages</label>
<input
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setPage(0);
}}
placeholder="Search message content, user, or reason..."
className="input"
/>
</div>
<div>
<label className="label">Action Filter</label>
<select
value={actionFilter}
onChange={(e) => {
setActionFilter(e.target.value);
setPage(0);
}}
className="input"
>
{ACTION_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
</div>
{/* Table */}
<div className="card">
{isLoading ? (
<div className="text-center py-12">Loading...</div>
) : logs && logs.items.length > 0 ? (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 font-semibold text-gray-700">Time</th>
{showGuildColumn && (
<th className="text-left py-3 px-4 font-semibold text-gray-700">Guild</th>
)}
<th className="text-left py-3 px-4 font-semibold text-gray-700">User</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Action</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Message</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Reason</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Type</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={log.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-3 px-4 text-sm text-gray-600">
{format(new Date(log.created_at), 'MMM d, yyyy HH:mm')}
</td>
{showGuildColumn && (
<td className="py-3 px-4 text-sm text-gray-600">{guildName}</td>
)}
<td className="py-3 px-4 font-medium">{log.target_name}</td>
<td className="py-3 px-4">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
log.action === 'ban'
? 'bg-red-100 text-red-800'
: log.action === 'kick'
? 'bg-orange-100 text-orange-800'
: log.action === 'timeout'
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{log.action}
</span>
</td>
<td className="py-3 px-4">
<div className="text-sm text-gray-900 whitespace-pre-wrap">
{log.message_content}
</div>
<div className="text-xs text-gray-500 mt-2">
{log.channel_id ? `Channel ${log.channel_id}` : 'Channel unknown'}
{messageLink ? (
<>
{' '}
·{' '}
<a
href={messageLink}
target="_blank"
rel="noreferrer"
className="text-primary-600 hover:text-primary-700"
>
Open in Discord
</a>
</>
) : null}
</div>
</td>
<td className="py-3 px-4 text-sm text-gray-600">
{log.reason || '—'}
</td>
<td className="py-3 px-4">
<span
className={`text-xs ${
log.is_automatic ? 'text-blue-600' : 'text-gray-600'
}`}
>
{log.is_automatic ? 'Auto' : 'Manual'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-between items-center mt-6 pt-4 border-t border-gray-200">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="btn-secondary disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {page + 1} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="btn-secondary disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
)}
</>
) : (
<div className="text-center py-12 text-gray-600">No chat logs found</div>
)}
</div>
</div>
);
}

View File

@@ -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<number | undefined>();
@@ -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() {
</p>
</div>
<select
value={selectedGuildId || ''}
value={selectedGuildId || ""}
onChange={(e) => {
setSelectedGuildId(e.target.value ? Number(e.target.value) : undefined);
setSelectedGuildId(
e.target.value ? Number(e.target.value) : undefined,
);
setPage(0);
}}
className="input max-w-xs"
@@ -61,47 +68,66 @@ export function Moderation() {
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 font-semibold text-gray-700">Time</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Target</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Action</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Moderator</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Reason</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Type</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Time
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Target
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Action
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Moderator
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Reason
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Type
</th>
</tr>
</thead>
<tbody>
{logs.items.map((log) => (
<tr key={log.id} className="border-b border-gray-100 hover:bg-gray-50">
<tr
key={log.id}
className="border-b border-gray-100 hover:bg-gray-50"
>
<td className="py-3 px-4 text-sm text-gray-600">
{format(new Date(log.created_at), 'MMM d, yyyy HH:mm')}
{format(new Date(log.created_at), "MMM d, yyyy HH:mm")}
</td>
<td className="py-3 px-4 font-medium">
{log.target_name}
</td>
<td className="py-3 px-4 font-medium">{log.target_name}</td>
<td className="py-3 px-4">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
log.action === 'ban'
? 'bg-red-100 text-red-800'
: log.action === 'kick'
? 'bg-orange-100 text-orange-800'
: log.action === 'timeout'
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'
log.action === "ban"
? "bg-red-100 text-red-800"
: log.action === "kick"
? "bg-orange-100 text-orange-800"
: log.action === "timeout"
? "bg-yellow-100 text-yellow-800"
: "bg-gray-100 text-gray-800"
}`}
>
{log.action}
</span>
</td>
<td className="py-3 px-4 text-sm">{log.moderator_name}</td>
<td className="py-3 px-4 text-sm">
{log.moderator_name}
</td>
<td className="py-3 px-4 text-sm text-gray-600">
{log.reason || '—'}
{log.reason || "—"}
</td>
<td className="py-3 px-4">
<span
className={`text-xs ${
log.is_automatic ? 'text-blue-600' : 'text-gray-600'
log.is_automatic ? "text-blue-600" : "text-gray-600"
}`}
>
{log.is_automatic ? 'Auto' : 'Manual'}
{log.is_automatic ? "Auto" : "Manual"}
</span>
</td>
</tr>
@@ -124,7 +150,9 @@ export function Moderation() {
Page {page + 1} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
onClick={() =>
setPage((p) => Math.min(totalPages - 1, p + 1))
}
disabled={page >= totalPages - 1}
className="btn-secondary disabled:opacity-50 disabled:cursor-not-allowed"
>
@@ -134,7 +162,9 @@ export function Moderation() {
)}
</>
) : (
<div className="text-center py-12 text-gray-600">No moderation logs found</div>
<div className="text-center py-12 text-gray-600">
No moderation logs found
</div>
)}
</div>
</div>

View File

@@ -0,0 +1,97 @@
/**
* Servers overview page
*/
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { guildsApi } from '../services/api';
export function Servers() {
const { data: guilds, isLoading } = useQuery({
queryKey: ['guilds'],
queryFn: guildsApi.list,
});
const total = guilds?.length ?? 0;
const premiumCount = guilds?.filter((guild) => guild.premium).length ?? 0;
const standardCount = total - premiumCount;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Servers</h1>
<p className="text-gray-600 mt-1">All servers that have added GuardDen</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="stat-card">
<div className="stat-label">Total Servers</div>
<div className="stat-value">{total}</div>
</div>
<div className="stat-card">
<div className="stat-label">Premium Servers</div>
<div className="stat-value">{premiumCount}</div>
</div>
<div className="stat-card">
<div className="stat-label">Standard Servers</div>
<div className="stat-value">{standardCount}</div>
</div>
</div>
{/* Table */}
<div className="card">
{isLoading ? (
<div className="text-center py-12">Loading...</div>
) : guilds && guilds.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 font-semibold text-gray-700">Server</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Server ID</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Owner ID</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Plan</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Actions</th>
</tr>
</thead>
<tbody>
{guilds.map((guild) => (
<tr key={guild.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-3 px-4 font-medium">{guild.name}</td>
<td className="py-3 px-4 text-sm text-gray-600">{guild.id}</td>
<td className="py-3 px-4 text-sm text-gray-600">{guild.owner_id}</td>
<td className="py-3 px-4">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
guild.premium
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-700'
}`}
>
{guild.premium ? 'Premium' : 'Standard'}
</span>
</td>
<td className="py-3 px-4">
<Link
to={`/settings?guild=${guild.id}`}
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Configure
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12 text-gray-600">No servers found</div>
)}
</div>
</div>
);
}

View File

@@ -2,44 +2,75 @@
* Guild settings page
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { guildsApi } from '../services/api';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import type { AutomodRuleConfig, GuildSettings as GuildSettingsType } from '../types/api';
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { guildsApi } from "../services/api";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useSearchParams } from "react-router-dom";
import type {
AutomodRuleConfig,
GuildSettings as GuildSettingsType,
} from "../types/api";
export function Settings() {
const [selectedGuildId, setSelectedGuildId] = useState<number | undefined>();
const [searchParams] = useSearchParams();
const [selectedGuildId, setSelectedGuildId] = useState<number | undefined>(
() => {
const guildParam = searchParams.get("guild");
if (!guildParam) {
return undefined;
}
const parsed = Number(guildParam);
return Number.isNaN(parsed) ? undefined : parsed;
},
);
const queryClient = useQueryClient();
const { data: guilds } = useQuery({
queryKey: ['guilds'],
queryKey: ["guilds"],
queryFn: guildsApi.list,
});
useEffect(() => {
const guildParam = searchParams.get("guild");
if (!guildParam) {
return;
}
const parsed = Number(guildParam);
if (!Number.isNaN(parsed) && parsed !== selectedGuildId) {
setSelectedGuildId(parsed);
}
}, [searchParams, selectedGuildId]);
const { data: settings } = useQuery({
queryKey: ['guild-settings', selectedGuildId],
queryKey: ["guild-settings", selectedGuildId],
queryFn: () => guildsApi.getSettings(selectedGuildId!),
enabled: !!selectedGuildId,
});
const { data: automodConfig } = useQuery({
queryKey: ['automod-config', selectedGuildId],
queryKey: ["automod-config", selectedGuildId],
queryFn: () => guildsApi.getAutomodConfig(selectedGuildId!),
enabled: !!selectedGuildId,
});
const updateSettingsMutation = useMutation({
mutationFn: (data: GuildSettingsType) => guildsApi.updateSettings(selectedGuildId!, data),
mutationFn: (data: GuildSettingsType) =>
guildsApi.updateSettings(selectedGuildId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['guild-settings', selectedGuildId] });
queryClient.invalidateQueries({
queryKey: ["guild-settings", selectedGuildId],
});
},
});
const updateAutomodMutation = useMutation({
mutationFn: (data: AutomodRuleConfig) => guildsApi.updateAutomodConfig(selectedGuildId!, data),
mutationFn: (data: AutomodRuleConfig) =>
guildsApi.updateAutomodConfig(selectedGuildId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['automod-config', selectedGuildId] });
queryClient.invalidateQueries({
queryKey: ["automod-config", selectedGuildId],
});
},
});
@@ -71,7 +102,7 @@ export function Settings() {
if (!selectedGuildId) return;
const blob = await guildsApi.exportConfig(selectedGuildId);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const a = document.createElement("a");
a.href = url;
a.download = `guild_${selectedGuildId}_config.json`;
a.click();
@@ -84,11 +115,17 @@ export function Settings() {
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Settings</h1>
<p className="text-gray-600 mt-1">Configure your guild settings and automod rules</p>
<p className="text-gray-600 mt-1">
Configure your guild settings and automod rules
</p>
</div>
<select
value={selectedGuildId || ''}
onChange={(e) => 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"
>
<option value="">Select a Guild</option>
@@ -102,7 +139,9 @@ export function Settings() {
{!selectedGuildId ? (
<div className="card text-center py-12">
<p className="text-gray-600">Please select a guild to configure settings</p>
<p className="text-gray-600">
Please select a guild to configure settings
</p>
</div>
) : (
<>
@@ -110,16 +149,23 @@ export function Settings() {
<div className="card">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold">General Settings</h2>
<button type="button" onClick={handleExport} className="btn-secondary text-sm">
<button
type="button"
onClick={handleExport}
className="btn-secondary text-sm"
>
Export Config
</button>
</div>
<form onSubmit={handleSubmitSettings(onSubmitSettings)} className="space-y-4">
<form
onSubmit={handleSubmitSettings(onSubmitSettings)}
className="space-y-4"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Command Prefix</label>
<input
{...registerSettings('prefix')}
{...registerSettings("prefix")}
type="text"
className="input"
placeholder="!"
@@ -128,7 +174,7 @@ export function Settings() {
<div>
<label className="label">Log Channel ID</label>
<input
{...registerSettings('log_channel_id')}
{...registerSettings("log_channel_id")}
type="number"
className="input"
placeholder="123456789"
@@ -137,7 +183,7 @@ export function Settings() {
<div>
<label className="label">Verification Role ID</label>
<input
{...registerSettings('verification_role_id')}
{...registerSettings("verification_role_id")}
type="number"
className="input"
placeholder="123456789"
@@ -146,7 +192,7 @@ export function Settings() {
<div>
<label className="label">AI Sensitivity (0-100)</label>
<input
{...registerSettings('ai_sensitivity')}
{...registerSettings("ai_sensitivity")}
type="number"
min="0"
max="100"
@@ -157,12 +203,16 @@ export function Settings() {
<div className="space-y-3">
<label className="flex items-center space-x-2">
<input {...registerSettings('automod_enabled')} type="checkbox" className="rounded" />
<input
{...registerSettings("automod_enabled")}
type="checkbox"
className="rounded"
/>
<span>Enable Automod</span>
</label>
<label className="flex items-center space-x-2">
<input
{...registerSettings('ai_moderation_enabled')}
{...registerSettings("ai_moderation_enabled")}
type="checkbox"
className="rounded"
/>
@@ -170,7 +220,7 @@ export function Settings() {
</label>
<label className="flex items-center space-x-2">
<input
{...registerSettings('verification_enabled')}
{...registerSettings("verification_enabled")}
type="checkbox"
className="rounded"
/>
@@ -182,9 +232,13 @@ export function Settings() {
<button
type="submit"
className="btn-primary"
disabled={!isSettingsDirty || updateSettingsMutation.isPending}
disabled={
!isSettingsDirty || updateSettingsMutation.isPending
}
>
{updateSettingsMutation.isPending ? 'Saving...' : 'Save Settings'}
{updateSettingsMutation.isPending
? "Saving..."
: "Save Settings"}
</button>
</div>
</form>
@@ -193,11 +247,14 @@ export function Settings() {
{/* Automod Configuration */}
<div className="card">
<h2 className="text-xl font-semibold mb-6">Automod Rules</h2>
<form onSubmit={handleSubmitAutomod(onSubmitAutomod)} className="space-y-4">
<form
onSubmit={handleSubmitAutomod(onSubmitAutomod)}
className="space-y-4"
>
<div className="space-y-3">
<label className="flex items-center space-x-2">
<input
{...registerAutomod('banned_words_enabled')}
{...registerAutomod("banned_words_enabled")}
type="checkbox"
className="rounded"
/>
@@ -205,7 +262,7 @@ export function Settings() {
</label>
<label className="flex items-center space-x-2">
<input
{...registerAutomod('scam_detection_enabled')}
{...registerAutomod("scam_detection_enabled")}
type="checkbox"
className="rounded"
/>
@@ -213,7 +270,7 @@ export function Settings() {
</label>
<label className="flex items-center space-x-2">
<input
{...registerAutomod('spam_detection_enabled')}
{...registerAutomod("spam_detection_enabled")}
type="checkbox"
className="rounded"
/>
@@ -221,7 +278,7 @@ export function Settings() {
</label>
<label className="flex items-center space-x-2">
<input
{...registerAutomod('invite_filter_enabled')}
{...registerAutomod("invite_filter_enabled")}
type="checkbox"
className="rounded"
/>
@@ -233,7 +290,7 @@ export function Settings() {
<div>
<label className="label">Max Mentions</label>
<input
{...registerAutomod('max_mentions')}
{...registerAutomod("max_mentions")}
type="number"
min="1"
max="20"
@@ -243,7 +300,7 @@ export function Settings() {
<div>
<label className="label">Max Emojis</label>
<input
{...registerAutomod('max_emojis')}
{...registerAutomod("max_emojis")}
type="number"
min="1"
max="50"
@@ -253,7 +310,7 @@ export function Settings() {
<div>
<label className="label">Spam Threshold</label>
<input
{...registerAutomod('spam_threshold')}
{...registerAutomod("spam_threshold")}
type="number"
min="1"
max="20"
@@ -268,7 +325,9 @@ export function Settings() {
className="btn-primary"
disabled={!isAutomodDirty || updateAutomodMutation.isPending}
>
{updateAutomodMutation.isPending ? 'Saving...' : 'Save Automod Config'}
{updateAutomodMutation.isPending
? "Saving..."
: "Save Automod Config"}
</button>
</div>
</form>

View File

@@ -2,40 +2,53 @@
* User management page
*/
import { useQuery } from '@tanstack/react-query';
import { usersApi, guildsApi } from '../services/api';
import { useState } from 'react';
import { format } from 'date-fns';
import { useQuery } from "@tanstack/react-query";
import { usersApi, guildsApi } from "../services/api";
import { useState } from "react";
import { format } from "date-fns";
export function Users() {
const [selectedGuildId, setSelectedGuildId] = useState<number | undefined>();
const [searchTerm, setSearchTerm] = useState('');
const [searchTerm, setSearchTerm] = useState("");
const [minStrikes, setMinStrikes] = useState("");
const { data: guilds } = useQuery({
queryKey: ['guilds'],
queryKey: ["guilds"],
queryFn: guildsApi.list,
});
const { data: users, isLoading } = useQuery({
queryKey: ['users', selectedGuildId, searchTerm],
queryFn: () => usersApi.search(selectedGuildId!, searchTerm || undefined),
enabled: !!selectedGuildId,
queryKey: ["users", selectedGuildId, searchTerm, minStrikes],
queryFn: () =>
usersApi.search(
selectedGuildId,
searchTerm || undefined,
minStrikes ? Number(minStrikes) : undefined,
),
});
const showGuildColumn = !selectedGuildId;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">User Management</h1>
<p className="text-gray-600 mt-1">Search and manage users across your servers</p>
<p className="text-gray-600 mt-1">
Search and manage users across your servers
</p>
</div>
<select
value={selectedGuildId || ''}
onChange={(e) => 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"
>
<option value="">Select a Guild</option>
<option value="">All Guilds</option>
{guilds?.map((guild) => (
<option key={guild.id} value={guild.id}>
{guild.name}
@@ -44,14 +57,10 @@ export function Users() {
</select>
</div>
{!selectedGuildId ? (
<div className="card text-center py-12">
<p className="text-gray-600">Please select a guild to search users</p>
</div>
) : (
<>
{/* Search */}
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Search Users</label>
<input
type="text"
@@ -61,6 +70,19 @@ export function Users() {
className="input"
/>
</div>
<div>
<label className="label">Minimum Strikes</label>
<input
type="number"
min="0"
value={minStrikes}
onChange={(e) => setMinStrikes(e.target.value)}
placeholder="0"
className="input"
/>
</div>
</div>
</div>
{/* Results */}
<div className="card">
@@ -71,38 +93,71 @@ export function Users() {
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 font-semibold text-gray-700">Username</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Strikes</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Warnings</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Kicks</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Bans</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Timeouts</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">First Seen</th>
{showGuildColumn && (
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Guild
</th>
)}
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Username
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Strikes
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Warnings
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Kicks
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Bans
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Timeouts
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
First Seen
</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.user_id} className="border-b border-gray-100 hover:bg-gray-50">
<tr
key={`${user.guild_id}-${user.user_id}`}
className="border-b border-gray-100 hover:bg-gray-50"
>
{showGuildColumn && (
<td className="py-3 px-4 text-sm text-gray-600">
{user.guild_name}
</td>
)}
<td className="py-3 px-4 font-medium">{user.username}</td>
<td className="py-3 px-4">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.strike_count > 5
? 'bg-red-100 text-red-800'
? "bg-red-100 text-red-800"
: user.strike_count > 2
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'
? "bg-yellow-100 text-yellow-800"
: "bg-gray-100 text-gray-800"
}`}
>
{user.strike_count}
</span>
</td>
<td className="py-3 px-4 text-center">{user.total_warnings}</td>
<td className="py-3 px-4 text-center">{user.total_kicks}</td>
<td className="py-3 px-4 text-center">
{user.total_warnings}
</td>
<td className="py-3 px-4 text-center">
{user.total_kicks}
</td>
<td className="py-3 px-4 text-center">{user.total_bans}</td>
<td className="py-3 px-4 text-center">{user.total_timeouts}</td>
<td className="py-3 px-4 text-center">
{user.total_timeouts}
</td>
<td className="py-3 px-4 text-sm text-gray-600">
{format(new Date(user.first_seen), 'MMM d, yyyy')}
{format(new Date(user.first_seen), "MMM d, yyyy")}
</td>
</tr>
))}
@@ -111,12 +166,12 @@ export function Users() {
</div>
) : (
<div className="text-center py-12 text-gray-600">
{searchTerm ? 'No users found matching your search' : 'Enter a username to search'}
{searchTerm || minStrikes
? "No users found matching your filters"
: "No user activity found"}
</div>
)}
</div>
</>
)}
</div>
);
}

View File

@@ -13,16 +13,16 @@ import type {
PaginatedLogs,
UserNote,
UserProfile,
} from '../types/api';
} from "../types/api";
const BASE_URL = '';
const BASE_URL = "";
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(BASE_URL + url, {
...options,
credentials: 'include',
credentials: "include",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
...options?.headers,
},
});
@@ -37,38 +37,66 @@ async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
// Auth API
export const authApi = {
getMe: () => fetchJson<Me>('/api/me'),
getMe: () => fetchJson<Me>("/api/me"),
};
// Guilds API
export const guildsApi = {
list: () => fetchJson<Guild[]>('/api/guilds'),
list: () => fetchJson<Guild[]>("/api/guilds"),
getSettings: (guildId: number) =>
fetchJson<GuildSettings>(`/api/guilds/${guildId}/settings`),
updateSettings: (guildId: number, settings: GuildSettings) =>
fetchJson<GuildSettings>(`/api/guilds/${guildId}/settings`, {
method: 'PUT',
method: "PUT",
body: JSON.stringify(settings),
}),
getAutomodConfig: (guildId: number) =>
fetchJson<AutomodRuleConfig>(`/api/guilds/${guildId}/automod`),
updateAutomodConfig: (guildId: number, config: AutomodRuleConfig) =>
fetchJson<AutomodRuleConfig>(`/api/guilds/${guildId}/automod`, {
method: 'PUT',
method: "PUT",
body: JSON.stringify(config),
}),
exportConfig: (guildId: number) =>
fetch(`${BASE_URL}/api/guilds/${guildId}/export`, {
credentials: 'include',
credentials: "include",
}).then((res) => res.blob()),
};
// Moderation API
type ModerationLogQuery = {
guildId?: number;
limit?: number;
offset?: number;
action?: string;
messageOnly?: boolean;
search?: string;
};
export const moderationApi = {
getLogs: (guildId?: number, limit = 50, offset = 0) => {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
getLogs: ({
guildId,
limit = 50,
offset = 0,
action,
messageOnly,
search,
}: ModerationLogQuery = {}) => {
const params = new URLSearchParams({
limit: String(limit),
offset: String(offset),
});
if (guildId) {
params.set('guild_id', String(guildId));
params.set("guild_id", String(guildId));
}
if (action) {
params.set("action", action);
}
if (messageOnly) {
params.set("message_only", "true");
}
if (search) {
params.set("search", search);
}
return fetchJson<PaginatedLogs>(`/api/moderation/logs?${params}`);
},
@@ -79,28 +107,38 @@ export const analyticsApi = {
getSummary: (guildId?: number, days = 7) => {
const params = new URLSearchParams({ days: String(days) });
if (guildId) {
params.set('guild_id', String(guildId));
params.set("guild_id", String(guildId));
}
return fetchJson<AnalyticsSummary>(`/api/analytics/summary?${params}`);
},
getModerationStats: (guildId?: number, days = 30) => {
const params = new URLSearchParams({ days: String(days) });
if (guildId) {
params.set('guild_id', String(guildId));
params.set("guild_id", String(guildId));
}
return fetchJson<ModerationStats>(`/api/analytics/moderation-stats?${params}`);
return fetchJson<ModerationStats>(
`/api/analytics/moderation-stats?${params}`,
);
},
};
// Users API
export const usersApi = {
search: (guildId: number, username?: string, minStrikes?: number, limit = 50) => {
const params = new URLSearchParams({ guild_id: String(guildId), limit: String(limit) });
search: (
guildId?: number,
username?: string,
minStrikes?: number,
limit = 50,
) => {
const params = new URLSearchParams({ limit: String(limit) });
if (guildId) {
params.set("guild_id", String(guildId));
}
if (username) {
params.set('username', username);
params.set("username", username);
}
if (minStrikes !== undefined) {
params.set('min_strikes', String(minStrikes));
params.set("min_strikes", String(minStrikes));
}
return fetchJson<UserProfile[]>(`/api/users/search?${params}`);
},
@@ -110,11 +148,14 @@ export const usersApi = {
fetchJson<UserNote[]>(`/api/users/${userId}/notes?guild_id=${guildId}`),
createNote: (userId: number, guildId: number, note: CreateUserNote) =>
fetchJson<UserNote>(`/api/users/${userId}/notes?guild_id=${guildId}`, {
method: 'POST',
method: "POST",
body: JSON.stringify(note),
}),
deleteNote: (userId: number, noteId: number, guildId: number) =>
fetchJson<void>(`/api/users/${userId}/notes/${noteId}?guild_id=${guildId}`, {
method: 'DELETE',
}),
fetchJson<void>(
`/api/users/${userId}/notes/${noteId}?guild_id=${guildId}`,
{
method: "DELETE",
},
),
};

View File

@@ -79,6 +79,8 @@ export interface AnalyticsSummary {
// User management types
export interface UserProfile {
guild_id: number;
guild_name: string;
user_id: number;
username: string;
strike_count: number;

View File

@@ -34,6 +34,8 @@ services:
dashboard:
build:
context: .
dockerfile: Dockerfile
target: development
image: guardden-dashboard:dev
container_name: guardden-dashboard-dev
@@ -45,6 +47,8 @@ services:
# Mount source code for hot reloading
- ./src:/app/src:ro
- ./migrations:/app/migrations:ro
# Serve locally built dashboard assets (optional)
- ./dashboard/frontend/dist:/app/dashboard/frontend/dist:ro
command: ["python", "-m", "guardden.dashboard", "--reload", "--host", "0.0.0.0"]
ports:
- "8080:8000"

View File

@@ -34,7 +34,7 @@ services:
dashboard:
build:
context: .
target: runtime
dockerfile: dashboard/Dockerfile
image: guardden-dashboard:latest
container_name: guardden-dashboard
restart: unless-stopped
@@ -123,22 +123,6 @@ services:
networks:
- guardden
grafana:
image: grafana/grafana:latest
container_name: guardden-grafana
restart: unless-stopped
profiles:
- monitoring-grafana
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
ports:
- "${GRAFANA_PORT:-3000}:3000"
volumes:
- grafana_data:/var/lib/grafana
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
networks:
- guardden
networks:
guardden:
driver: bridge
@@ -150,4 +134,3 @@ volumes:
guardden_data:
guardden_logs:
prometheus_data:
grafana_data:

View File

@@ -170,6 +170,9 @@ build_docker() {
echo "🐳 Building base image..."
docker build -t guardden:latest .
echo "Building dashboard image..."
docker build -f dashboard/Dockerfile -t guardden-dashboard:latest .
echo "🧠 Building image with AI dependencies..."
docker build --build-arg INSTALL_AI=true -t guardden:ai .

View File

@@ -3,7 +3,7 @@
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, select
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from guardden.dashboard.auth import require_owner
@@ -27,7 +27,9 @@ def create_api_router(
def require_owner_dep(request: Request) -> None:
require_owner(settings, request)
@router.get("/guilds", response_model=list[GuildSummary], dependencies=[Depends(require_owner_dep)])
@router.get(
"/guilds", response_model=list[GuildSummary], dependencies=[Depends(require_owner_dep)]
)
async def list_guilds(
session: AsyncSession = Depends(get_session),
) -> list[GuildSummary]:
@@ -47,6 +49,9 @@ def create_api_router(
guild_id: int | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
action: str | None = Query(default=None),
message_only: bool = Query(default=False),
search: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
) -> PaginatedLogs:
query = select(ModerationLog)
@@ -55,6 +60,25 @@ def create_api_router(
query = query.where(ModerationLog.guild_id == guild_id)
count_query = count_query.where(ModerationLog.guild_id == guild_id)
if action:
query = query.where(ModerationLog.action == action)
count_query = count_query.where(ModerationLog.action == action)
if message_only:
query = query.where(ModerationLog.message_content.is_not(None))
count_query = count_query.where(ModerationLog.message_content.is_not(None))
if search:
like = f"%{search}%"
search_filter = or_(
ModerationLog.target_name.ilike(like),
ModerationLog.moderator_name.ilike(like),
ModerationLog.reason.ilike(like),
ModerationLog.message_content.ilike(like),
)
query = query.where(search_filter)
count_query = count_query.where(search_filter)
query = query.order_by(ModerationLog.created_at.desc()).offset(offset).limit(limit)
total_result = await session.execute(count_query)
total = int(total_result.scalar() or 0)

View File

@@ -71,6 +71,8 @@ class AnalyticsSummary(BaseModel):
# User Management Schemas
class UserProfile(BaseModel):
guild_id: int
guild_name: str
user_id: int
username: str
strike_count: int

View File

@@ -11,7 +11,7 @@ from guardden.dashboard.auth import require_owner
from guardden.dashboard.config import DashboardSettings
from guardden.dashboard.db import DashboardDatabase
from guardden.dashboard.schemas import CreateUserNote, UserNote, UserProfile
from guardden.models import ModerationLog, UserActivity
from guardden.models import Guild, ModerationLog, UserActivity
from guardden.models import UserNote as UserNoteModel
@@ -35,14 +35,16 @@ def create_users_router(
dependencies=[Depends(require_owner_dep)],
)
async def search_users(
guild_id: int = Query(...),
guild_id: int | None = Query(default=None),
username: str | None = Query(default=None),
min_strikes: int | None = Query(default=None, ge=0),
limit: int = Query(default=50, ge=1, le=200),
session: AsyncSession = Depends(get_session),
) -> list[UserProfile]:
"""Search for users in a guild with optional filters."""
query = select(UserActivity).where(UserActivity.guild_id == guild_id)
"""Search for users with optional guild and filter parameters."""
query = select(UserActivity, Guild.name).join(Guild, Guild.id == UserActivity.guild_id)
if guild_id:
query = query.where(UserActivity.guild_id == guild_id)
if username:
query = query.where(UserActivity.username.ilike(f"%{username}%"))
@@ -53,14 +55,14 @@ def create_users_router(
query = query.order_by(UserActivity.last_seen.desc()).limit(limit)
result = await session.execute(query)
users = result.scalars().all()
users = result.all()
# Get last moderation action for each user
profiles = []
for user in users:
for user, guild_name in users:
last_action_query = (
select(ModerationLog.created_at)
.where(ModerationLog.guild_id == guild_id)
.where(ModerationLog.guild_id == user.guild_id)
.where(ModerationLog.target_id == user.user_id)
.order_by(ModerationLog.created_at.desc())
.limit(1)
@@ -70,6 +72,8 @@ def create_users_router(
profiles.append(
UserProfile(
guild_id=user.guild_id,
guild_name=guild_name,
user_id=user.user_id,
username=user.username,
strike_count=user.strike_count,
@@ -96,19 +100,21 @@ def create_users_router(
) -> UserProfile:
"""Get detailed profile for a specific user."""
query = (
select(UserActivity)
select(UserActivity, Guild.name)
.join(Guild, Guild.id == UserActivity.guild_id)
.where(UserActivity.guild_id == guild_id)
.where(UserActivity.user_id == user_id)
)
result = await session.execute(query)
user = result.scalar_one_or_none()
row = result.one_or_none()
if not user:
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found in this guild",
)
user, guild_name = row
# Get last moderation action
last_action_query = (
@@ -122,6 +128,8 @@ def create_users_router(
last_action = last_action_result.scalar()
return UserProfile(
guild_id=user.guild_id,
guild_name=guild_name,
user_id=user.user_id,
username=user.username,
strike_count=user.strike_count,