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 ### Docker Operations
```bash ```bash
./scripts/dev.sh build # Build Docker images ./scripts/dev.sh build # Build Docker images (bot + dashboard)
``` ```
## 🐳 Development Services ## 🐳 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 | | Redis Commander | http://localhost:8081 | Redis administration |
| MailHog | http://localhost:8025 | Email testing | | 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 ## 🧪 Testing
### Running Tests ### Running Tests

View File

@@ -35,6 +35,13 @@ GuardDen is a comprehensive Discord moderation bot designed to protect your comm
- Ban/unban events - Ban/unban events
- All moderation actions - 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 ## Quick Start
### Prerequisites ### Prerequisites
@@ -100,6 +107,7 @@ GuardDen is a comprehensive Discord moderation bot designed to protect your comm
```bash ```bash
docker compose up -d docker compose up -d
``` ```
4. Open the dashboard (if configured): `http://localhost:8080`
### Local Development ### Local Development
@@ -260,12 +268,15 @@ Managed wordlists are synced weekly by default. You can override sources with
## Dashboard ## 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`. 1. Configure Entra + Discord OAuth credentials in `.env`.
2. Build the frontend: `cd dashboard/frontend && npm install && npm run build`. 2. Run with Docker: `docker compose up -d dashboard` (builds the dashboard UI).
3. Run with Docker: `docker compose up dashboard`. 3. For local development without Docker, build the frontend:
4. OAuth callbacks: `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` - Entra: `http://localhost:8080/auth/entra/callback`
- Discord: `http://localhost:8080/auth/discord/callback` - Discord: `http://localhost:8080/auth/discord/callback`
@@ -405,4 +416,4 @@ MIT License - see LICENSE file for details.
- [x] Verification/captcha system - [x] Verification/captcha system
- [x] Rate limiting - [x] Rate limiting
- [ ] Voice channel moderation - [ ] 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 { Layout } from "./components/Layout";
import { Dashboard } from "./pages/Dashboard"; import { Dashboard } from "./pages/Dashboard";
import { Analytics } from "./pages/Analytics"; import { Analytics } from "./pages/Analytics";
import { Servers } from "./pages/Servers";
import { Users } from "./pages/Users"; import { Users } from "./pages/Users";
import { Chats } from "./pages/Chats";
import { Moderation } from "./pages/Moderation"; import { Moderation } from "./pages/Moderation";
import { Settings } from "./pages/Settings"; import { Settings } from "./pages/Settings";
@@ -15,8 +17,10 @@ export default function App() {
<Routes> <Routes>
<Route path="/" element={<Layout />}> <Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="servers" element={<Servers />} />
<Route path="analytics" element={<Analytics />} /> <Route path="analytics" element={<Analytics />} />
<Route path="users" element={<Users />} /> <Route path="users" element={<Users />} />
<Route path="chats" element={<Chats />} />
<Route path="moderation" element={<Moderation />} /> <Route path="moderation" element={<Moderation />} />
<Route path="settings" element={<Settings />} /> <Route path="settings" element={<Settings />} />
</Route> </Route>

View File

@@ -2,22 +2,24 @@
* Main dashboard layout with navigation * Main dashboard layout with navigation
*/ */
import { Link, Outlet, useLocation } from 'react-router-dom'; import { Link, Outlet, useLocation } from "react-router-dom";
import { useQuery } from '@tanstack/react-query'; import { useQuery } from "@tanstack/react-query";
import { authApi } from '../services/api'; import { authApi } from "../services/api";
const navigation = [ const navigation = [
{ name: 'Dashboard', href: '/' }, { name: "Dashboard", href: "/" },
{ name: 'Analytics', href: '/analytics' }, { name: "Servers", href: "/servers" },
{ name: 'Users', href: '/users' }, { name: "Users", href: "/users" },
{ name: 'Moderation', href: '/moderation' }, { name: "Chats", href: "/chats" },
{ name: 'Settings', href: '/settings' }, { name: "Moderation", href: "/moderation" },
{ name: "Analytics", href: "/analytics" },
{ name: "Settings", href: "/settings" },
]; ];
export function Layout() { export function Layout() {
const location = useLocation(); const location = useLocation();
const { data: me } = useQuery({ const { data: me } = useQuery({
queryKey: ['me'], queryKey: ["me"],
queryFn: authApi.getMe, queryFn: authApi.getMe,
}); });
@@ -38,8 +40,8 @@ export function Layout() {
to={item.href} to={item.href}
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${ className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive isActive
? 'bg-gray-100 text-gray-900' ? "bg-gray-100 text-gray-900"
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' : "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
}`} }`}
> >
{item.name} {item.name}
@@ -53,7 +55,7 @@ export function Layout() {
{me?.owner ? ( {me?.owner ? (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600">
{me.entra ? '✓ Entra' : ''} {me.discord ? '✓ Discord' : ''} {me.entra ? "✓ Entra" : ""} {me.discord ? "✓ Discord" : ""}
</span> </span>
<a <a
href="/auth/logout" href="/auth/logout"
@@ -85,7 +87,8 @@ export function Layout() {
Authentication Required Authentication Required
</h2> </h2>
<p className="text-gray-600 mb-6"> <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> </p>
<div className="flex justify-center space-x-4"> <div className="flex justify-center space-x-4">
<a href="/auth/entra/login" className="btn-secondary"> <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) * Moderation logs page (enhanced version of original)
*/ */
import { useQuery } from '@tanstack/react-query'; import { useQuery } from "@tanstack/react-query";
import { moderationApi, guildsApi } from '../services/api'; import { moderationApi, guildsApi } from "../services/api";
import { useState } from 'react'; import { useState } from "react";
import { format } from 'date-fns'; import { format } from "date-fns";
export function Moderation() { export function Moderation() {
const [selectedGuildId, setSelectedGuildId] = useState<number | undefined>(); const [selectedGuildId, setSelectedGuildId] = useState<number | undefined>();
@@ -13,13 +13,18 @@ export function Moderation() {
const limit = 50; const limit = 50;
const { data: guilds } = useQuery({ const { data: guilds } = useQuery({
queryKey: ['guilds'], queryKey: ["guilds"],
queryFn: guildsApi.list, queryFn: guildsApi.list,
}); });
const { data: logs, isLoading } = useQuery({ const { data: logs, isLoading } = useQuery({
queryKey: ['moderation-logs', selectedGuildId, page], queryKey: ["moderation-logs", selectedGuildId, page],
queryFn: () => moderationApi.getLogs(selectedGuildId, limit, page * limit), queryFn: () =>
moderationApi.getLogs({
guildId: selectedGuildId,
limit,
offset: page * limit,
}),
}); });
const totalPages = logs ? Math.ceil(logs.total / limit) : 0; const totalPages = logs ? Math.ceil(logs.total / limit) : 0;
@@ -35,9 +40,11 @@ export function Moderation() {
</p> </p>
</div> </div>
<select <select
value={selectedGuildId || ''} value={selectedGuildId || ""}
onChange={(e) => { onChange={(e) => {
setSelectedGuildId(e.target.value ? Number(e.target.value) : undefined); setSelectedGuildId(
e.target.value ? Number(e.target.value) : undefined,
);
setPage(0); setPage(0);
}} }}
className="input max-w-xs" className="input max-w-xs"
@@ -61,47 +68,66 @@ export function Moderation() {
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-gray-200"> <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">
<th className="text-left py-3 px-4 font-semibold text-gray-700">Target</th> Time
<th className="text-left py-3 px-4 font-semibold text-gray-700">Action</th> </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">
<th className="text-left py-3 px-4 font-semibold text-gray-700">Reason</th> Target
<th className="text-left py-3 px-4 font-semibold text-gray-700">Type</th> </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> </tr>
</thead> </thead>
<tbody> <tbody>
{logs.items.map((log) => ( {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"> <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>
<td className="py-3 px-4 font-medium">{log.target_name}</td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<span <span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${ className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
log.action === 'ban' log.action === "ban"
? 'bg-red-100 text-red-800' ? "bg-red-100 text-red-800"
: log.action === 'kick' : log.action === "kick"
? 'bg-orange-100 text-orange-800' ? "bg-orange-100 text-orange-800"
: log.action === 'timeout' : log.action === "timeout"
? 'bg-yellow-100 text-yellow-800' ? "bg-yellow-100 text-yellow-800"
: 'bg-gray-100 text-gray-800' : "bg-gray-100 text-gray-800"
}`} }`}
> >
{log.action} {log.action}
</span> </span>
</td> </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"> <td className="py-3 px-4 text-sm text-gray-600">
{log.reason || '—'} {log.reason || "—"}
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<span <span
className={`text-xs ${ 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> </span>
</td> </td>
</tr> </tr>
@@ -124,7 +150,9 @@ export function Moderation() {
Page {page + 1} of {totalPages} Page {page + 1} of {totalPages}
</span> </span>
<button <button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} onClick={() =>
setPage((p) => Math.min(totalPages - 1, p + 1))
}
disabled={page >= totalPages - 1} disabled={page >= totalPages - 1}
className="btn-secondary disabled:opacity-50 disabled:cursor-not-allowed" 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>
</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 * Guild settings page
*/ */
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { guildsApi } from '../services/api'; import { guildsApi } from "../services/api";
import { useState } from 'react'; import { useEffect, useState } from "react";
import { useForm } from 'react-hook-form'; import { useForm } from "react-hook-form";
import type { AutomodRuleConfig, GuildSettings as GuildSettingsType } from '../types/api'; import { useSearchParams } from "react-router-dom";
import type {
AutomodRuleConfig,
GuildSettings as GuildSettingsType,
} from "../types/api";
export function Settings() { 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 queryClient = useQueryClient();
const { data: guilds } = useQuery({ const { data: guilds } = useQuery({
queryKey: ['guilds'], queryKey: ["guilds"],
queryFn: guildsApi.list, 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({ const { data: settings } = useQuery({
queryKey: ['guild-settings', selectedGuildId], queryKey: ["guild-settings", selectedGuildId],
queryFn: () => guildsApi.getSettings(selectedGuildId!), queryFn: () => guildsApi.getSettings(selectedGuildId!),
enabled: !!selectedGuildId, enabled: !!selectedGuildId,
}); });
const { data: automodConfig } = useQuery({ const { data: automodConfig } = useQuery({
queryKey: ['automod-config', selectedGuildId], queryKey: ["automod-config", selectedGuildId],
queryFn: () => guildsApi.getAutomodConfig(selectedGuildId!), queryFn: () => guildsApi.getAutomodConfig(selectedGuildId!),
enabled: !!selectedGuildId, enabled: !!selectedGuildId,
}); });
const updateSettingsMutation = useMutation({ const updateSettingsMutation = useMutation({
mutationFn: (data: GuildSettingsType) => guildsApi.updateSettings(selectedGuildId!, data), mutationFn: (data: GuildSettingsType) =>
guildsApi.updateSettings(selectedGuildId!, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['guild-settings', selectedGuildId] }); queryClient.invalidateQueries({
queryKey: ["guild-settings", selectedGuildId],
});
}, },
}); });
const updateAutomodMutation = useMutation({ const updateAutomodMutation = useMutation({
mutationFn: (data: AutomodRuleConfig) => guildsApi.updateAutomodConfig(selectedGuildId!, data), mutationFn: (data: AutomodRuleConfig) =>
guildsApi.updateAutomodConfig(selectedGuildId!, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['automod-config', selectedGuildId] }); queryClient.invalidateQueries({
queryKey: ["automod-config", selectedGuildId],
});
}, },
}); });
@@ -71,7 +102,7 @@ export function Settings() {
if (!selectedGuildId) return; if (!selectedGuildId) return;
const blob = await guildsApi.exportConfig(selectedGuildId); const blob = await guildsApi.exportConfig(selectedGuildId);
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `guild_${selectedGuildId}_config.json`; a.download = `guild_${selectedGuildId}_config.json`;
a.click(); a.click();
@@ -84,11 +115,17 @@ export function Settings() {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900">Settings</h1> <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> </div>
<select <select
value={selectedGuildId || ''} value={selectedGuildId || ""}
onChange={(e) => setSelectedGuildId(e.target.value ? Number(e.target.value) : undefined)} onChange={(e) =>
setSelectedGuildId(
e.target.value ? Number(e.target.value) : undefined,
)
}
className="input max-w-xs" className="input max-w-xs"
> >
<option value="">Select a Guild</option> <option value="">Select a Guild</option>
@@ -102,7 +139,9 @@ export function Settings() {
{!selectedGuildId ? ( {!selectedGuildId ? (
<div className="card text-center py-12"> <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> </div>
) : ( ) : (
<> <>
@@ -110,16 +149,23 @@ export function Settings() {
<div className="card"> <div className="card">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold">General Settings</h2> <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 Export Config
</button> </button>
</div> </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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="label">Command Prefix</label> <label className="label">Command Prefix</label>
<input <input
{...registerSettings('prefix')} {...registerSettings("prefix")}
type="text" type="text"
className="input" className="input"
placeholder="!" placeholder="!"
@@ -128,7 +174,7 @@ export function Settings() {
<div> <div>
<label className="label">Log Channel ID</label> <label className="label">Log Channel ID</label>
<input <input
{...registerSettings('log_channel_id')} {...registerSettings("log_channel_id")}
type="number" type="number"
className="input" className="input"
placeholder="123456789" placeholder="123456789"
@@ -137,7 +183,7 @@ export function Settings() {
<div> <div>
<label className="label">Verification Role ID</label> <label className="label">Verification Role ID</label>
<input <input
{...registerSettings('verification_role_id')} {...registerSettings("verification_role_id")}
type="number" type="number"
className="input" className="input"
placeholder="123456789" placeholder="123456789"
@@ -146,7 +192,7 @@ export function Settings() {
<div> <div>
<label className="label">AI Sensitivity (0-100)</label> <label className="label">AI Sensitivity (0-100)</label>
<input <input
{...registerSettings('ai_sensitivity')} {...registerSettings("ai_sensitivity")}
type="number" type="number"
min="0" min="0"
max="100" max="100"
@@ -157,12 +203,16 @@ export function Settings() {
<div className="space-y-3"> <div className="space-y-3">
<label className="flex items-center space-x-2"> <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> <span>Enable Automod</span>
</label> </label>
<label className="flex items-center space-x-2"> <label className="flex items-center space-x-2">
<input <input
{...registerSettings('ai_moderation_enabled')} {...registerSettings("ai_moderation_enabled")}
type="checkbox" type="checkbox"
className="rounded" className="rounded"
/> />
@@ -170,7 +220,7 @@ export function Settings() {
</label> </label>
<label className="flex items-center space-x-2"> <label className="flex items-center space-x-2">
<input <input
{...registerSettings('verification_enabled')} {...registerSettings("verification_enabled")}
type="checkbox" type="checkbox"
className="rounded" className="rounded"
/> />
@@ -182,9 +232,13 @@ export function Settings() {
<button <button
type="submit" type="submit"
className="btn-primary" className="btn-primary"
disabled={!isSettingsDirty || updateSettingsMutation.isPending} disabled={
!isSettingsDirty || updateSettingsMutation.isPending
}
> >
{updateSettingsMutation.isPending ? 'Saving...' : 'Save Settings'} {updateSettingsMutation.isPending
? "Saving..."
: "Save Settings"}
</button> </button>
</div> </div>
</form> </form>
@@ -193,11 +247,14 @@ export function Settings() {
{/* Automod Configuration */} {/* Automod Configuration */}
<div className="card"> <div className="card">
<h2 className="text-xl font-semibold mb-6">Automod Rules</h2> <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"> <div className="space-y-3">
<label className="flex items-center space-x-2"> <label className="flex items-center space-x-2">
<input <input
{...registerAutomod('banned_words_enabled')} {...registerAutomod("banned_words_enabled")}
type="checkbox" type="checkbox"
className="rounded" className="rounded"
/> />
@@ -205,7 +262,7 @@ export function Settings() {
</label> </label>
<label className="flex items-center space-x-2"> <label className="flex items-center space-x-2">
<input <input
{...registerAutomod('scam_detection_enabled')} {...registerAutomod("scam_detection_enabled")}
type="checkbox" type="checkbox"
className="rounded" className="rounded"
/> />
@@ -213,7 +270,7 @@ export function Settings() {
</label> </label>
<label className="flex items-center space-x-2"> <label className="flex items-center space-x-2">
<input <input
{...registerAutomod('spam_detection_enabled')} {...registerAutomod("spam_detection_enabled")}
type="checkbox" type="checkbox"
className="rounded" className="rounded"
/> />
@@ -221,7 +278,7 @@ export function Settings() {
</label> </label>
<label className="flex items-center space-x-2"> <label className="flex items-center space-x-2">
<input <input
{...registerAutomod('invite_filter_enabled')} {...registerAutomod("invite_filter_enabled")}
type="checkbox" type="checkbox"
className="rounded" className="rounded"
/> />
@@ -233,7 +290,7 @@ export function Settings() {
<div> <div>
<label className="label">Max Mentions</label> <label className="label">Max Mentions</label>
<input <input
{...registerAutomod('max_mentions')} {...registerAutomod("max_mentions")}
type="number" type="number"
min="1" min="1"
max="20" max="20"
@@ -243,7 +300,7 @@ export function Settings() {
<div> <div>
<label className="label">Max Emojis</label> <label className="label">Max Emojis</label>
<input <input
{...registerAutomod('max_emojis')} {...registerAutomod("max_emojis")}
type="number" type="number"
min="1" min="1"
max="50" max="50"
@@ -253,7 +310,7 @@ export function Settings() {
<div> <div>
<label className="label">Spam Threshold</label> <label className="label">Spam Threshold</label>
<input <input
{...registerAutomod('spam_threshold')} {...registerAutomod("spam_threshold")}
type="number" type="number"
min="1" min="1"
max="20" max="20"
@@ -268,7 +325,9 @@ export function Settings() {
className="btn-primary" className="btn-primary"
disabled={!isAutomodDirty || updateAutomodMutation.isPending} disabled={!isAutomodDirty || updateAutomodMutation.isPending}
> >
{updateAutomodMutation.isPending ? 'Saving...' : 'Save Automod Config'} {updateAutomodMutation.isPending
? "Saving..."
: "Save Automod Config"}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -2,40 +2,53 @@
* User management page * User management page
*/ */
import { useQuery } from '@tanstack/react-query'; import { useQuery } from "@tanstack/react-query";
import { usersApi, guildsApi } from '../services/api'; import { usersApi, guildsApi } from "../services/api";
import { useState } from 'react'; import { useState } from "react";
import { format } from 'date-fns'; import { format } from "date-fns";
export function Users() { export function Users() {
const [selectedGuildId, setSelectedGuildId] = useState<number | undefined>(); const [selectedGuildId, setSelectedGuildId] = useState<number | undefined>();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState("");
const [minStrikes, setMinStrikes] = useState("");
const { data: guilds } = useQuery({ const { data: guilds } = useQuery({
queryKey: ['guilds'], queryKey: ["guilds"],
queryFn: guildsApi.list, queryFn: guildsApi.list,
}); });
const { data: users, isLoading } = useQuery({ const { data: users, isLoading } = useQuery({
queryKey: ['users', selectedGuildId, searchTerm], queryKey: ["users", selectedGuildId, searchTerm, minStrikes],
queryFn: () => usersApi.search(selectedGuildId!, searchTerm || undefined), queryFn: () =>
enabled: !!selectedGuildId, usersApi.search(
selectedGuildId,
searchTerm || undefined,
minStrikes ? Number(minStrikes) : undefined,
),
}); });
const showGuildColumn = !selectedGuildId;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900">User Management</h1> <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> </div>
<select <select
value={selectedGuildId || ''} value={selectedGuildId || ""}
onChange={(e) => setSelectedGuildId(e.target.value ? Number(e.target.value) : undefined)} onChange={(e) =>
setSelectedGuildId(
e.target.value ? Number(e.target.value) : undefined,
)
}
className="input max-w-xs" className="input max-w-xs"
> >
<option value="">Select a Guild</option> <option value="">All Guilds</option>
{guilds?.map((guild) => ( {guilds?.map((guild) => (
<option key={guild.id} value={guild.id}> <option key={guild.id} value={guild.id}>
{guild.name} {guild.name}
@@ -44,14 +57,10 @@ export function Users() {
</select> </select>
</div> </div>
{!selectedGuildId ? ( {/* Search */}
<div className="card text-center py-12"> <div className="card">
<p className="text-gray-600">Please select a guild to search users</p> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
</div> <div>
) : (
<>
{/* Search */}
<div className="card">
<label className="label">Search Users</label> <label className="label">Search Users</label>
<input <input
type="text" type="text"
@@ -61,62 +70,108 @@ export function Users() {
className="input" className="input"
/> />
</div> </div>
<div>
{/* Results */} <label className="label">Minimum Strikes</label>
<div className="card"> <input
{isLoading ? ( type="number"
<div className="text-center py-12">Loading...</div> min="0"
) : users && users.length > 0 ? ( value={minStrikes}
<div className="overflow-x-auto"> onChange={(e) => setMinStrikes(e.target.value)}
<table className="w-full"> placeholder="0"
<thead> className="input"
<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>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.user_id} className="border-b border-gray-100 hover:bg-gray-50">
<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'
: user.strike_count > 2
? '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_bans}</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')}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12 text-gray-600">
{searchTerm ? 'No users found matching your search' : 'Enter a username to search'}
</div>
)}
</div> </div>
</> </div>
)} </div>
{/* Results */}
<div className="card">
{isLoading ? (
<div className="text-center py-12">Loading...</div>
) : users && users.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
{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.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"
: user.strike_count > 2
? "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_bans}</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")}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12 text-gray-600">
{searchTerm || minStrikes
? "No users found matching your filters"
: "No user activity found"}
</div>
)}
</div>
</div> </div>
); );
} }

View File

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

View File

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

View File

@@ -34,6 +34,8 @@ services:
dashboard: dashboard:
build: build:
context: .
dockerfile: Dockerfile
target: development target: development
image: guardden-dashboard:dev image: guardden-dashboard:dev
container_name: guardden-dashboard-dev container_name: guardden-dashboard-dev
@@ -45,6 +47,8 @@ services:
# Mount source code for hot reloading # Mount source code for hot reloading
- ./src:/app/src:ro - ./src:/app/src:ro
- ./migrations:/app/migrations: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"] command: ["python", "-m", "guardden.dashboard", "--reload", "--host", "0.0.0.0"]
ports: ports:
- "8080:8000" - "8080:8000"

View File

@@ -34,7 +34,7 @@ services:
dashboard: dashboard:
build: build:
context: . context: .
target: runtime dockerfile: dashboard/Dockerfile
image: guardden-dashboard:latest image: guardden-dashboard:latest
container_name: guardden-dashboard container_name: guardden-dashboard
restart: unless-stopped restart: unless-stopped
@@ -123,22 +123,6 @@ services:
networks: networks:
- guardden - 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: networks:
guardden: guardden:
driver: bridge driver: bridge
@@ -150,4 +134,3 @@ volumes:
guardden_data: guardden_data:
guardden_logs: guardden_logs:
prometheus_data: prometheus_data:
grafana_data:

View File

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

View File

@@ -3,7 +3,7 @@
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, Query, Request 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 sqlalchemy.ext.asyncio import AsyncSession
from guardden.dashboard.auth import require_owner from guardden.dashboard.auth import require_owner
@@ -27,7 +27,9 @@ def create_api_router(
def require_owner_dep(request: Request) -> None: def require_owner_dep(request: Request) -> None:
require_owner(settings, request) 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( async def list_guilds(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[GuildSummary]: ) -> list[GuildSummary]:
@@ -47,6 +49,9 @@ def create_api_router(
guild_id: int | None = Query(default=None), guild_id: int | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200), limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0), 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), session: AsyncSession = Depends(get_session),
) -> PaginatedLogs: ) -> PaginatedLogs:
query = select(ModerationLog) query = select(ModerationLog)
@@ -55,6 +60,25 @@ def create_api_router(
query = query.where(ModerationLog.guild_id == guild_id) query = query.where(ModerationLog.guild_id == guild_id)
count_query = count_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) query = query.order_by(ModerationLog.created_at.desc()).offset(offset).limit(limit)
total_result = await session.execute(count_query) total_result = await session.execute(count_query)
total = int(total_result.scalar() or 0) total = int(total_result.scalar() or 0)

View File

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