From 9b3134d7671ccb0ff86be928ff92c7a33b1ff17b Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 18 Aug 2026 17:43:02 +0700 Subject: [PATCH] =?UTF-8?q?feat(gmw):=20public=20features=20#2-#6=20?= =?UTF-8?q?=E2=80=94=20live=20moderation=20feed,=20toxic=20topic=20trends,?= =?UTF-8?q?=20channel=20timeline,=20CSV=20export,=20activity=20heatmap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Live Moderation Feed: gateway publishes discord:moderation:action (Redis) → backend WS emits moderation_action → public web shows realtime stream. - Toxic Topic Trends: backend moderation.trends aggregates categories/severity/action_type (read-only) → SVG bar + donut. - Channel Timeline: messages view gets Feed/Timeline toggle with date-grouped separators. - CSV Export: client-side downloadCsv for moderation actions (no backend write scope). - Activity Heatmap: backend messages.activity (per-hour volume by channel) → pure-SVG grid. User reputation deliberately excluded — no such feature exists in the codebase. All read-only / public-facing / fully automatic per project rules. --- .../2026-08-18_gmw-public-features-2-6.md | 88 ++++++++++ .../modules/messages/messages.repository.ts | 25 +++ .../src/modules/messages/messages.service.ts | 4 + .../moderation/moderation.repository.ts | 54 ++++++ .../modules/moderation/moderation.service.ts | 5 +- services/backend/src/orpc/router.ts | 15 ++ services/backend/src/shared/redis-channels.ts | 2 + services/discord-gateway/src/app/bootstrap.ts | 2 + .../event-broadcaster/eventBroadcaster.ts | 10 ++ .../modules/event-broadcaster/eventTypes.ts | 2 + .../message-capture/moderationActionsDb.ts | 19 +- .../src/shared/redis-channels.ts | 1 + .../src/app/(dashboard)/messages/view.tsx | 162 +++++++++++++----- .../src/app/(dashboard)/moderation/view.tsx | 48 +++++- .../src/components/ActivityHeatmap.tsx | 89 ++++++++++ .../src/components/LiveModerationFeed.tsx | 102 +++++++++++ .../frontend/src/components/TopicTrends.tsx | 146 ++++++++++++++++ services/frontend/src/hooks/index.ts | 3 + services/frontend/src/hooks/use-messages.ts | 7 + services/frontend/src/hooks/use-moderation.ts | 51 +++++- services/frontend/src/lib/api/messages.ts | 7 + services/frontend/src/lib/api/moderation.ts | 9 +- services/frontend/src/lib/csv.ts | 32 ++++ services/frontend/src/lib/types/message.ts | 6 + services/frontend/src/lib/types/moderation.ts | 6 + services/frontend/src/lib/ws/types.ts | 3 + 26 files changed, 854 insertions(+), 44 deletions(-) create mode 100644 .hermes/plans/2026-08-18_gmw-public-features-2-6.md create mode 100644 services/frontend/src/components/ActivityHeatmap.tsx create mode 100644 services/frontend/src/components/LiveModerationFeed.tsx create mode 100644 services/frontend/src/components/TopicTrends.tsx create mode 100644 services/frontend/src/lib/csv.ts diff --git a/.hermes/plans/2026-08-18_gmw-public-features-2-6.md b/.hermes/plans/2026-08-18_gmw-public-features-2-6.md new file mode 100644 index 0000000..815697d --- /dev/null +++ b/.hermes/plans/2026-08-18_gmw-public-features-2-6.md @@ -0,0 +1,88 @@ +# GMW — Fitur Publik Lanjutan (#2–#6) Implementation Plan + +> **For Hermes:** Implement task-by-task. Build + lint + typecheck each service +> after its changes. Deploy via push to main (CI handles Nix build + systemd). +> Hard constraint (user 2026-08-18): public read-only web, fully automatic, +> rules in code, NO admin endpoints, NO shadow mode, NO per-channel web config. +> **EXPLICITLY EXCLUDED: User Reputation / Strike History** (user: "hapus +> sepenuhnya fitur user reputation" — it was never built; do not add it). + +## Existing infra to reuse (verified) +- **WS**: backend `ws/server.ts` broadcasts JSON `{type,data,timestamp}` to + frontendClients. Backend `ws/redis-bridge.ts` subscribes Redis channels + listed in `DISCORD_CHANNEL_TO_WS_EVENT` (backend `shared/redis-channels.ts`) + and re-emits as WS events. FE `src/lib/ws` auto-reconnect typed client. +- **Gateway → Redis**: `EventBroadcaster` + `RedisEventPublisher` ( + `discord-gateway/src/modules/event-broadcaster`). Publish via + `eventBroadcaster.publish(EventChannels.X, payload)`. +- **Moderation data**: `moderation_actions` table (now has explainability + cols). `moderation.repository.listActions` returns rows. `ModerationAction` + FE type at `frontend/src/lib/types/moderation.ts`. +- **Messages**: `messages.list` / `getMessagesByChannel` (backend oRPC + + repository). FE `messagesApi` + `useMessages`. +- **Charts**: NO chart lib installed. Use **pure SVG/CSS** (consistent with + repo; avoid new deps). +- **CSV**: client-side Blob download, no backend. + +## Task 1 — Live Moderation Feed (#2) +**Gateway**: add `MODERATION_ACTION: "discord:moderation:action"` to +`redis-channels.ts` (shared) + `EventChannels.MODERATION_ACTION` in +`eventTypes.ts`. In `moderationActionsDb.createModerationAction`, after insert, +publish `eventBroadcaster.publish(EventChannels.MODERATION_ACTION, actionRow)`. +**Backend**: add `DISCORD_MODERATION_ACTION` constant + map +`[DISCORD_MODERATION_ACTION]: "moderation_action"` in `DISCORD_CHANNEL_TO_WS_EVENT`. +**FE**: in `src/lib/ws`, subscribe to `moderation_action`; add `useLiveModeration` +hook (SWR-style with WS push, capped buffer ~50). Add `` +client component on `/moderation` page (top of list, animated new-row). +Risk: gateway publish at every action (already async insert) — fire-and-forget, +wrap in try/catch. Verify WS event reaches FE via `wscat`/curl or log. + +## Task 2 — Toxic Topic Trends (#3) +**Backend**: add `moderation.trends` oRPC. Query `moderation_actions` grouped +by `categories` (jsonb text[]) over last 30 days, count per category + severity +breakdown. Also `action_type` distribution. Return +`{ categories: {name,count}[], severities: {level,count}[], actions: {type,count}[] }`. +Map jsonb array in SQL (use `unnest` or parse in JS). Reuse `getDatabase`. +**FE**: `useModerationTrends` hook + `` SVG bar chart (top 10 +categories) + severity donut (SVG arcs). Place on `/moderation` as a panel. + +## Task 3 — Channel Timeline / Replay (#4) +Reuse existing `messages.list` (guildId) + `getMessagesByChannel`. Add a +**Timeline tab** to `/messages` that groups messages by date (client-side +bucket from `created_at`). Load-more via cursor. No new backend (existing +`messagesRouter.list` already supports guildId+limit+cursor). If needed, add +`messages.timeline` aggregation (count per day) — but keep simple: client +groups fetched rows. Verify existing endpoint returns enough history. + +## Task 4 — Export CSV (#5) +**FE only**. `lib/csv.ts` `toCsv(rows, columns)` + `downloadCsv(filename, csv)`. +Add "Export CSV" button on `/moderation` (exports current actions) and +`/messages` (exports current list). Pure client-side, read-only. No backend. + +## Task 5 — Activity Heatmap (#6) +**Backend**: add `messages.activity` oRPC: per-channel message count grouped by +hour-of-day (0–23) over last 14 days. Return +`{ channels: {channelId, name, byHour: number[24]}[], max }`. Use SQL +`EXTRACT(hour from ...)` + group by channel. Channel name from +`message.metadata->'channel'->>'channelName'`. +**FE**: `useMessageActivity` hook + `` SVG grid (channels × +24h, color intensity = count/max). Place on `/messages` or `/dashboard`. + +## Verification checklist +- [ ] `pnpm typecheck && pnpm lint && pnpm build` green for gateway, backend, frontend +- [ ] Backend `/trpc/moderation/trends` returns categories/severities/actions +- [ ] Backend `/trpc/messages/activity` returns byHour grids +- [ ] WS `moderation_action` received by FE (log or visible live row) +- [ ] No admin/write endpoint added; all public read-only +- [ ] No User Reputation code anywhere (grep "reputation|strike|reputasi") +- [ ] Deploy via push; all 3 services `running`; moderation + messages pages load + +## Files touched (summary) +- gateway: `shared/redis-channels.ts`, `event-broadcaster/eventTypes.ts`, + `event-broadcaster/eventBroadcaster.ts`, `message-capture/moderationActionsDb.ts` +- backend: `shared/redis-channels.ts`, `orpc/router.ts`, + `modules/moderation/moderation.service.ts` (+repository), + `modules/messages/messages.service.ts` (+repository, +schema) +- frontend: `lib/ws/*`, `hooks/use-moderation.ts`, `hooks/use-messages.ts`, + `lib/csv.ts`, `lib/types/*`, `app/(dashboard)/moderation/view.tsx`, + `app/(dashboard)/messages/view.tsx`, new components under `components/` diff --git a/services/backend/src/modules/messages/messages.repository.ts b/services/backend/src/modules/messages/messages.repository.ts index 5bc992e..2261859 100644 --- a/services/backend/src/modules/messages/messages.repository.ts +++ b/services/backend/src/modules/messages/messages.repository.ts @@ -459,6 +459,31 @@ export class MessagesRepository { return { data: trimmed, nextCursor }; } + + /** + * Per-hour message volume for the last `days` days, grouped by channel. + * Powers the public Activity Heatmap (read-only, no write scope). + * Returns a flat list of { channel_id, hour (0-23), count } buckets. + */ + async getActivity(days = 30) { + const db = getDatabase(); + const since = Date.now() - days * 24 * 60 * 60 * 1000; + const result = await db.execute(sql` + SELECT channel_id, + EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour, + COUNT(*)::int AS c + FROM messages + WHERE created_at >= ${since} + GROUP BY channel_id, hour + ORDER BY channel_id, hour + `); + const rows = (result.rows as Record[]) || []; + return rows.map((r) => ({ + channelId: String(r.channel_id ?? "unknown"), + hour: Number(r.hour ?? 0), + count: Number(r.c ?? 0), + })); + } } export const messagesRepository = new MessagesRepository(); diff --git a/services/backend/src/modules/messages/messages.service.ts b/services/backend/src/modules/messages/messages.service.ts index 4dfb57d..06814ab 100644 --- a/services/backend/src/modules/messages/messages.service.ts +++ b/services/backend/src/modules/messages/messages.service.ts @@ -101,6 +101,10 @@ export class MessagesService { const results = hits.map((h) => mapSearchHit(h)); return { results, nextCursor: null }; } + + async getActivity(days = 30) { + return messagesRepository.getActivity(days); + } } /** Shape returned to the frontend (text + metadata from the archive payload). */ diff --git a/services/backend/src/modules/moderation/moderation.repository.ts b/services/backend/src/modules/moderation/moderation.repository.ts index e692160..5c875b5 100644 --- a/services/backend/src/modules/moderation/moderation.repository.ts +++ b/services/backend/src/modules/moderation/moderation.repository.ts @@ -164,6 +164,60 @@ export class ModerationRepository { return { data, nextCursor }; } + + /** + * Aggregate moderation trends over the last `days` days. + * - category counts (from the jsonb/text[] `categories` column, unnested) + * - severity distribution + * - action_type distribution + * Read-only; powers the public Toxic Topic Trends panel. + */ + async getTrends(days: number) { + const db = getDatabase(); + const since = Date.now() - days * 24 * 60 * 60 * 1000; + + const cats = await db.execute(sql` + SELECT jsonb_array_elements_text(a.categories::jsonb) AS cat, COUNT(*)::int AS c + FROM moderation_actions a + WHERE a.created_at >= ${since} AND a.categories IS NOT NULL AND a.categories != '[]' AND a.categories != '' + GROUP BY cat + ORDER BY c DESC + LIMIT 15 + `); + const catRows = (cats.rows as Record[]) || []; + + const sev = await db.execute(sql` + SELECT severity, COUNT(*)::int AS c + FROM moderation_actions + WHERE created_at >= ${since} AND severity IS NOT NULL + GROUP BY severity + `); + const sevRows = (sev.rows as Record[]) || []; + + const act = await db.execute(sql` + SELECT action_type, COUNT(*)::int AS c + FROM moderation_actions + WHERE created_at >= ${since} + GROUP BY action_type + ORDER BY c DESC + `); + const actRows = (act.rows as Record[]) || []; + + return { + categories: catRows.map((r) => ({ + name: String(r.cat), + count: Number(r.c ?? 0), + })), + severities: sevRows.map((r) => ({ + level: String(r.severity), + count: Number(r.c ?? 0), + })), + actions: actRows.map((r) => ({ + type: String(r.action_type), + count: Number(r.c ?? 0), + })), + }; + } } export const moderationRepository = new ModerationRepository(); diff --git a/services/backend/src/modules/moderation/moderation.service.ts b/services/backend/src/modules/moderation/moderation.service.ts index 076e3e1..0c9b172 100644 --- a/services/backend/src/modules/moderation/moderation.service.ts +++ b/services/backend/src/modules/moderation/moderation.service.ts @@ -8,10 +8,13 @@ const logger = createChildLogger("moderation.service"); export class ModerationService { async getStats() { - logger.debug("Fetching moderation stats"); return moderationRepository.getStats(); } + async getTrends(days = 30) { + return moderationRepository.getTrends(days); + } + async listActions(query: ListModerationQuery) { logger.debug({ query }, "Listing moderation actions"); return moderationRepository.listActions(query); diff --git a/services/backend/src/orpc/router.ts b/services/backend/src/orpc/router.ts index 8e91ded..75cd2f6 100644 --- a/services/backend/src/orpc/router.ts +++ b/services/backend/src/orpc/router.ts @@ -143,6 +143,14 @@ const messagesRouter = { semanticSearch: os .input(semanticSearchSchema) .handler(({ input }) => messagesService.semanticSearch(input)), + // Public, read-only activity heatmap data (per-hour volume by channel). + activity: os + .input( + z.object({ + days: z.coerce.number().int().positive().max(365).default(30), + }), + ) + .handler(({ input }) => messagesService.getActivity(input.days)), }; // ── Moderation ─────────────────────────────────────────────────── @@ -165,6 +173,13 @@ const moderationRouter = { cursor: input.cursor, }), ), + trends: os + .input( + z.object({ + days: z.coerce.number().int().positive().max(365).default(30), + }), + ) + .handler(({ input }) => moderationService.getTrends(input.days)), }; // ── Media ──────────────────────────────────────────────────────── diff --git a/services/backend/src/shared/redis-channels.ts b/services/backend/src/shared/redis-channels.ts index b42ad6c..e3d9e1f 100644 --- a/services/backend/src/shared/redis-channels.ts +++ b/services/backend/src/shared/redis-channels.ts @@ -30,6 +30,7 @@ export const DISCORD_CHANNEL_TOPIC_UPDATED = "discord:channel:topic_updated"; export const DISCORD_PRESENCE_UPDATED = "discord:presence:updated"; export const DISCORD_GUILD_MEMBER_ADDED = "discord:guild_member:added"; export const DISCORD_GUILD_MEMBER_REMOVED = "discord:guild_member:removed"; +export const DISCORD_MODERATION_ACTION = "discord:moderation:action"; // --------------------------------------------------------------------------- // Command channels (backend -> discord-gateway) @@ -126,4 +127,5 @@ export const DISCORD_CHANNEL_TO_WS_EVENT: Record = { [DISCORD_PRESENCE_UPDATED]: "presence_updated", [DISCORD_GUILD_MEMBER_ADDED]: "guild_member_added", [DISCORD_GUILD_MEMBER_REMOVED]: "guild_member_removed", + [DISCORD_MODERATION_ACTION]: "moderation_action", }; diff --git a/services/discord-gateway/src/app/bootstrap.ts b/services/discord-gateway/src/app/bootstrap.ts index 96de64e..11be6aa 100644 --- a/services/discord-gateway/src/app/bootstrap.ts +++ b/services/discord-gateway/src/app/bootstrap.ts @@ -25,6 +25,7 @@ import { registerMessageCapture, setEventBroadcaster as setMessageCaptureEventBroadcaster, } from "../modules/message-capture/messageCapture.js"; +import { setModerationEventBroadcaster } from "../modules/message-capture/moderationActionsDb.js"; import { registerReactionCapture } from "../modules/reaction-tracking/index.js"; import { registerThreadCapture } from "../modules/thread-tracking/index.js"; import { registerPresenceCapture } from "../modules/user-presence/index.js"; @@ -254,6 +255,7 @@ export async function initializeDiscordGateway() { logger.info({ user: client.user?.tag }, "Bot logged in"); setMessageCaptureEventBroadcaster(eventBroadcaster); setRecorderEventBroadcaster(eventBroadcaster); + setModerationEventBroadcaster(eventBroadcaster); registerMessageCapture(client); startPendingAIAnalysisWorker(client, eventBroadcaster); diff --git a/services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts b/services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts index 31ce12d..1acfeaf 100644 --- a/services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts +++ b/services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts @@ -293,6 +293,16 @@ export class EventBroadcaster { }); } + async moderationAction(data: Record): Promise { + this.logger.debug({ data }, "Publishing moderation_action"); + await this.publisher.publish(EventChannels.MODERATION_ACTION, { + type: "moderation_action", + data, + timestamp: Date.now(), + source: "discord-gateway", + }); + } + async analysisQueueStatus(data: Record): Promise { this.logger.debug({ data }, "Publishing analysis_queue_status"); await this.publisher.publish(EventChannels.ANALYSIS_QUEUE_STATUS, { diff --git a/services/discord-gateway/src/modules/event-broadcaster/eventTypes.ts b/services/discord-gateway/src/modules/event-broadcaster/eventTypes.ts index 0d0afd6..277716f 100644 --- a/services/discord-gateway/src/modules/event-broadcaster/eventTypes.ts +++ b/services/discord-gateway/src/modules/event-broadcaster/eventTypes.ts @@ -9,6 +9,7 @@ import { DISCORD_MESSAGE_CREATED, DISCORD_MESSAGE_DELETED, DISCORD_MESSAGE_UPDATED, + DISCORD_MODERATION_ACTION, DISCORD_PRESENCE_UPDATED, DISCORD_REACTION_ADDED, DISCORD_REACTION_REMOVED, @@ -50,6 +51,7 @@ export const EventChannels = { GUILD_MEMBER_ADDED: DISCORD_GUILD_MEMBER_ADDED, GUILD_MEMBER_REMOVED: DISCORD_GUILD_MEMBER_REMOVED, VOICE_ANALYZED: DISCORD_VOICE_ANALYZED, + MODERATION_ACTION: DISCORD_MODERATION_ACTION, } as const; export type EventChannelType = diff --git a/services/discord-gateway/src/modules/message-capture/moderationActionsDb.ts b/services/discord-gateway/src/modules/message-capture/moderationActionsDb.ts index dd15afc..2608070 100644 --- a/services/discord-gateway/src/modules/message-capture/moderationActionsDb.ts +++ b/services/discord-gateway/src/modules/message-capture/moderationActionsDb.ts @@ -4,8 +4,16 @@ import type * as schema from "../../shared/database/schema.js"; import { moderationActionsTable } from "../../shared/database/schema.js"; import { buildCursorCondition, pageResult } from "../../shared/index.js"; import { createChildLogger, type Logger } from "../../shared/logger/index.js"; +import type { EventBroadcaster } from "../event-broadcaster/eventBroadcaster.js"; import type { ModerationAction, PageResult } from "../message-capture/types.js"; +let _eventBroadcaster: EventBroadcaster | null = null; + +/** Inject the gateway's event broadcaster so actions can be published live. */ +export function setModerationEventBroadcaster(eb: EventBroadcaster): void { + _eventBroadcaster = eb; +} + // ─── ModerationActionsDb Class ────────────────────────────────────────────── export class ModerationActionsDb { @@ -38,7 +46,16 @@ export class ModerationActionsDb { }) .returning(); - return rows[0] as ModerationAction; + const created = rows[0] as ModerationAction; + + // Fire-and-forget live broadcast (backend WS → frontend feed). + if (_eventBroadcaster) { + _eventBroadcaster + .moderationAction(created as unknown as Record) + .catch(() => {}); + } + + return created; } catch (error) { this.logger.error( { diff --git a/services/discord-gateway/src/shared/redis-channels.ts b/services/discord-gateway/src/shared/redis-channels.ts index b42ad6c..5754964 100644 --- a/services/discord-gateway/src/shared/redis-channels.ts +++ b/services/discord-gateway/src/shared/redis-channels.ts @@ -30,6 +30,7 @@ export const DISCORD_CHANNEL_TOPIC_UPDATED = "discord:channel:topic_updated"; export const DISCORD_PRESENCE_UPDATED = "discord:presence:updated"; export const DISCORD_GUILD_MEMBER_ADDED = "discord:guild_member:added"; export const DISCORD_GUILD_MEMBER_REMOVED = "discord:guild_member:removed"; +export const DISCORD_MODERATION_ACTION = "discord:moderation:action"; // --------------------------------------------------------------------------- // Command channels (backend -> discord-gateway) diff --git a/services/frontend/src/app/(dashboard)/messages/view.tsx b/services/frontend/src/app/(dashboard)/messages/view.tsx index dfa1a10..1736be6 100644 --- a/services/frontend/src/app/(dashboard)/messages/view.tsx +++ b/services/frontend/src/app/(dashboard)/messages/view.tsx @@ -2,6 +2,7 @@ import { AlertTriangle, + Calendar, CheckCircle2, Image as ImageIcon, Loader2, @@ -11,6 +12,7 @@ import { ShieldAlert, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ActivityHeatmap } from "@/components/ActivityHeatmap"; import { useAmbient } from "@/components/ambient/ambient-context"; import { Avatar, @@ -28,6 +30,7 @@ import { import { GuildChannelPicker } from "@/components/shared/guild-picker"; import { useLoadMore, + useMessageActivity, useMessageDetail, useMessageSearch, useMessages, @@ -71,6 +74,8 @@ export function MessagesView({ // Search mode: "exact" (substring match over captured messages) or // "semantic" (vector similarity over the persistent Qdrant archive). const [semanticMode, setSemanticMode] = useState(false); + // feed | timeline: "timeline" groups messages into date-grouped cards. + const [viewMode, setViewMode] = useState<"feed" | "timeline">("feed"); // Guard against loading the entire history on a long scroll: cap how many // older pages we append. Each page is 50 messages (backend limit default). const MAX_OLDER_PAGES = 10; @@ -106,6 +111,7 @@ export function MessagesView({ query, query.trim().length >= 2 && semanticMode, ); + const activity = useMessageActivity(30); const detail = useMessageDetail(selected); const ambient = useAmbient(); @@ -140,6 +146,33 @@ export function MessagesView({ // returns DESC (newest first); reverse so the feed reads top→bottom like DC. const display = useMemo(() => [...list].reverse(), [list]); + // Timeline mode: inject date-separator headers above the first message of + // each day. Messages are sorted oldest→newest (display is reversed), so a + // date change means a new group. Produces an array of either "date" or "msg" + // nodes so the render loop can switch easily. + const timelineNodes = useMemo(() => { + if (viewMode !== "timeline") return null; + const out: Array< + | { type: "date"; label: string; iso: string } + | { type: "msg"; m: (typeof display)[number] } + > = []; + let prev = ""; + for (const m of display) { + const d = new Date(m.created_at).toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + }); + const iso = new Date(m.created_at).toISOString().slice(0, 10); + if (d !== prev) { + out.push({ type: "date", label: d, iso }); + prev = d; + } + out.push({ type: "msg", m }); + } + return out; + }, [display, viewMode]); + // Ref to the scroll container so we can manage scroll position like Discord: // open at the bottom (newest), keep the viewport stable when prepending older // messages at the top, and follow new live messages only when already near @@ -208,6 +241,20 @@ export function MessagesView({ > {semanticMode ? "Semantic" : "Exact"} +
@@ -326,45 +373,33 @@ export function MessagesView({ } }} > - {display.map((m, i) => ( - - ))} + {viewMode === "timeline" && timelineNodes + ? timelineNodes.map((node, _i) => + node.type === "date" ? ( +
+ + {node.label} +
+ ) : ( + + ), + ) + : display.map((m, _i) => ( + + ))}
)} @@ -392,6 +427,10 @@ export function MessagesView({ )} + + {activity.data && activity.data.length > 0 && ( + + )} ); } @@ -513,3 +552,48 @@ function MessageDetail({ ); } + +/** Single message card used by both the live feed and the date-grouped timeline. */ +function MessageRow({ + m, + selected, + onSelect, +}: { + m: MessageRecord; + selected: string | null; + onSelect: (id: string) => void; +}) { + return ( + + ); +} diff --git a/services/frontend/src/app/(dashboard)/moderation/view.tsx b/services/frontend/src/app/(dashboard)/moderation/view.tsx index 8c126a0..161cd44 100644 --- a/services/frontend/src/app/(dashboard)/moderation/view.tsx +++ b/services/frontend/src/app/(dashboard)/moderation/view.tsx @@ -16,6 +16,7 @@ import { import { useEffect, useState } from "react"; import { useAmbient } from "@/components/ambient/ambient-context"; import { Donut } from "@/components/charts"; +import { LiveModerationFeed } from "@/components/LiveModerationFeed"; import { Badge, GlassPanel, @@ -30,8 +31,15 @@ import { SkeletonPanel, SkeletonRows, } from "@/components/shared"; -import { useModerationActions, useModerationStats } from "@/hooks"; +import { TopicTrends } from "@/components/TopicTrends"; +import { + useLiveModeration, + useModerationActions, + useModerationStats, + useModerationTrends, +} from "@/hooks"; import { aiTone } from "@/lib/ai-status"; +import { downloadCsv } from "@/lib/csv"; import { formatNumber, formatRelativeTime } from "@/lib/format"; import type { ModerationAction, @@ -71,6 +79,8 @@ export function ModerationView({ typeFilter || undefined, !statusFilter && !typeFilter ? initialActions : undefined, ); + const liveActions = useLiveModeration(initialActions ?? [], 50); + const { data: trends } = useModerationTrends(30); const failedRate = stats ? stats.failed_rate * 100 : 0; @@ -150,6 +160,18 @@ export function ModerationView({
+
+ {trends ? ( + + ) : ( + + )} +
+ +
+ +
+
@@ -215,6 +237,30 @@ export function ModerationView({ size="sm" className="w-32" /> +
} /> diff --git a/services/frontend/src/components/ActivityHeatmap.tsx b/services/frontend/src/components/ActivityHeatmap.tsx new file mode 100644 index 0000000..5a5f1fc --- /dev/null +++ b/services/frontend/src/components/ActivityHeatmap.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { GlassPanel } from "@/components/primitives"; +import { SectionHeader } from "@/components/shared"; +import type { MessageActivityBucket } from "@/lib/types"; + +const HOURS = Array.from({ length: 24 }, (_, i) => i); + +function heatColor(t: number): string { + // t in [0,1] → signal gradient (dark → bright). + if (t <= 0) return "var(--color-hairline)"; + return `rgba(45, 212, 191, ${0.15 + 0.85 * t})`; +} + +export function ActivityHeatmap({ + buckets, +}: { + buckets: MessageActivityBucket[]; +}) { + // Group by channel, find max count for normalization. + const channels = Array.from(new Set(buckets.map((b) => b.channelId))); + const byKey = new Map(); + let max = 0; + for (const b of buckets) { + const k = `${b.channelId}:${b.hour}`; + byKey.set(k, (byKey.get(k) ?? 0) + b.count); + if (byKey.get(k)! > max) max = byKey.get(k)!; + } + + if (buckets.length === 0) { + return ( + + +

+ No message activity recorded yet. +

+
+ ); + } + + return ( + + + {channels.length} channels · messages/hour + + } + /> +
+
+ {channels.map((ch) => ( +
+ + {ch.slice(-6)} + +
+ {HOURS.map((h) => { + const c = byKey.get(`${ch}:${h}`) ?? 0; + const t = max > 0 ? c / max : 0; + return ( +
+ ); + })} +
+
+ ))} +
+ +
+ {[0, 6, 12, 18, 23].map((h) => ( + + {String(h).padStart(2, "0")}h + + ))} +
+
+
+
+ + ); +} diff --git a/services/frontend/src/components/LiveModerationFeed.tsx b/services/frontend/src/components/LiveModerationFeed.tsx new file mode 100644 index 0000000..45128c1 --- /dev/null +++ b/services/frontend/src/components/LiveModerationFeed.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { Badge, GlassPanel } from "@/components/primitives"; +import { formatRelativeTime } from "@/lib/format"; +import type { ModerationAction } from "@/lib/types"; + +const ACTION_LABEL: Record = { + delete_message: "Deleted", + timeout_user: "Timeout", + warn_user: "Warned", + reset_nickname: "Nickname reset", + ban_user: "Banned", + kick_user: "Kicked", + notify_user: "Notified", + none: "None", +}; + +function severityTone( + sev?: string | null, +): "signal" | "amber" | "vermilion" | null { + switch (sev) { + case "critical": + case "high": + return "vermilion"; + case "medium": + return "amber"; + case "low": + return "signal"; + default: + return null; + } +} + +export function LiveModerationFeed({ + actions, +}: { + actions: ModerationAction[]; +}) { + return ( + +
+
+ + + + +

Live Feed

+
+ {actions.length} recent +
+ +
+ {actions.length === 0 ? ( +

+ Waiting for new moderation actions… +

+ ) : ( +
    + {actions.map((a, i) => { + const tone = severityTone(a.severity); + return ( +
  • +
    +
    + + {ACTION_LABEL[a.action_type] ?? a.action_type} + + {a.severity && ( + + {a.severity} + + )} + {a.categories?.length ? ( + + {a.categories.slice(0, 3).join(", ")} + + ) : null} +
    + {a.reason && ( +

    + “{a.reason}” +

    + )} +

    + {a.username ?? a.user_id ?? "unknown"} ·{" "} + {formatRelativeTime(a.created_at)} +

    +
    +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/services/frontend/src/components/TopicTrends.tsx b/services/frontend/src/components/TopicTrends.tsx new file mode 100644 index 0000000..431630d --- /dev/null +++ b/services/frontend/src/components/TopicTrends.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { Donut } from "@/components/charts/donut"; +import { GlassPanel } from "@/components/primitives"; +import { SectionHeader } from "@/components/shared"; +import { formatNumber } from "@/lib/format"; +import type { ModerationTrends } from "@/lib/types"; + +const SEVERITY_COLOR: Record = { + critical: "var(--color-vermilion)", + high: "var(--color-vermilion)", + medium: "var(--color-amber)", + low: "var(--color-signal)", + none: "var(--color-ink-faint)", +}; + +function BarRow({ + label, + count, + max, + color = "var(--color-signal)", +}: { + label: string; + count: number; + max: number; + color?: string; +}) { + const pct = max > 0 ? Math.max(2, (count / max) * 100) : 0; + return ( +
+ {label} +
+
+
+ + {formatNumber(count)} + +
+ ); +} + +export function TopicTrends({ trends }: { trends: ModerationTrends }) { + const maxCat = trends.categories.reduce((m, c) => Math.max(m, c.count), 0); + const maxAct = trends.actions.reduce((m, a) => Math.max(m, a.count), 0); + const totalSev = trends.severities.reduce((s, x) => s + x.count, 0); + + const severitySegments = trends.severities.map((s) => ({ + value: s.count, + color: SEVERITY_COLOR[s.level] ?? "var(--color-ink-faint)", + label: s.level, + })); + + return ( + + + {trends.categories.length === 0 && trends.severities.length === 0 ? ( +

+ No categorized actions in the last 30 days. +

+ ) : ( +
+
+

+ Top flagged categories +

+
+ {trends.categories.slice(0, 10).map((c) => ( + + ))} + {trends.categories.length === 0 && ( +

+ No categories recorded. +

+ )} +
+
+ +
+
+

+ Severity +

+ {totalSev > 0 ? ( +
+ +
+ {trends.severities.map((s) => ( +
+ + + {s.level} + + + {formatNumber(s.count)} + +
+ ))} +
+
+ ) : ( +

No severity data.

+ )} +
+ +
+

+ Action types +

+
+ {trends.actions.slice(0, 6).map((a) => ( + + ))} +
+
+
+
+ )} +
+ ); +} diff --git a/services/frontend/src/hooks/index.ts b/services/frontend/src/hooks/index.ts index 1a21407..56063fc 100644 --- a/services/frontend/src/hooks/index.ts +++ b/services/frontend/src/hooks/index.ts @@ -30,10 +30,13 @@ export { useReview, useSemanticSearch, useTextChannels, + useMessageActivity, } from "./use-messages"; export { + useLiveModeration, useModerationActions, useModerationStats, + useModerationTrends, } from "./use-moderation"; export { useDeleteRecording, diff --git a/services/frontend/src/hooks/use-messages.ts b/services/frontend/src/hooks/use-messages.ts index 2cced71..a768937 100644 --- a/services/frontend/src/hooks/use-messages.ts +++ b/services/frontend/src/hooks/use-messages.ts @@ -5,6 +5,7 @@ import { messagesApi, voiceApi } from "@/lib/api"; import type { AttachmentRecord, Channel, + MessageActivityBucket, MessageRecord, SemanticSearchResult, } from "@/lib/types"; @@ -374,3 +375,9 @@ export function useMessagesStream( return { streaming, error }; } + +export function useMessageActivity(days = 30) { + return useSWR(["activity", days], () => + messagesApi.getActivity(days), + ); +} diff --git a/services/frontend/src/hooks/use-moderation.ts b/services/frontend/src/hooks/use-moderation.ts index a2b8f99..346ef6f 100644 --- a/services/frontend/src/hooks/use-moderation.ts +++ b/services/frontend/src/hooks/use-moderation.ts @@ -1,6 +1,12 @@ +import { useCallback, useEffect, useRef, useState } from "react"; import useSWR from "swr"; import { moderationApi } from "@/lib/api"; -import type { ModerationAction, ModerationStats } from "@/lib/types"; +import type { + ModerationAction, + ModerationStats, + ModerationTrends, +} from "@/lib/types"; +import { useWebSocket } from "@/lib/ws/context"; export function useModerationStats(initialData?: ModerationStats) { return useSWR( @@ -33,3 +39,46 @@ export function useModerationActions( }, ); } + +/** + * Live moderation feed: merges the initial SWR list with actions pushed over + * the WebSocket in real time. Returns a capped, newest-first buffer. + * Read-only / public — no write actions. + */ +export function useLiveModeration( + initialData: ModerationAction[] = [], + cap = 50, +) { + const { on: subscribe } = useWebSocket(); + const [live, setLive] = useState(initialData); + const seen = useRef>(new Set(initialData.map((a) => a.id))); + + useEffect(() => { + setLive(initialData); + seen.current = new Set(initialData.map((a) => a.id)); + }, [initialData]); + + const handle = useCallback( + (action: ModerationAction) => { + if (seen.current.has(action.id)) return; + seen.current.add(action.id); + setLive((prev) => [action, ...prev].slice(0, cap)); + }, + [cap], + ); + + useEffect(() => { + const unsub = subscribe("moderation_action", handle); + return unsub; + }, [subscribe, handle]); + + return live; +} + +export function useModerationTrends(days = 30, initialData?: ModerationTrends) { + return useSWR( + ["moderation-trends", days], + () => moderationApi.getTrends(days), + { fallbackData: initialData }, + ); +} diff --git a/services/frontend/src/lib/api/messages.ts b/services/frontend/src/lib/api/messages.ts index 0eb52fb..8bf26be 100644 --- a/services/frontend/src/lib/api/messages.ts +++ b/services/frontend/src/lib/api/messages.ts @@ -1,6 +1,7 @@ import { orpc } from "@/lib/orpc/client"; import type { AttachmentRecord, + MessageActivityBucket, MessageRecord, SemanticSearchResult, } from "@/lib/types"; @@ -73,4 +74,10 @@ export const messagesApi = { results: SemanticSearchResult[]; nextCursor: null; }>, + + // Public, read-only activity heatmap data (per-hour volume by channel). + getActivity: (days = 30) => + orpc.messages.activity({ days }) as unknown as Promise< + MessageActivityBucket[] + >, }; diff --git a/services/frontend/src/lib/api/moderation.ts b/services/frontend/src/lib/api/moderation.ts index 43e6343..cb4d579 100644 --- a/services/frontend/src/lib/api/moderation.ts +++ b/services/frontend/src/lib/api/moderation.ts @@ -1,5 +1,9 @@ import { orpc } from "@/lib/orpc/client"; -import type { ModerationStats, PaginatedModerationActions } from "@/lib/types"; +import type { + ModerationStats, + ModerationTrends, + PaginatedModerationActions, +} from "@/lib/types"; export const moderationApi = { getStats: () => @@ -17,4 +21,7 @@ export const moderationApi = { actionType, cursor, }) as unknown as Promise, + + getTrends: (days = 30) => + orpc.moderation.trends({ days }) as unknown as Promise, }; diff --git a/services/frontend/src/lib/csv.ts b/services/frontend/src/lib/csv.ts new file mode 100644 index 0000000..5bf2006 --- /dev/null +++ b/services/frontend/src/lib/csv.ts @@ -0,0 +1,32 @@ +/** Client-side CSV export. Pure browser — no backend, no write scope. */ +export function toCsv(rows: Record[]): string { + if (rows.length === 0) return ""; + const headers = Array.from( + rows.reduce>((s, r) => { + Object.keys(r).forEach((k) => s.add(k)); + return s; + }, new Set()), + ); + const esc = (v: unknown): string => { + if (v == null) return ""; + const s = typeof v === "object" ? JSON.stringify(v) : String(v); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + const head = headers.map(esc).join(","); + const body = rows + .map((r) => headers.map((h) => esc(r[h])).join(",")) + .join("\n"); + return `${head}\n${body}`; +} + +export function downloadCsv(filename: string, rows: Record[]) { + const csv = toCsv(rows); + if (!csv) return; + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/services/frontend/src/lib/types/message.ts b/services/frontend/src/lib/types/message.ts index 5e6846a..0e6a9a8 100644 --- a/services/frontend/src/lib/types/message.ts +++ b/services/frontend/src/lib/types/message.ts @@ -173,6 +173,12 @@ export interface SemanticSearchResult { created_at: number; } +export interface MessageActivityBucket { + channelId: string; + hour: number; + count: number; +} + export interface SemanticSearchResponse { results: SemanticSearchResult[]; nextCursor: null; diff --git a/services/frontend/src/lib/types/moderation.ts b/services/frontend/src/lib/types/moderation.ts index 3946dfb..16de4ac 100644 --- a/services/frontend/src/lib/types/moderation.ts +++ b/services/frontend/src/lib/types/moderation.ts @@ -44,3 +44,9 @@ export interface PaginatedModerationActions { data: ModerationAction[]; nextCursor: string | null; } + +export interface ModerationTrends { + categories: { name: string; count: number }[]; + severities: { level: string; count: number }[]; + actions: { type: string; count: number }[]; +} diff --git a/services/frontend/src/lib/ws/types.ts b/services/frontend/src/lib/ws/types.ts index edae6d9..f063eab 100644 --- a/services/frontend/src/lib/ws/types.ts +++ b/services/frontend/src/lib/ws/types.ts @@ -2,6 +2,7 @@ import type { ActiveSpeaker, MediaState, MessageRecord, + ModerationAction, VoiceRecording, } from "@/lib/types"; @@ -68,6 +69,8 @@ export interface WsEventMap { presence_updated: unknown; guild_member_added: unknown; guild_member_removed: unknown; + /** Live moderation action broadcast (gateway → Redis → backend → WS). */ + moderation_action: ModerationAction; media_state: MediaState; user_state: unknown; ui_state: unknown;