Embeddable server status widgets with 6 themes (Dark, Light, transparent, Neon/Cyberpunk, Military, Minimal).

Supports iframe, SVG and PNG output
  for forums, signatures and external websites.

  - Public endpoints: /widget/:token (iframe), /api/widget/:token/image.svg,
    /api/widget/:token/image.png — no auth required, 45s cache, CORS *
  - Admin CRUD via /api/widgets with token generation/rotation
  - Widget management modal on Dashboard page with embed code tabs
    (iframe snippet, image URLs, BBCode) and live preview
  - Prisma Widget model, shared theme definitions in @ts6/common
  - Server-side SVG generation with @resvg/resvg-js PNG rendering
  - Nginx config: X-Frame-Options override for /widget/ path
This commit is contained in:
Clusterzx
2026-02-25 11:40:10 +01:00
parent 15fd90e927
commit 0aa6ce68db
19 changed files with 1435 additions and 26 deletions
+6
View File
@@ -42,6 +42,12 @@ RUN printf 'server {\n\
proxy_set_header Connection "upgrade";\n\
proxy_set_header Host $host;\n\
}\n\
\n\
location /widget/ {\n\
add_header X-Frame-Options "";\n\
add_header Content-Security-Policy "frame-ancestors *";\n\
try_files $uri $uri/ /index.html;\n\
}\n\
\n\
location / {\n\
try_files $uri $uri/ /index.html;\n\
+22 -20
View File
@@ -11,37 +11,39 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ts6/common": "workspace:*",
"@prisma/client": "^6.3.0",
"express": "^4.21.0",
"cors": "^2.8.5",
"helmet": "^8.0.0",
"@resvg/resvg-js": "^2.6.2",
"@ts6/common": "workspace:*",
"axios": "^1.7.0",
"jsonwebtoken": "^9.0.2",
"bcryptjs": "^2.4.3",
"ws": "^8.18.0",
"ssh2": "^1.16.0",
"node-cron": "^3.0.3",
"express-rate-limit": "^7.4.0",
"zod": "^3.23.0",
"pino": "^9.4.0",
"pino-pretty": "^11.2.0",
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"expr-eval": "^2.0.2",
"express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"libsodium-wrappers-sumo": "^0.8.2",
"multer": "^1.4.5-lts.1",
"nanoid": "^5.1.6",
"node-cron": "^3.0.3",
"opusscript": "^0.1.1",
"multer": "^1.4.5-lts.1"
"pino": "^9.4.0",
"pino-pretty": "^11.2.0",
"ssh2": "^1.16.0",
"ws": "^8.18.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/multer": "^1.4.12",
"@types/express": "^5.0.0",
"@types/cors": "^2.8.17",
"@types/jsonwebtoken": "^9.0.7",
"@types/bcryptjs": "^2.4.6",
"@types/ws": "^8.5.12",
"@types/ssh2": "^1.15.1",
"@types/node-cron": "^3.0.11",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12",
"@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11",
"@types/ssh2": "^1.15.1",
"@types/ws": "^8.5.12",
"prisma": "^6.3.0",
"tsx": "^4.19.0",
"typescript": "^5.4.0"
+18
View File
@@ -62,6 +62,7 @@ model TsServerConfig {
musicBots MusicBot[]
songs Song[]
radioStations RadioStation[]
widgets Widget[]
}
model BotFlow {
@@ -197,3 +198,20 @@ model RadioStation {
serverConfig TsServerConfig @relation(fields: [serverConfigId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
}
// === Server Widget / Banner ===
model Widget {
id Int @id @default(autoincrement())
name String
token String @unique
serverConfigId Int
serverConfig TsServerConfig @relation(fields: [serverConfigId], references: [id], onDelete: Cascade)
virtualServerId Int @default(1)
theme String @default("dark")
showChannelTree Boolean @default(true)
showClients Boolean @default(true)
maxChannelDepth Int @default(5)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+6
View File
@@ -26,6 +26,8 @@ import { musicBotRoutes } from './routes/music-bots.routes.js';
import { musicLibraryRoutes } from './routes/music-library.routes.js';
import { playlistRoutes } from './routes/playlists.routes.js';
import { radioStationRoutes } from './routes/radio-stations.routes.js';
import { widgetPublicRoutes } from './routes/widget-public.routes.js';
import { widgetRoutes } from './routes/widget.routes.js';
export function createApp(): Express {
const app = express();
@@ -49,6 +51,9 @@ export function createApp(): Express {
engine.handleWebhookRequest(req, res);
});
// Public widget routes (unauthenticated — embeddable on external sites)
app.use('/api/widget', widgetPublicRoutes);
// Protected routes
app.use('/api', authMiddleware);
app.use('/api/servers', serverRoutes);
@@ -72,6 +77,7 @@ export function createApp(): Express {
app.use('/api/servers/:configId/music-library', musicLibraryRoutes);
app.use('/api/playlists', playlistRoutes);
app.use('/api/servers/:configId/radio-stations', radioStationRoutes);
app.use('/api/widgets', widgetRoutes);
// Error handler (must be last)
app.use(errorHandler);
@@ -0,0 +1,114 @@
import { Router, Request, Response } from 'express';
import type { ConnectionPool } from '../ts-client/connection-pool.js';
import { buildWidgetTree } from '../widget/build-widget-tree.js';
import { renderWidgetSvg } from '../widget/widget-svg.js';
import type { WidgetData } from '@ts6/common';
// Simple in-process cache (45s TTL)
interface CacheEntry { data: WidgetData; expiresAt: number; }
export const widgetDataCache = new Map<string, CacheEntry>();
const CACHE_TTL_MS = 45_000;
async function getWidgetData(token: string, req: Request): Promise<WidgetData | null> {
const cached = widgetDataCache.get(token);
if (cached && cached.expiresAt > Date.now()) return cached.data;
const prisma = req.app.locals.prisma;
const widget = await prisma.widget.findUnique({ where: { token } });
if (!widget) return null;
const pool: ConnectionPool = req.app.locals.connectionPool;
let client;
try {
client = pool.getClient(widget.serverConfigId);
} catch {
return null; // server offline
}
const sid = widget.virtualServerId;
const [serverInfoRaw, channelListRaw, clientListRaw] = await Promise.all([
client.execute(sid, 'serverinfo'),
client.execute(sid, 'channellist'),
client.execute(sid, 'clientlist'),
]);
const info = Array.isArray(serverInfoRaw) ? serverInfoRaw[0] : serverInfoRaw;
const channels = Array.isArray(channelListRaw) ? channelListRaw : [];
const clients = Array.isArray(clientListRaw) ? clientListRaw : [];
const onlineClients = clients.filter((c: any) => String(c.client_type) === '0');
const data: WidgetData = {
serverName: info.virtualserver_name || 'TeamSpeak Server',
onlineUsers: onlineClients.length,
maxClients: Number(info.virtualserver_maxclients) || 0,
uptime: Number(info.virtualserver_uptime) || 0,
platform: info.virtualserver_platform || '',
version: info.virtualserver_version || '',
theme: widget.theme as any,
showChannelTree: widget.showChannelTree,
showClients: widget.showClients,
channelTree: widget.showChannelTree
? buildWidgetTree(channels, clients, widget.maxChannelDepth, widget.showClients)
: [],
fetchedAt: new Date().toISOString(),
};
widgetDataCache.set(token, { data, expiresAt: Date.now() + CACHE_TTL_MS });
return data;
}
export const widgetPublicRoutes: Router = Router();
// GET /:token/data — JSON widget data
widgetPublicRoutes.get('/:token/data', async (req: Request, res: Response, next) => {
try {
const token = req.params.token as string;
const data = await getWidgetData(token, req);
if (!data) return res.status(404).json({ error: 'Widget not found or server offline' });
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=45');
res.json(data);
} catch (err) { next(err); }
});
// GET /:token/image.svg — SVG image
widgetPublicRoutes.get('/:token/image.svg', async (req: Request, res: Response, next) => {
try {
const token = req.params.token as string;
const data = await getWidgetData(token, req);
if (!data) return res.status(404).send('Widget not found');
const svg = renderWidgetSvg(data);
res.setHeader('Content-Type', 'image/svg+xml');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=45');
res.send(svg);
} catch (err) { next(err); }
});
// GET /:token/image.png — PNG image
widgetPublicRoutes.get('/:token/image.png', async (req: Request, res: Response, next) => {
try {
const token = req.params.token as string;
const data = await getWidgetData(token, req);
if (!data) return res.status(404).send('Widget not found');
const svg = renderWidgetSvg(data);
let pngBuffer: Buffer;
try {
const { Resvg } = await import('@resvg/resvg-js');
const resvg = new Resvg(svg, { fitTo: { mode: 'width' as const, value: 400 } });
pngBuffer = Buffer.from(resvg.render().asPng());
} catch {
// If @resvg/resvg-js is not available, fall back to SVG
res.setHeader('Content-Type', 'image/svg+xml');
res.setHeader('Access-Control-Allow-Origin', '*');
return res.send(svg);
}
res.setHeader('Content-Type', 'image/png');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=45');
res.send(pngBuffer);
} catch (err) { next(err); }
});
@@ -0,0 +1,111 @@
import { Router, Request, Response } from 'express';
import { nanoid } from 'nanoid';
import { requireRole } from '../middleware/rbac.js';
import { AppError } from '../middleware/error-handler.js';
import { widgetDataCache } from './widget-public.routes.js';
export const widgetRoutes: Router = Router();
// GET / — List all widgets
widgetRoutes.get('/', async (req: Request, res: Response, next) => {
try {
const prisma = req.app.locals.prisma;
const widgets = await prisma.widget.findMany({
orderBy: { createdAt: 'desc' },
include: { serverConfig: { select: { id: true, name: true, host: true } } },
});
res.json(widgets);
} catch (err) { next(err); }
});
// POST / — Create widget (admin only)
widgetRoutes.post('/', requireRole('admin'), async (req: Request, res: Response, next) => {
try {
const prisma = req.app.locals.prisma;
const { name, serverConfigId, virtualServerId, theme, showChannelTree, showClients, maxChannelDepth } = req.body;
if (!name || !serverConfigId) throw new AppError(400, 'name and serverConfigId are required');
const serverConfig = await prisma.tsServerConfig.findUnique({ where: { id: serverConfigId } });
if (!serverConfig) throw new AppError(404, 'Server config not found');
const widget = await prisma.widget.create({
data: {
name,
token: nanoid(21),
serverConfigId,
virtualServerId: virtualServerId ?? 1,
theme: theme ?? 'dark',
showChannelTree: showChannelTree ?? true,
showClients: showClients ?? true,
maxChannelDepth: maxChannelDepth ?? 5,
},
include: { serverConfig: { select: { id: true, name: true, host: true } } },
});
res.status(201).json(widget);
} catch (err) { next(err); }
});
// PATCH /:id — Update widget (admin only)
widgetRoutes.patch('/:id', requireRole('admin'), async (req: Request, res: Response, next) => {
try {
const prisma = req.app.locals.prisma;
const id = parseInt(req.params.id as string);
const { name, theme, showChannelTree, showClients, maxChannelDepth } = req.body;
// Invalidate cache for this widget's token
const existing = await prisma.widget.findUnique({ where: { id } });
if (!existing) throw new AppError(404, 'Widget not found');
widgetDataCache.delete(existing.token);
const widget = await prisma.widget.update({
where: { id },
data: {
...(name !== undefined && { name }),
...(theme !== undefined && { theme }),
...(showChannelTree !== undefined && { showChannelTree }),
...(showClients !== undefined && { showClients }),
...(maxChannelDepth !== undefined && { maxChannelDepth }),
},
include: { serverConfig: { select: { id: true, name: true, host: true } } },
});
res.json(widget);
} catch (err) { next(err); }
});
// DELETE /:id — Delete widget (admin only)
widgetRoutes.delete('/:id', requireRole('admin'), async (req: Request, res: Response, next) => {
try {
const prisma = req.app.locals.prisma;
const id = parseInt(req.params.id as string);
const existing = await prisma.widget.findUnique({ where: { id } });
if (!existing) throw new AppError(404, 'Widget not found');
widgetDataCache.delete(existing.token);
await prisma.widget.delete({ where: { id } });
res.json({ success: true });
} catch (err) { next(err); }
});
// POST /:id/regenerate-token — Rotate token (admin only)
widgetRoutes.post('/:id/regenerate-token', requireRole('admin'), async (req: Request, res: Response, next) => {
try {
const prisma = req.app.locals.prisma;
const id = parseInt(req.params.id as string);
const existing = await prisma.widget.findUnique({ where: { id } });
if (!existing) throw new AppError(404, 'Widget not found');
widgetDataCache.delete(existing.token);
const widget = await prisma.widget.update({
where: { id },
data: { token: nanoid(21) },
include: { serverConfig: { select: { id: true, name: true, host: true } } },
});
res.json(widget);
} catch (err) { next(err); }
});
@@ -0,0 +1,59 @@
import type { WidgetChannelNode, WidgetClient } from '@ts6/common';
export function buildWidgetTree(
channels: any[],
clients: any[],
maxDepth: number,
showClients: boolean,
): WidgetChannelNode[] {
// Group human clients by channel
const clientsByChannel = new Map<number, WidgetClient[]>();
if (showClients) {
for (const c of clients) {
if (String(c.client_type) !== '0') continue;
const cid = Number(c.cid);
if (!clientsByChannel.has(cid)) clientsByChannel.set(cid, []);
clientsByChannel.get(cid)!.push({
clid: Number(c.clid),
nickname: String(c.client_nickname || '?'),
isAway: Number(c.client_away) === 1,
isMuted: Number(c.client_input_muted) === 1,
});
}
}
// Build flat map
const map = new Map<number, WidgetChannelNode>();
const roots: WidgetChannelNode[] = [];
for (const ch of channels) {
const cid = Number(ch.cid);
map.set(cid, {
cid,
name: String(ch.channel_name || ''),
hasPassword: Number(ch.channel_flag_password) === 1,
clients: clientsByChannel.get(cid) ?? [],
children: [],
});
}
// Link parents
for (const ch of channels) {
const cid = Number(ch.cid);
const pid = Number(ch.pid);
const node = map.get(cid)!;
if (pid === 0) {
roots.push(node);
} else {
map.get(pid)?.children.push(node);
}
}
// Prune depth
function pruneDepth(nodes: WidgetChannelNode[], depth: number): WidgetChannelNode[] {
if (depth >= maxDepth) return nodes.map(n => ({ ...n, children: [] }));
return nodes.map(n => ({ ...n, children: pruneDepth(n.children, depth + 1) }));
}
return pruneDepth(roots, 0);
}
+157
View File
@@ -0,0 +1,157 @@
import type { WidgetData, WidgetChannelNode, WidgetTheme, WidgetThemePalette } from '@ts6/common';
import { WIDGET_THEMES } from '@ts6/common';
const WIDTH = 400;
const PADDING = 14;
const HEADER_HEIGHT = 72;
const CHANNEL_ROW = 22;
const CLIENT_ROW = 18;
const FOOTER_HEIGHT = 28;
const FONT = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function countTreeRows(nodes: WidgetChannelNode[], showClients: boolean): number {
let count = 0;
for (const node of nodes) {
count += 1; // channel row
if (showClients) count += node.clients.length;
count += countTreeRows(node.children, showClients);
}
return count;
}
export function renderWidgetSvg(data: WidgetData): string {
const theme = WIDGET_THEMES[data.theme] || WIDGET_THEMES.dark;
const treeRows = data.showChannelTree
? countTreeRows(data.channelTree, data.showClients)
: 0;
const treeHeight = treeRows * CHANNEL_ROW;
const totalHeight = HEADER_HEIGHT + treeHeight + FOOTER_HEIGHT + PADDING * 2;
const lines: string[] = [];
let y = PADDING;
// --- SVG open ---
lines.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${totalHeight}" viewBox="0 0 ${WIDTH} ${totalHeight}">`);
// --- Defs (rounded clip) ---
lines.push(`<defs>`);
lines.push(`<clipPath id="rc"><rect x="0" y="0" width="${WIDTH}" height="${totalHeight}" rx="10"/></clipPath>`);
lines.push(`</defs>`);
lines.push(`<g clip-path="url(#rc)">`);
// --- Background ---
lines.push(`<rect width="${WIDTH}" height="${totalHeight}" fill="${theme.background}"/>`);
// --- Header background ---
lines.push(`<rect x="0" y="0" width="${WIDTH}" height="${HEADER_HEIGHT}" fill="${theme.headerBg}"/>`);
// --- Server name ---
y += 28;
const serverName = escapeXml(truncate(data.serverName, 38));
lines.push(`<text x="${PADDING}" y="${y}" fill="${theme.accent}" font-family="${FONT}" font-size="15" font-weight="700">${serverName}</text>`);
// --- ONLINE badge ---
const badgeX = WIDTH - PADDING - 52;
lines.push(`<rect x="${badgeX}" y="${y - 13}" width="52" height="18" rx="9" fill="${theme.clientColor}"/>`);
lines.push(`<text x="${badgeX + 26}" y="${y}" fill="#fff" font-family="${FONT}" font-size="10" font-weight="600" text-anchor="middle">ONLINE</text>`);
// --- Stats row ---
y += 22;
const stats = `${data.onlineUsers} / ${data.maxClients} users • ${formatUptime(data.uptime)} uptime`;
lines.push(`<text x="${PADDING}" y="${y}" fill="${theme.textSecondary}" font-family="${FONT}" font-size="11">${escapeXml(stats)}</text>`);
// --- Separator ---
y += 14;
lines.push(`<line x1="${PADDING}" y1="${y}" x2="${WIDTH - PADDING}" y2="${y}" stroke="${theme.border}" stroke-width="1"/>`);
y += 8;
// --- Channel tree ---
if (data.showChannelTree) {
y = renderTreeNodes(lines, data.channelTree, data.showClients, theme, y, 0);
}
// --- Footer ---
const footerY = totalHeight - 10;
lines.push(`<line x1="${PADDING}" y1="${footerY - 14}" x2="${WIDTH - PADDING}" y2="${footerY - 14}" stroke="${theme.border}" stroke-width="1"/>`);
lines.push(`<text x="${WIDTH / 2}" y="${footerY}" fill="${theme.textSecondary}" font-family="${FONT}" font-size="9" text-anchor="middle" opacity="0.6">TS6 WebUI Widget</text>`);
lines.push(`</g></svg>`);
return lines.join('\n');
}
function renderTreeNodes(
lines: string[],
nodes: WidgetChannelNode[],
showClients: boolean,
theme: WidgetThemePalette,
y: number,
depth: number,
): number {
const indent = PADDING + depth * 16;
for (const node of nodes) {
// Channel row
const channelName = escapeXml(truncate(node.name, 36 - depth * 2));
const hashX = indent;
const textX = indent + 14;
// Channel icon (#)
lines.push(`<text x="${hashX}" y="${y + 14}" fill="${theme.accent}" font-family="${FONT}" font-size="12" font-weight="700">#</text>`);
lines.push(`<text x="${textX}" y="${y + 14}" fill="${theme.textPrimary}" font-family="${FONT}" font-size="12">${channelName}</text>`);
// Password lock
if (node.hasPassword) {
lines.push(`<text x="${WIDTH - PADDING - 12}" y="${y + 14}" fill="${theme.textSecondary}" font-family="${FONT}" font-size="10">🔒</text>`);
}
// Client count badge
if (node.clients.length > 0) {
const badgeX2 = node.hasPassword ? WIDTH - PADDING - 36 : WIDTH - PADDING - 18;
lines.push(`<text x="${badgeX2}" y="${y + 14}" fill="${theme.textSecondary}" font-family="${FONT}" font-size="10" text-anchor="end">${node.clients.length}</text>`);
}
y += CHANNEL_ROW;
// Client entries
if (showClients) {
for (const client of node.clients) {
const clientX = indent + 18;
let displayName = escapeXml(truncate(client.nickname, 32 - depth * 2));
if (client.isAway) displayName += ' [away]';
if (client.isMuted) displayName += ' [muted]';
// Small dot indicator
lines.push(`<circle cx="${indent + 10}" cy="${y + 10}" r="3" fill="${theme.clientColor}"/>`);
lines.push(`<text x="${clientX}" y="${y + 13}" fill="${theme.clientColor}" font-family="${FONT}" font-size="11">${displayName}</text>`);
y += CLIENT_ROW;
}
}
// Recurse children
y = renderTreeNodes(lines, node.children, showClients, theme, y, depth + 1);
}
return y;
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + '…' : s;
}
+1
View File
@@ -5,3 +5,4 @@ export * from './types/auth.js';
export * from './types/music.js';
export * from './constants/events.js';
export * from './utils/ts-escape.js';
export * from './widget-themes.js';
+65
View File
@@ -200,3 +200,68 @@ export interface PaginatedResponse<T> {
offset: number;
limit: number;
}
// === Server Widget / Banner ===
export type WidgetTheme = 'dark' | 'light' | 'transparent' | 'neon' | 'military' | 'minimal';
export interface WidgetConfig {
id: number;
name: string;
token: string;
serverConfigId: number;
virtualServerId: number;
theme: WidgetTheme;
showChannelTree: boolean;
showClients: boolean;
maxChannelDepth: number;
createdAt: string;
updatedAt: string;
}
export interface CreateWidgetRequest {
name: string;
serverConfigId: number;
virtualServerId?: number;
theme?: WidgetTheme;
showChannelTree?: boolean;
showClients?: boolean;
maxChannelDepth?: number;
}
export interface UpdateWidgetRequest {
name?: string;
theme?: WidgetTheme;
showChannelTree?: boolean;
showClients?: boolean;
maxChannelDepth?: number;
}
export interface WidgetData {
serverName: string;
onlineUsers: number;
maxClients: number;
uptime: number;
platform: string;
version: string;
theme: WidgetTheme;
showChannelTree: boolean;
showClients: boolean;
channelTree: WidgetChannelNode[];
fetchedAt: string;
}
export interface WidgetChannelNode {
cid: number;
name: string;
hasPassword: boolean;
clients: WidgetClient[];
children: WidgetChannelNode[];
}
export interface WidgetClient {
clid: number;
nickname: string;
isAway: boolean;
isMuted: boolean;
}
+84
View File
@@ -0,0 +1,84 @@
import type { WidgetTheme } from './types/api.js';
export interface WidgetThemePalette {
background: string;
backgroundSecondary: string;
border: string;
textPrimary: string;
textSecondary: string;
accent: string;
clientColor: string;
headerBg: string;
}
export const WIDGET_THEMES: Record<WidgetTheme, WidgetThemePalette> = {
dark: {
background: '#0f1117',
backgroundSecondary: '#161b22',
border: '#30363d',
textPrimary: '#e6edf3',
textSecondary: '#8b949e',
accent: '#58a6ff',
clientColor: '#3fb950',
headerBg: '#21262d',
},
light: {
background: '#ffffff',
backgroundSecondary: '#f6f8fa',
border: '#d0d7de',
textPrimary: '#1f2328',
textSecondary: '#636c76',
accent: '#0969da',
clientColor: '#1a7f37',
headerBg: '#f6f8fa',
},
transparent: {
background: 'rgba(0,0,0,0.65)',
backgroundSecondary: 'rgba(255,255,255,0.05)',
border: 'rgba(255,255,255,0.12)',
textPrimary: '#ffffff',
textSecondary: 'rgba(255,255,255,0.55)',
accent: '#60a5fa',
clientColor: '#4ade80',
headerBg: 'rgba(0,0,0,0.4)',
},
neon: {
background: '#020510',
backgroundSecondary: '#080d1a',
border: '#00e5ff',
textPrimary: '#e0f7ff',
textSecondary: '#7ecfef',
accent: '#00ffff',
clientColor: '#00ff88',
headerBg: '#050c18',
},
military: {
background: '#1a1f0e',
backgroundSecondary: '#232a12',
border: '#4a5a2a',
textPrimary: '#c8d4a0',
textSecondary: '#8a9860',
accent: '#8ab840',
clientColor: '#70a030',
headerBg: '#151c0a',
},
minimal: {
background: '#fafafa',
backgroundSecondary: '#f4f4f5',
border: '#e4e4e7',
textPrimary: '#18181b',
textSecondary: '#71717a',
accent: '#18181b',
clientColor: '#16a34a',
headerBg: '#f4f4f5',
},
};
export const WIDGET_THEME_LABELS: Record<WidgetTheme, string> = {
dark: 'Dark',
light: 'Light',
transparent: 'Transparent',
neon: 'Neon / Cyberpunk',
military: 'Military / OD Green',
minimal: 'Minimal / Clean',
};
+2
View File
@@ -42,6 +42,7 @@ const BotEditor = lazy(() => import('@/pages/BotEditor'));
const MusicBots = lazy(() => import('@/pages/MusicBots'));
const Settings = lazy(() => import('@/pages/Settings'));
const NotFound = lazy(() => import('@/pages/NotFound'));
const WidgetPage = lazy(() => import('@/pages/WidgetPage'));
export function App() {
return (
@@ -50,6 +51,7 @@ export function App() {
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/widget/:token" element={<WidgetPage />} />
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
+19
View File
@@ -0,0 +1,19 @@
import api from './client';
import type { WidgetConfig, CreateWidgetRequest, UpdateWidgetRequest } from '@ts6/common';
export const widgetApi = {
list: (): Promise<WidgetConfig[]> =>
api.get('/widgets').then((r) => r.data),
create: (data: CreateWidgetRequest): Promise<WidgetConfig> =>
api.post('/widgets', data).then((r) => r.data),
update: (id: number, data: UpdateWidgetRequest): Promise<WidgetConfig> =>
api.patch(`/widgets/${id}`, data).then((r) => r.data),
delete: (id: number): Promise<void> =>
api.delete(`/widgets/${id}`),
regenerateToken: (id: number): Promise<WidgetConfig> =>
api.post(`/widgets/${id}/regenerate-token`).then((r) => r.data),
};
@@ -0,0 +1,349 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Slider } from '@/components/ui/slider';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { useWidgets, useCreateWidget, useUpdateWidget, useDeleteWidget, useRegenerateWidgetToken } from '@/hooks/use-widgets';
import { useQuery } from '@tanstack/react-query';
import { serversApi } from '@/api/servers.api';
import type { WidgetConfig, WidgetTheme, ServerConfig } from '@ts6/common';
import { WIDGET_THEME_LABELS } from '@ts6/common';
import { Copy, ExternalLink, Plus, Trash2, Pencil, RefreshCw, ArrowLeft, Code2, Image } from 'lucide-react';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
type ViewState = 'list' | 'create' | 'edit' | 'embed';
const THEME_OPTIONS = Object.entries(WIDGET_THEME_LABELS) as [WidgetTheme, string][];
function copyText(text: string) {
navigator.clipboard.writeText(text);
}
export function WidgetManagerModal({ open, onOpenChange }: Props) {
const { data: widgets = [], isLoading } = useWidgets();
const { data: servers = [] } = useQuery<ServerConfig[]>({ queryKey: ['servers'], queryFn: serversApi.list });
const createWidget = useCreateWidget();
const updateWidget = useUpdateWidget();
const deleteWidget = useDeleteWidget();
const regenerateToken = useRegenerateWidgetToken();
const [view, setView] = useState<ViewState>('list');
const [editTarget, setEditTarget] = useState<WidgetConfig | null>(null);
const [embedTarget, setEmbedTarget] = useState<WidgetConfig | null>(null);
// Form state
const [name, setName] = useState('');
const [serverConfigId, setServerConfigId] = useState<number>(0);
const [virtualServerId, setVirtualServerId] = useState(1);
const [theme, setTheme] = useState<WidgetTheme>('dark');
const [showChannelTree, setShowChannelTree] = useState(true);
const [showClients, setShowClients] = useState(true);
const [maxChannelDepth, setMaxChannelDepth] = useState(5);
const resetForm = () => {
setName('');
setServerConfigId(servers[0]?.id ?? 0);
setVirtualServerId(1);
setTheme('dark');
setShowChannelTree(true);
setShowClients(true);
setMaxChannelDepth(5);
};
const openCreate = () => {
resetForm();
setServerConfigId(servers[0]?.id ?? 0);
setView('create');
};
const openEdit = (w: WidgetConfig) => {
setEditTarget(w);
setName(w.name);
setTheme(w.theme);
setShowChannelTree(w.showChannelTree);
setShowClients(w.showClients);
setMaxChannelDepth(w.maxChannelDepth);
setView('edit');
};
const openEmbed = (w: WidgetConfig) => {
setEmbedTarget(w);
setView('embed');
};
const handleCreate = async () => {
if (!name.trim() || !serverConfigId) return;
await createWidget.mutateAsync({
name: name.trim(),
serverConfigId,
virtualServerId,
theme,
showChannelTree,
showClients,
maxChannelDepth,
});
setView('list');
};
const handleUpdate = async () => {
if (!editTarget || !name.trim()) return;
await updateWidget.mutateAsync({
id: editTarget.id,
data: { name: name.trim(), theme, showChannelTree, showClients, maxChannelDepth },
});
setView('list');
};
const handleDelete = async (id: number) => {
await deleteWidget.mutateAsync(id);
};
const handleRegenerate = async (w: WidgetConfig) => {
await regenerateToken.mutateAsync(w.id);
if (embedTarget?.id === w.id) {
// Refresh embed target with new token
const updated = widgets.find(x => x.id === w.id);
if (updated) setEmbedTarget(updated);
}
};
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return (
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) setView('list'); }}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{view !== 'list' && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setView('list')}>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
{view === 'list' && 'Server Widgets'}
{view === 'create' && 'Create Widget'}
{view === 'edit' && 'Edit Widget'}
{view === 'embed' && 'Embed Code'}
</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 -mx-6 px-6">
{/* LIST VIEW */}
{view === 'list' && (
<div className="space-y-3">
<Button size="sm" onClick={openCreate} disabled={servers.length === 0}>
<Plus className="h-3.5 w-3.5 mr-1.5" /> New Widget
</Button>
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{widgets.length === 0 && !isLoading && (
<p className="text-sm text-muted-foreground py-8 text-center">
No widgets yet. Create one to embed your server status on external sites.
</p>
)}
{widgets.map((w) => (
<div key={w.id} className="border rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{w.name}</span>
<Badge variant="outline" className="text-[10px]">
{WIDGET_THEME_LABELS[w.theme] || w.theme}
</Badge>
</div>
</div>
<p className="text-xs text-muted-foreground">
Server: {(w as any).serverConfig?.name || `#${w.serverConfigId}`} | VS {w.virtualServerId}
</p>
<div className="flex items-center gap-1.5 flex-wrap">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEmbed(w)}>
<Code2 className="h-3 w-3 mr-1" /> Embed
</Button>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => window.open(`/widget/${w.token}`, '_blank')}>
<ExternalLink className="h-3 w-3 mr-1" /> Preview
</Button>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => window.open(`/api/widget/${w.token}/image.svg`, '_blank')}>
<Image className="h-3 w-3 mr-1" /> SVG
</Button>
<Button size="sm" variant="ghost" className="h-7 text-xs" onClick={() => openEdit(w)}>
<Pencil className="h-3 w-3 mr-1" /> Edit
</Button>
<Button size="sm" variant="ghost" className="h-7 text-xs text-destructive" onClick={() => handleDelete(w.id)}>
<Trash2 className="h-3 w-3 mr-1" /> Delete
</Button>
</div>
</div>
))}
</div>
)}
{/* CREATE / EDIT VIEW */}
{(view === 'create' || view === 'edit') && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="My Server Widget" />
</div>
{view === 'create' && (
<>
<div className="space-y-2">
<Label>Server</Label>
<Select value={String(serverConfigId)} onValueChange={(v) => setServerConfigId(Number(v))}>
<SelectTrigger><SelectValue placeholder="Select server" /></SelectTrigger>
<SelectContent>
{servers.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Virtual Server ID</Label>
<Input type="number" min={1} value={virtualServerId} onChange={(e) => setVirtualServerId(Number(e.target.value))} />
</div>
</>
)}
<div className="space-y-2">
<Label>Theme</Label>
<Select value={theme} onValueChange={(v) => setTheme(v as WidgetTheme)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{THEME_OPTIONS.map(([key, label]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator />
<div className="flex items-center justify-between">
<Label>Show Channel Tree</Label>
<Switch checked={showChannelTree} onCheckedChange={setShowChannelTree} />
</div>
<div className="flex items-center justify-between">
<Label>Show Clients</Label>
<Switch checked={showClients} onCheckedChange={setShowClients} />
</div>
<div className="space-y-2">
<Label>Max Channel Depth: {maxChannelDepth}</Label>
<Slider min={1} max={10} step={1} value={[maxChannelDepth]} onValueChange={([v]) => setMaxChannelDepth(v)} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setView('list')}>Cancel</Button>
<Button onClick={view === 'create' ? handleCreate : handleUpdate} disabled={!name.trim()}>
{view === 'create' ? 'Create' : 'Save'}
</Button>
</div>
</div>
)}
{/* EMBED VIEW */}
{view === 'embed' && embedTarget && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="font-medium text-sm">{embedTarget.name}</p>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => handleRegenerate(embedTarget)}>
<RefreshCw className="h-3 w-3 mr-1" /> Regenerate Token
</Button>
</div>
<Tabs defaultValue="iframe">
<TabsList className="w-full">
<TabsTrigger value="iframe" className="flex-1">iFrame</TabsTrigger>
<TabsTrigger value="image" className="flex-1">Image URLs</TabsTrigger>
<TabsTrigger value="bbcode" className="flex-1">BBCode</TabsTrigger>
</TabsList>
<TabsContent value="iframe" className="space-y-2">
<Label className="text-xs text-muted-foreground">Paste this into your website HTML:</Label>
<div className="relative">
<code className="block bg-muted rounded-md p-3 text-xs font-mono break-all leading-relaxed">
{`<iframe src="${origin}/widget/${embedTarget.token}" width="420" height="600" frameborder="0" scrolling="auto" style="border-radius:8px;border:none;"></iframe>`}
</code>
<Button
size="icon" variant="ghost"
className="absolute top-1 right-1 h-7 w-7"
onClick={() => copyText(`<iframe src="${origin}/widget/${embedTarget.token}" width="420" height="600" frameborder="0" scrolling="auto" style="border-radius:8px;border:none;"></iframe>`)}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</TabsContent>
<TabsContent value="image" className="space-y-3">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">SVG (scalable, best quality):</Label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted rounded-md p-2 text-xs font-mono break-all">
{`${origin}/api/widget/${embedTarget.token}/image.svg`}
</code>
<Button size="icon" variant="ghost" className="h-7 w-7 flex-shrink-0" onClick={() => copyText(`${origin}/api/widget/${embedTarget.token}/image.svg`)}>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">PNG (for forums / signatures):</Label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted rounded-md p-2 text-xs font-mono break-all">
{`${origin}/api/widget/${embedTarget.token}/image.png`}
</code>
<Button size="icon" variant="ghost" className="h-7 w-7 flex-shrink-0" onClick={() => copyText(`${origin}/api/widget/${embedTarget.token}/image.png`)}>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">HTML img tag:</Label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted rounded-md p-2 text-xs font-mono break-all">
{`<img src="${origin}/api/widget/${embedTarget.token}/image.png" alt="TeamSpeak Server" />`}
</code>
<Button size="icon" variant="ghost" className="h-7 w-7 flex-shrink-0" onClick={() => copyText(`<img src="${origin}/api/widget/${embedTarget.token}/image.png" alt="TeamSpeak Server" />`)}>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="bbcode" className="space-y-2">
<Label className="text-xs text-muted-foreground">Forum BBCode (uses PNG):</Label>
<div className="relative">
<code className="block bg-muted rounded-md p-3 text-xs font-mono break-all">
{`[img]${origin}/api/widget/${embedTarget.token}/image.png[/img]`}
</code>
<Button
size="icon" variant="ghost"
className="absolute top-1 right-1 h-7 w-7"
onClick={() => copyText(`[img]${origin}/api/widget/${embedTarget.token}/image.png[/img]`)}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</TabsContent>
</Tabs>
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,163 @@
import type { WidgetData, WidgetChannelNode as WidgetChannelNodeType, WidgetTheme } from '@ts6/common';
import { WIDGET_THEMES } from '@ts6/common';
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function ChannelNode({ node, depth, showClients, theme }: {
node: WidgetChannelNodeType;
depth: number;
showClients: boolean;
theme: WidgetTheme;
}) {
const t = WIDGET_THEMES[theme];
const indent = depth * 16;
return (
<div>
{/* Channel row */}
<div style={{
paddingLeft: `${indent + 4}px`,
paddingTop: '3px',
paddingBottom: '3px',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '12px',
}}>
<span style={{ color: t.accent, fontWeight: 700, fontSize: '11px', flexShrink: 0 }}>#</span>
<span style={{ color: t.textPrimary, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{node.name}
</span>
{node.hasPassword && (
<span style={{ color: t.textSecondary, fontSize: '10px', flexShrink: 0 }}>🔒</span>
)}
{node.clients.length > 0 && (
<span style={{ color: t.textSecondary, fontSize: '10px', flexShrink: 0 }}>{node.clients.length}</span>
)}
</div>
{/* Clients */}
{showClients && node.clients.map((client) => (
<div key={client.clid} style={{
paddingLeft: `${indent + 22}px`,
paddingTop: '1px',
paddingBottom: '1px',
fontSize: '11px',
color: t.clientColor,
display: 'flex',
alignItems: 'center',
gap: '5px',
}}>
<span style={{
width: '6px', height: '6px', borderRadius: '50%',
background: t.clientColor, flexShrink: 0,
}} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{client.nickname}
{client.isAway && <span style={{ color: t.textSecondary }}> [away]</span>}
{client.isMuted && <span style={{ color: t.textSecondary }}> [muted]</span>}
</span>
</div>
))}
{/* Children */}
{node.children.map((child) => (
<ChannelNode key={child.cid} node={child} depth={depth + 1} showClients={showClients} theme={theme} />
))}
</div>
);
}
export function WidgetRenderer({ data }: { data: WidgetData }) {
const t = WIDGET_THEMES[data.theme] || WIDGET_THEMES.dark;
return (
<div style={{
background: t.background,
color: t.textPrimary,
fontFamily: "'Segoe UI', 'Helvetica Neue', Arial, sans-serif",
fontSize: '13px',
minHeight: '100vh',
padding: '14px',
boxSizing: 'border-box',
}}>
{/* Header */}
<div style={{
borderBottom: `1px solid ${t.border}`,
paddingBottom: '10px',
marginBottom: '10px',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{
fontWeight: 700,
color: t.accent,
fontSize: '15px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
marginRight: '8px',
}}>
{data.serverName}
</span>
<span style={{
background: t.clientColor,
color: '#fff',
fontSize: '10px',
padding: '2px 8px',
borderRadius: '9999px',
fontWeight: 600,
flexShrink: 0,
letterSpacing: '0.5px',
}}>
ONLINE
</span>
</div>
<div style={{
marginTop: '6px',
color: t.textSecondary,
fontSize: '11px',
display: 'flex',
gap: '14px',
}}>
<span>{data.onlineUsers} / {data.maxClients} users</span>
<span>{formatUptime(data.uptime)} uptime</span>
</div>
</div>
{/* Channel Tree */}
{data.showChannelTree && data.channelTree.length > 0 && (
<div style={{ marginBottom: '10px' }}>
{data.channelTree.map((node) => (
<ChannelNode
key={node.cid}
node={node}
depth={0}
showClients={data.showClients}
theme={data.theme}
/>
))}
</div>
)}
{/* Footer */}
<div style={{
borderTop: `1px solid ${t.border}`,
paddingTop: '8px',
textAlign: 'center',
color: t.textSecondary,
fontSize: '9px',
opacity: 0.6,
}}>
TS6 WebUI Widget
</div>
</div>
);
}
@@ -0,0 +1,42 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { widgetApi } from '@/api/widget.api';
import type { CreateWidgetRequest, UpdateWidgetRequest } from '@ts6/common';
export function useWidgets() {
return useQuery({
queryKey: ['widgets'],
queryFn: widgetApi.list,
});
}
export function useCreateWidget() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateWidgetRequest) => widgetApi.create(data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['widgets'] }),
});
}
export function useUpdateWidget() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateWidgetRequest }) => widgetApi.update(id, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['widgets'] }),
});
}
export function useDeleteWidget() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => widgetApi.delete(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['widgets'] }),
});
}
export function useRegenerateWidgetToken() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => widgetApi.regenerateToken(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['widgets'] }),
});
}
+20 -6
View File
@@ -1,11 +1,14 @@
import { useDashboard } from '@/hooks/use-dashboard';
import { useServerStore } from '@/stores/server.store';
import { useAuthStore } from '@/stores/auth.store';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { PageLoader } from '@/components/shared/LoadingSpinner';
import { EmptyState } from '@/components/shared/EmptyState';
import { WidgetManagerModal } from '@/components/widget/WidgetManagerModal';
import { formatBytes, formatUptime } from '@/lib/utils';
import { Users, Activity, Clock, Hash, ArrowDownToLine, ArrowUpFromLine, Wifi, Server } from 'lucide-react';
import { Users, Activity, Clock, Hash, ArrowDownToLine, ArrowUpFromLine, Wifi, Server, LayoutGrid } from 'lucide-react';
import { AreaChart, Area, XAxis, YAxis, Tooltip as ReTooltip, ResponsiveContainer } from 'recharts';
import { useState, useEffect } from 'react';
@@ -40,7 +43,9 @@ function StatsCard({ icon: Icon, label, value, sub, accentColor = 'text-primary'
export default function Dashboard() {
const { selectedConfigId, selectedSid } = useServerStore();
const { data, isLoading, error } = useDashboard();
const isAdmin = useAuthStore((s) => s.isAdmin());
const [bandwidthHistory, setBandwidthHistory] = useState<any[]>([]);
const [showWidgets, setShowWidgets] = useState(false);
// Build bandwidth history from periodic data
useEffect(() => {
@@ -91,11 +96,18 @@ export default function Dashboard() {
<span className="text-xs text-muted-foreground font-mono-data">{data.version} / {data.platform}</span>
</div>
</div>
<div className="text-right">
<p className="text-[10px] text-muted-foreground font-mono-data uppercase tracking-widest">Live Monitoring</p>
<div className="flex items-center gap-1 justify-end mt-0.5">
<div className="h-1.5 w-1.5 rounded-full bg-emerald-400 pulse-dot" />
<span className="text-[10px] text-emerald-400 font-mono-data">ACTIVE</span>
<div className="flex items-center gap-3">
{isAdmin && (
<Button size="sm" variant="outline" onClick={() => setShowWidgets(true)}>
<LayoutGrid className="h-3.5 w-3.5 mr-1.5" /> Widgets
</Button>
)}
<div className="text-right">
<p className="text-[10px] text-muted-foreground font-mono-data uppercase tracking-widest">Live Monitoring</p>
<div className="flex items-center gap-1 justify-end mt-0.5">
<div className="h-1.5 w-1.5 rounded-full bg-emerald-400 pulse-dot" />
<span className="text-[10px] text-emerald-400 font-mono-data">ACTIVE</span>
</div>
</div>
</div>
</div>
@@ -230,6 +242,8 @@ export default function Dashboard() {
</CardContent>
</Card>
</div>
<WidgetManagerModal open={showWidgets} onOpenChange={setShowWidgets} />
</div>
);
}
@@ -0,0 +1,53 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import type { WidgetData } from '@ts6/common';
import { WidgetRenderer } from '@/components/widget/WidgetRenderer';
export default function WidgetPage() {
const { token } = useParams<{ token: string }>();
useEffect(() => {
document.title = 'TS Server Widget';
const meta = document.createElement('meta');
meta.name = 'robots';
meta.content = 'noindex';
document.head.appendChild(meta);
return () => { document.head.removeChild(meta); };
}, []);
const { data, isLoading, error } = useQuery<WidgetData>({
queryKey: ['widget', token],
queryFn: () => axios.get(`/api/widget/${token}/data`).then((r) => r.data),
enabled: !!token,
refetchInterval: 30_000,
retry: 3,
});
if (isLoading) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100vh', background: '#0f1117', color: '#8b949e',
fontFamily: "'Segoe UI', sans-serif", fontSize: '13px',
}}>
Loading...
</div>
);
}
if (error || !data) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100vh', background: '#0f1117', color: '#8b949e',
fontFamily: "'Segoe UI', sans-serif", fontSize: '13px',
}}>
Widget not available
</div>
);
}
return <WidgetRenderer data={data} />;
}
+144
View File
@@ -17,6 +17,9 @@ importers:
'@prisma/client':
specifier: ^6.3.0
version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)
'@resvg/resvg-js':
specifier: ^2.6.2
version: 2.6.2
'@ts6/common':
specifier: workspace:*
version: link:../common
@@ -53,6 +56,9 @@ importers:
multer:
specifier: ^1.4.5-lts.1
version: 1.4.5-lts.2
nanoid:
specifier: ^5.1.6
version: 5.1.6
node-cron:
specifier: ^3.0.3
version: 3.0.3
@@ -1299,6 +1305,86 @@ packages:
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
engines: {node: '>=14.0.0'}
'@resvg/resvg-js-android-arm-eabi@2.6.2':
resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
'@resvg/resvg-js-android-arm64@2.6.2':
resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@resvg/resvg-js-darwin-arm64@2.6.2':
resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@resvg/resvg-js-darwin-x64@2.6.2':
resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@resvg/resvg-js-linux-arm-gnueabihf@2.6.2':
resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@resvg/resvg-js-linux-arm64-gnu@2.6.2':
resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@resvg/resvg-js-linux-x64-musl@2.6.2':
resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@resvg/resvg-js-win32-ia32-msvc@2.6.2':
resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@resvg/resvg-js-win32-x64-msvc@2.6.2':
resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@resvg/resvg-js@2.6.2':
resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==}
engines: {node: '>= 10'}
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -2343,6 +2429,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.6:
resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
engines: {node: ^18 || >=20}
hasBin: true
negotiator@0.6.3:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
@@ -3936,6 +4027,57 @@ snapshots:
'@remix-run/router@1.23.2': {}
'@resvg/resvg-js-android-arm-eabi@2.6.2':
optional: true
'@resvg/resvg-js-android-arm64@2.6.2':
optional: true
'@resvg/resvg-js-darwin-arm64@2.6.2':
optional: true
'@resvg/resvg-js-darwin-x64@2.6.2':
optional: true
'@resvg/resvg-js-linux-arm-gnueabihf@2.6.2':
optional: true
'@resvg/resvg-js-linux-arm64-gnu@2.6.2':
optional: true
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
optional: true
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
optional: true
'@resvg/resvg-js-linux-x64-musl@2.6.2':
optional: true
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
optional: true
'@resvg/resvg-js-win32-ia32-msvc@2.6.2':
optional: true
'@resvg/resvg-js-win32-x64-msvc@2.6.2':
optional: true
'@resvg/resvg-js@2.6.2':
optionalDependencies:
'@resvg/resvg-js-android-arm-eabi': 2.6.2
'@resvg/resvg-js-android-arm64': 2.6.2
'@resvg/resvg-js-darwin-arm64': 2.6.2
'@resvg/resvg-js-darwin-x64': 2.6.2
'@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2
'@resvg/resvg-js-linux-arm64-gnu': 2.6.2
'@resvg/resvg-js-linux-arm64-musl': 2.6.2
'@resvg/resvg-js-linux-x64-gnu': 2.6.2
'@resvg/resvg-js-linux-x64-musl': 2.6.2
'@resvg/resvg-js-win32-arm64-msvc': 2.6.2
'@resvg/resvg-js-win32-ia32-msvc': 2.6.2
'@resvg/resvg-js-win32-x64-msvc': 2.6.2
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.57.1':
@@ -4974,6 +5116,8 @@ snapshots:
nanoid@3.3.11: {}
nanoid@5.1.6: {}
negotiator@0.6.3: {}
node-cron@3.0.3: