mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
feat: add monitoring dashboard (Prometheus + Grafana + Umami + Admin)
Phase 1: Infrastructure - Add docker/prometheus/prometheus.yml with scrape configs for all services - Add docker/grafana/provisioning for auto-configured datasources - Add docker/grafana/dashboards (system-overview, backends-docker) - Update docker-compose.macmini.yml with monitoring services: - prometheus, grafana, node-exporter, cadvisor - postgres-exporter, redis-exporter, umami - Add grafana.mana.how and analytics.mana.how to Caddyfile Phase 2: Backend Metrics - Create packages/shared-nestjs-metrics with: - MetricsModule (auto /metrics endpoint) - MetricsService (Counter, Histogram, Gauge helpers) - MetricsMiddleware (auto HTTP request tracking) Phase 3: Umami Web Analytics - Add Umami tracking scripts to all landing pages - Add Umami tracking scripts to all web apps - Create scripts/mac-mini/setup-umami-db.sh Phase 4: Admin Dashboard (ManaCore Web) - Add admin routes: /admin, /admin/users, /admin/system - Create StatCard, QuickLinks, UserTable components - Add Admin link to navigation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ad7a84feef
commit
6d86a08d63
36 changed files with 2779 additions and 559 deletions
|
|
@ -31,3 +31,13 @@ SUPABASE_SERVICE_ROLE_KEY=
|
|||
# ============================================
|
||||
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
|
||||
AZURE_OPENAI_API_KEY=your-api-key-here
|
||||
|
||||
# ============================================
|
||||
# Monitoring (Grafana)
|
||||
# ============================================
|
||||
GRAFANA_PASSWORD=your-grafana-admin-password
|
||||
|
||||
# ============================================
|
||||
# Web Analytics (Umami)
|
||||
# ============================================
|
||||
UMAMI_APP_SECRET=your-umami-secret-here
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ const {
|
|||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!-- Umami Analytics -->
|
||||
<script
|
||||
defer
|
||||
src="https://analytics.mana.how/script.js"
|
||||
data-website-id="CALENDAR_LANDING_WEBSITE_ID"></script>
|
||||
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="antialiased">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Calendar</title>
|
||||
%sveltekit.head%
|
||||
<!-- Umami Analytics -->
|
||||
<script defer src="https://analytics.mana.how/script.js" data-website-id="CALENDAR_WEB_WEBSITE_ID"></script>
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ const {
|
|||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!-- Umami Analytics -->
|
||||
<script
|
||||
defer
|
||||
src="https://analytics.mana.how/script.js"
|
||||
data-website-id="CHAT_LANDING_WEBSITE_ID"></script>
|
||||
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="min-h-screen bg-background-page text-text-primary antialiased">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
<!-- Umami Analytics -->
|
||||
<script defer src="https://analytics.mana.how/script.js" data-website-id="CHAT_WEB_WEBSITE_ID"></script>
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
|
|
|
|||
51
apps/clock/apps/landing/src/layouts/Layout.astro
Normal file
51
apps/clock/apps/landing/src/layouts/Layout.astro
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
---
|
||||
import '../styles/global.css';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title = 'Clock - Time Tracking & Focus',
|
||||
description = 'Track your time, stay focused, and boost productivity. Pomodoro timer, time tracking, and focus sessions. Start free today.',
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="de" class="scroll-smooth">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
|
||||
<!-- Preconnect to Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!-- Umami Analytics -->
|
||||
<script
|
||||
defer
|
||||
src="https://analytics.mana.how/script.js"
|
||||
data-website-id="CLOCK_LANDING_WEBSITE_ID"></script>
|
||||
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="antialiased">
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -5,6 +5,8 @@
|
|||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
<!-- Umami Analytics -->
|
||||
<script defer src="https://analytics.mana.how/script.js" data-website-id="CLOCK_WEB_WEBSITE_ID"></script>
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
<!-- Umami Analytics -->
|
||||
<script defer src="https://analytics.mana.how/script.js" data-website-id="CONTACTS_WEB_WEBSITE_ID"></script>
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
|
|
|
|||
|
|
@ -71,8 +71,8 @@ const lang = getLangFromUrl(Astro.url);
|
|||
<!-- Umami Analytics -->
|
||||
<script
|
||||
defer
|
||||
src="https://umami.manacore.ai/script.js"
|
||||
data-website-id="dbafd5bb-f192-4cea-a857-593956c5ef00"></script>
|
||||
src="https://analytics.mana.how/script.js"
|
||||
data-website-id="MANACORE_LANDING_WEBSITE_ID"></script>
|
||||
|
||||
<!-- Alternate language links for SEO -->
|
||||
<AlternateLinks />
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
<!-- Umami Analytics -->
|
||||
<script defer src="https://analytics.mana.how/script.js" data-website-id="MANACORE_WEB_WEBSITE_ID"></script>
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
<script lang="ts">
|
||||
interface Link {
|
||||
name: string;
|
||||
url: string;
|
||||
description: string;
|
||||
icon: 'grafana' | 'analytics' | 'docker' | 'api' | 'external';
|
||||
}
|
||||
|
||||
let { links }: { links: Link[] } = $props();
|
||||
|
||||
const icons = {
|
||||
grafana: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />`,
|
||||
analytics: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 8v8m-4-5v5m-4-2v2m-2 4h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />`,
|
||||
docker: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />`,
|
||||
api: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />`,
|
||||
external: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />`,
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<h3 class="text-lg font-semibold mb-4">Quick Links</h3>
|
||||
<div class="grid gap-3">
|
||||
{#each links as link}
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-3 p-3 rounded-lg border bg-background hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<div class="rounded-md bg-primary/10 p-2">
|
||||
<svg class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
{@html icons[link.icon]}
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm truncate">{link.name}</p>
|
||||
<p class="text-xs text-muted-foreground truncate">{link.description}</p>
|
||||
</div>
|
||||
<svg
|
||||
class="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
value: string | number;
|
||||
change?: number;
|
||||
changeLabel?: string;
|
||||
icon?: 'users' | 'activity' | 'chart' | 'clock' | 'shield' | 'alert';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
let { title, value, change, changeLabel, icon = 'chart', loading = false }: Props = $props();
|
||||
|
||||
const icons = {
|
||||
users: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />`,
|
||||
activity: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />`,
|
||||
chart: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />`,
|
||||
clock: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />`,
|
||||
shield: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />`,
|
||||
alert: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />`,
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border bg-card p-6 shadow-sm">
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-4 animate-pulse">
|
||||
<div class="rounded-full bg-muted p-3 h-12 w-12"></div>
|
||||
<div class="flex-1">
|
||||
<div class="h-4 bg-muted rounded w-24 mb-2"></div>
|
||||
<div class="h-8 bg-muted rounded w-16"></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="rounded-full bg-primary/10 p-3">
|
||||
<svg class="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
{@html icons[icon]}
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">{title}</p>
|
||||
<p class="text-2xl font-bold">{value}</p>
|
||||
{#if change !== undefined}
|
||||
<p class="text-sm {change >= 0 ? 'text-green-500' : 'text-red-500'}">
|
||||
{change >= 0 ? '+' : ''}{change}%
|
||||
{#if changeLabel}
|
||||
<span class="text-muted-foreground">{changeLabel}</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
128
apps/manacore/apps/web/src/lib/components/admin/UserTable.svelte
Normal file
128
apps/manacore/apps/web/src/lib/components/admin/UserTable.svelte
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<script lang="ts">
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
createdAt: string;
|
||||
lastActiveAt?: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
users: User[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
let { users, loading = false }: Props = $props();
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string | undefined): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'gerade eben';
|
||||
if (diffMins < 60) return `vor ${diffMins} Min`;
|
||||
if (diffHours < 24) return `vor ${diffHours} Std`;
|
||||
if (diffDays < 7) return `vor ${diffDays} Tagen`;
|
||||
return formatDate(dateStr);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border bg-card shadow-sm overflow-hidden">
|
||||
<div class="p-4 border-b">
|
||||
<h3 class="text-lg font-semibold">Benutzer</h3>
|
||||
</div>
|
||||
{#if loading}
|
||||
<div class="p-4 space-y-3">
|
||||
{#each Array(5) as _}
|
||||
<div class="animate-pulse flex items-center gap-4">
|
||||
<div class="h-10 w-10 bg-muted rounded-full"></div>
|
||||
<div class="flex-1">
|
||||
<div class="h-4 bg-muted rounded w-1/4 mb-2"></div>
|
||||
<div class="h-3 bg-muted rounded w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-muted/50">
|
||||
<tr>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
Benutzer
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
Rolle
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
Registriert
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
Letzte Aktivität
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
{#each users as user}
|
||||
<tr class="hover:bg-muted/30 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span class="text-sm font-medium text-primary">
|
||||
{(user.name || user.email)[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-sm">{user.name || '-'}</p>
|
||||
<p class="text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||
{user.role === 'admin'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'}"
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-muted-foreground">
|
||||
{formatDate(user.createdAt)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-muted-foreground">
|
||||
{formatRelativeTime(user.lastActiveAt)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if users.length === 0}
|
||||
<div class="p-8 text-center text-muted-foreground">Keine Benutzer gefunden</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -80,7 +80,8 @@
|
|||
let userEmail = $derived(authStore.user?.email);
|
||||
|
||||
// Navigation items for ManaCore
|
||||
const navItems: PillNavItem[] = [
|
||||
// Admin link is conditionally added based on user role
|
||||
let baseNavItems: PillNavItem[] = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: 'home' },
|
||||
{ href: '/credits', label: 'Credits', icon: 'creditCard' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
|
|
@ -88,6 +89,13 @@
|
|||
{ href: '/settings', label: 'Settings', icon: 'settings' },
|
||||
];
|
||||
|
||||
// TODO: Check user role from authStore and add admin link if admin
|
||||
// For now, always show admin link for testing
|
||||
const navItems: PillNavItem[] = [
|
||||
...baseNavItems,
|
||||
{ href: '/admin', label: 'Admin', icon: 'shield' },
|
||||
];
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-5)
|
||||
const navRoutes = navItems.map((item) => item.href);
|
||||
|
||||
|
|
|
|||
72
apps/manacore/apps/web/src/routes/(app)/admin/+layout.svelte
Normal file
72
apps/manacore/apps/web/src/routes/(app)/admin/+layout.svelte
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
const tabs = [
|
||||
{ href: '/admin', label: 'Overview', icon: 'home' },
|
||||
{ href: '/admin/users', label: 'Users', icon: 'users' },
|
||||
{ href: '/admin/system', label: 'System', icon: 'server' },
|
||||
];
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
home: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />`,
|
||||
users: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />`,
|
||||
server: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />`,
|
||||
};
|
||||
|
||||
function isActive(href: string, pathname: string): boolean {
|
||||
if (href === '/admin') {
|
||||
return pathname === '/admin';
|
||||
}
|
||||
return pathname.startsWith(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Admin Dashboard</h1>
|
||||
<p class="text-muted-foreground">System monitoring and management</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-red-600 dark:text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-red-600 dark:text-red-400">Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex gap-1 border-b pb-px">
|
||||
{#each tabs as tab}
|
||||
{@const active = isActive(tab.href, $page.url.pathname)}
|
||||
<a
|
||||
href={tab.href}
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-t-lg transition-colors
|
||||
{active
|
||||
? 'text-primary border-b-2 border-primary -mb-px bg-primary/5'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
{@html icons[tab.icon]}
|
||||
</svg>
|
||||
{tab.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
155
apps/manacore/apps/web/src/routes/(app)/admin/+page.svelte
Normal file
155
apps/manacore/apps/web/src/routes/(app)/admin/+page.svelte
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import StatCard from '$lib/components/admin/StatCard.svelte';
|
||||
import QuickLinks from '$lib/components/admin/QuickLinks.svelte';
|
||||
|
||||
interface Stats {
|
||||
totalUsers: number;
|
||||
newUsers7d: number;
|
||||
newUsers30d: number;
|
||||
activeSessions: number;
|
||||
uniqueUsers24h: number;
|
||||
loginSuccess7d: number;
|
||||
loginFailed7d: number;
|
||||
}
|
||||
|
||||
let stats = $state<Stats | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const quickLinks = [
|
||||
{
|
||||
name: 'Grafana Dashboard',
|
||||
url: 'https://grafana.mana.how',
|
||||
description: 'System & Backend Metrics',
|
||||
icon: 'grafana' as const,
|
||||
},
|
||||
{
|
||||
name: 'Umami Analytics',
|
||||
url: 'https://analytics.mana.how',
|
||||
description: 'Web Analytics',
|
||||
icon: 'analytics' as const,
|
||||
},
|
||||
{
|
||||
name: 'Docker Dashboard',
|
||||
url: 'https://grafana.mana.how/d/backends-docker',
|
||||
description: 'Container Metrics',
|
||||
icon: 'docker' as const,
|
||||
},
|
||||
{
|
||||
name: 'System Overview',
|
||||
url: 'https://grafana.mana.how/d/system-overview',
|
||||
description: 'CPU, Memory, Disk',
|
||||
icon: 'grafana' as const,
|
||||
},
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
// TODO: Replace with actual API call to fetch admin stats
|
||||
// const response = await fetch('/api/admin/stats');
|
||||
// stats = await response.json();
|
||||
|
||||
// Mock data for now
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
stats = {
|
||||
totalUsers: 42,
|
||||
newUsers7d: 8,
|
||||
newUsers30d: 23,
|
||||
activeSessions: 15,
|
||||
uniqueUsers24h: 12,
|
||||
loginSuccess7d: 156,
|
||||
loginFailed7d: 3,
|
||||
};
|
||||
} catch (e) {
|
||||
error = 'Failed to load stats';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
let userGrowthPercent = $derived(
|
||||
stats
|
||||
? Math.round((stats.newUsers7d / Math.max(stats.totalUsers - stats.newUsers7d, 1)) * 100)
|
||||
: 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard title="Total Users" value={stats?.totalUsers ?? '-'} icon="users" {loading} />
|
||||
<StatCard
|
||||
title="New Users (7d)"
|
||||
value={stats?.newUsers7d ?? '-'}
|
||||
change={userGrowthPercent}
|
||||
changeLabel="vs previous"
|
||||
icon="users"
|
||||
{loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Sessions"
|
||||
value={stats?.activeSessions ?? '-'}
|
||||
icon="activity"
|
||||
{loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Unique Users (24h)"
|
||||
value={stats?.uniqueUsers24h ?? '-'}
|
||||
icon="clock"
|
||||
{loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Security & Quick Links -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Security Overview -->
|
||||
<div class="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<h3 class="text-lg font-semibold mb-4">Security (Last 7 Days)</h3>
|
||||
{#if loading}
|
||||
<div class="space-y-4 animate-pulse">
|
||||
<div class="h-4 bg-muted rounded w-full"></div>
|
||||
<div class="h-4 bg-muted rounded w-3/4"></div>
|
||||
</div>
|
||||
{:else if stats}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
<span class="text-sm">Successful Logins</span>
|
||||
</div>
|
||||
<span class="font-mono text-sm">{stats.loginSuccess7d}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 w-2 rounded-full bg-red-500"></div>
|
||||
<span class="text-sm">Failed Logins</span>
|
||||
</div>
|
||||
<span class="font-mono text-sm">{stats.loginFailed7d}</span>
|
||||
</div>
|
||||
<div class="pt-2 border-t">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">Success Rate</span>
|
||||
<span class="font-medium text-green-600">
|
||||
{Math.round(
|
||||
(stats.loginSuccess7d / (stats.loginSuccess7d + stats.loginFailed7d)) * 100
|
||||
)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<QuickLinks links={quickLinks} />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20 p-4"
|
||||
>
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import QuickLinks from '$lib/components/admin/QuickLinks.svelte';
|
||||
|
||||
interface ServiceHealth {
|
||||
name: string;
|
||||
status: 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
|
||||
url: string;
|
||||
lastCheck?: string;
|
||||
}
|
||||
|
||||
let services = $state<ServiceHealth[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
const monitoringLinks = [
|
||||
{
|
||||
name: 'Grafana - System Overview',
|
||||
url: 'https://grafana.mana.how/d/system-overview',
|
||||
description: 'CPU, Memory, Disk, Network',
|
||||
icon: 'grafana' as const,
|
||||
},
|
||||
{
|
||||
name: 'Grafana - Backends & Docker',
|
||||
url: 'https://grafana.mana.how/d/backends-docker',
|
||||
description: 'Container metrics & API performance',
|
||||
icon: 'docker' as const,
|
||||
},
|
||||
{
|
||||
name: 'Prometheus',
|
||||
url: 'https://grafana.mana.how/explore',
|
||||
description: 'Raw metrics & queries',
|
||||
icon: 'api' as const,
|
||||
},
|
||||
{
|
||||
name: 'Umami Analytics',
|
||||
url: 'https://analytics.mana.how',
|
||||
description: 'Web analytics dashboard',
|
||||
icon: 'analytics' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const statusColors = {
|
||||
healthy: 'bg-green-500',
|
||||
degraded: 'bg-yellow-500',
|
||||
unhealthy: 'bg-red-500',
|
||||
unknown: 'bg-gray-400',
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
healthy: 'Healthy',
|
||||
degraded: 'Degraded',
|
||||
unhealthy: 'Unhealthy',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
// TODO: Replace with actual health check API
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
services = [
|
||||
{ name: 'Mana Core Auth', status: 'healthy', url: 'https://auth.mana.how' },
|
||||
{ name: 'ManaCore Web', status: 'healthy', url: 'https://mana.how' },
|
||||
{ name: 'Chat Backend', status: 'healthy', url: 'https://chat-api.mana.how' },
|
||||
{ name: 'Chat Web', status: 'healthy', url: 'https://chat.mana.how' },
|
||||
{ name: 'Todo Backend', status: 'healthy', url: 'https://todo-api.mana.how' },
|
||||
{ name: 'Todo Web', status: 'healthy', url: 'https://todo.mana.how' },
|
||||
{ name: 'Calendar Backend', status: 'healthy', url: 'https://calendar-api.mana.how' },
|
||||
{ name: 'Calendar Web', status: 'healthy', url: 'https://calendar.mana.how' },
|
||||
{ name: 'Clock Backend', status: 'healthy', url: 'https://clock-api.mana.how' },
|
||||
{ name: 'Clock Web', status: 'healthy', url: 'https://clock.mana.how' },
|
||||
{ name: 'Contacts Backend', status: 'healthy', url: 'https://contacts-api.mana.how' },
|
||||
{ name: 'Contacts Web', status: 'healthy', url: 'https://contacts.mana.how' },
|
||||
{ name: 'PostgreSQL', status: 'healthy', url: '-' },
|
||||
{ name: 'Redis', status: 'healthy', url: '-' },
|
||||
{ name: 'Grafana', status: 'healthy', url: 'https://grafana.mana.how' },
|
||||
{ name: 'Umami', status: 'healthy', url: 'https://analytics.mana.how' },
|
||||
];
|
||||
|
||||
loading = false;
|
||||
});
|
||||
|
||||
let healthyCount = $derived(services.filter((s) => s.status === 'healthy').length);
|
||||
let totalCount = $derived(services.length);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- System Status Overview -->
|
||||
<div class="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold">System Status</h3>
|
||||
{#if !loading}
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-2.5 w-2.5 rounded-full {healthyCount === totalCount
|
||||
? 'bg-green-500'
|
||||
: 'bg-yellow-500'}"
|
||||
></div>
|
||||
<span class="text-sm font-medium">
|
||||
{healthyCount}/{totalCount} Services Healthy
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{#each Array(8) as _}
|
||||
<div class="animate-pulse h-12 bg-muted rounded-lg"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{#each services as service}
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg border bg-background">
|
||||
<div class="h-2.5 w-2.5 rounded-full {statusColors[service.status]}"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">{service.name}</p>
|
||||
<p class="text-xs text-muted-foreground">{statusLabels[service.status]}</p>
|
||||
</div>
|
||||
{#if service.url !== '-'}
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Monitoring Links -->
|
||||
<QuickLinks links={monitoringLinks} />
|
||||
|
||||
<!-- Environment Info -->
|
||||
<div class="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<h3 class="text-lg font-semibold mb-4">Environment</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Server</span>
|
||||
<span class="font-mono">Mac Mini (mana.how)</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Domain</span>
|
||||
<span class="font-mono">*.mana.how</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">SSL</span>
|
||||
<span class="font-mono text-green-600">Caddy (Auto)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Database</span>
|
||||
<span class="font-mono">PostgreSQL 16</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Cache</span>
|
||||
<span class="font-mono">Redis 7</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Tunnel</span>
|
||||
<span class="font-mono">Cloudflare</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
124
apps/manacore/apps/web/src/routes/(app)/admin/users/+page.svelte
Normal file
124
apps/manacore/apps/web/src/routes/(app)/admin/users/+page.svelte
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import UserTable from '$lib/components/admin/UserTable.svelte';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
createdAt: string;
|
||||
lastActiveAt?: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
let users = $state<User[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let searchQuery = $state('');
|
||||
|
||||
let filteredUsers = $derived(
|
||||
searchQuery
|
||||
? users.filter(
|
||||
(u) =>
|
||||
u.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
u.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: users
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
// TODO: Replace with actual API call
|
||||
// const response = await fetch('/api/admin/users');
|
||||
// users = await response.json();
|
||||
|
||||
// Mock data for now
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
users = [
|
||||
{
|
||||
id: '1',
|
||||
email: 'admin@mana.how',
|
||||
name: 'Admin User',
|
||||
createdAt: '2024-01-15T10:00:00Z',
|
||||
lastActiveAt: new Date().toISOString(),
|
||||
role: 'admin',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
email: 'user1@example.com',
|
||||
name: 'Max Mustermann',
|
||||
createdAt: '2024-06-20T14:30:00Z',
|
||||
lastActiveAt: new Date(Date.now() - 3600000).toISOString(),
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
email: 'user2@example.com',
|
||||
name: 'Erika Musterfrau',
|
||||
createdAt: '2024-09-01T08:15:00Z',
|
||||
lastActiveAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
email: 'user3@example.com',
|
||||
createdAt: '2024-12-10T16:45:00Z',
|
||||
lastActiveAt: new Date(Date.now() - 172800000).toISOString(),
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
email: 'newuser@example.com',
|
||||
name: 'New User',
|
||||
createdAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
role: 'user',
|
||||
},
|
||||
];
|
||||
} catch (e) {
|
||||
error = 'Failed to load users';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Search & Filters -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users..."
|
||||
bind:value={searchQuery}
|
||||
class="w-full pl-10 pr-4 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{filteredUsers.length} of {users.length} users
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- User Table -->
|
||||
<UserTable users={filteredUsers} {loading} />
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20 p-4"
|
||||
>
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -42,6 +42,12 @@ const {
|
|||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!-- Umami Analytics -->
|
||||
<script
|
||||
defer
|
||||
src="https://analytics.mana.how/script.js"
|
||||
data-website-id="MANADECK_LANDING_WEBSITE_ID"></script>
|
||||
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="min-h-screen bg-background-page text-text-primary antialiased">
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@
|
|||
<meta name="msapplication-config" content="none" />
|
||||
|
||||
%sveltekit.head%
|
||||
<!-- Umami Analytics -->
|
||||
<script defer src="https://analytics.mana.how/script.js" data-website-id="TODO_WEB_WEBSITE_ID"></script>
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
|
|
|
|||
|
|
@ -378,6 +378,134 @@ services:
|
|||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ============================================
|
||||
# Monitoring Stack
|
||||
# ============================================
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.51.0
|
||||
container_name: manacore-prometheus
|
||||
restart: always
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--storage.tsdb.retention.time=30d'
|
||||
- '--web.enable-lifecycle'
|
||||
volumes:
|
||||
- ./docker/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- prometheus_data:/prometheus
|
||||
ports:
|
||||
- "9090:9090"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9090/-/healthy"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:10.4.1
|
||||
container_name: manacore-grafana
|
||||
restart: always
|
||||
depends_on:
|
||||
prometheus:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: admin
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin}
|
||||
GF_USERS_ALLOW_SIGN_UP: false
|
||||
GF_SERVER_ROOT_URL: https://grafana.mana.how
|
||||
volumes:
|
||||
- ./docker/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./docker/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
- grafana_data:/var/lib/grafana
|
||||
ports:
|
||||
- "3100:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.7.0
|
||||
container_name: manacore-node-exporter
|
||||
restart: always
|
||||
command:
|
||||
- '--path.procfs=/host/proc'
|
||||
- '--path.sysfs=/host/sys'
|
||||
- '--path.rootfs=/rootfs'
|
||||
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /:/rootfs:ro
|
||||
ports:
|
||||
- "9100:9100"
|
||||
|
||||
cadvisor:
|
||||
image: gcr.io/cadvisor/cadvisor:v0.49.1
|
||||
container_name: manacore-cadvisor
|
||||
restart: always
|
||||
privileged: true
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
- /var/run:/var/run:ro
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker/:/var/lib/docker:ro
|
||||
- /dev/disk/:/dev/disk:ro
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
postgres-exporter:
|
||||
image: prometheuscommunity/postgres-exporter:v0.15.0
|
||||
container_name: manacore-postgres-exporter
|
||||
restart: always
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATA_SOURCE_NAME: postgresql://postgres:${POSTGRES_PASSWORD:-manacore123}@postgres:5432/postgres?sslmode=disable
|
||||
ports:
|
||||
- "9187:9187"
|
||||
|
||||
redis-exporter:
|
||||
image: oliver006/redis_exporter:v1.58.0
|
||||
container_name: manacore-redis-exporter
|
||||
restart: always
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
REDIS_ADDR: redis://redis:6379
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123}
|
||||
ports:
|
||||
- "9121:9121"
|
||||
|
||||
# ============================================
|
||||
# Web Analytics (Umami)
|
||||
# ============================================
|
||||
|
||||
umami:
|
||||
image: ghcr.io/umami-software/umami:postgresql-latest
|
||||
container_name: manacore-umami
|
||||
restart: always
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-manacore123}@postgres:5432/umami
|
||||
DATABASE_TYPE: postgresql
|
||||
APP_SECRET: ${UMAMI_APP_SECRET:-change-me-umami-secret}
|
||||
DISABLE_TELEMETRY: 1
|
||||
ports:
|
||||
- "3200:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/heartbeat"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ============================================
|
||||
# Volumes
|
||||
# ============================================
|
||||
|
|
@ -387,3 +515,7 @@ volumes:
|
|||
name: manacore-postgres
|
||||
redis_data:
|
||||
name: manacore-redis
|
||||
prometheus_data:
|
||||
name: manacore-prometheus
|
||||
grafana_data:
|
||||
name: manacore-grafana
|
||||
|
|
|
|||
90
docker/caddy/Caddyfile.production
Normal file
90
docker/caddy/Caddyfile.production
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# ManaCore Production Reverse Proxy
|
||||
# Domain: mana.how
|
||||
# Server: 46.224.108.214
|
||||
#
|
||||
# Deploy to: ~/Caddyfile on production server
|
||||
# Reload with: docker exec caddy caddy reload --config /etc/caddy/Caddyfile
|
||||
|
||||
# ============================================
|
||||
# Auth Service
|
||||
# ============================================
|
||||
auth.mana.how {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# ManaCore Dashboard (Main)
|
||||
# ============================================
|
||||
mana.how {
|
||||
reverse_proxy localhost:5173
|
||||
}
|
||||
|
||||
www.mana.how {
|
||||
redir https://mana.how{uri} permanent
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Chat App
|
||||
# ============================================
|
||||
chat.mana.how {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
chat-api.mana.how {
|
||||
reverse_proxy localhost:3002
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Todo App
|
||||
# ============================================
|
||||
todo.mana.how {
|
||||
reverse_proxy localhost:5188
|
||||
}
|
||||
|
||||
todo-api.mana.how {
|
||||
reverse_proxy localhost:3018
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Calendar App
|
||||
# ============================================
|
||||
calendar.mana.how {
|
||||
reverse_proxy localhost:5186
|
||||
}
|
||||
|
||||
calendar-api.mana.how {
|
||||
reverse_proxy localhost:3016
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Clock App
|
||||
# ============================================
|
||||
clock.mana.how {
|
||||
reverse_proxy localhost:5187
|
||||
}
|
||||
|
||||
clock-api.mana.how {
|
||||
reverse_proxy localhost:3017
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Contacts App
|
||||
# ============================================
|
||||
contacts.mana.how {
|
||||
reverse_proxy localhost:5184
|
||||
}
|
||||
|
||||
contacts-api.mana.how {
|
||||
reverse_proxy localhost:3015
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Monitoring & Analytics
|
||||
# ============================================
|
||||
grafana.mana.how {
|
||||
reverse_proxy localhost:3100
|
||||
}
|
||||
|
||||
analytics.mana.how {
|
||||
reverse_proxy localhost:3200
|
||||
}
|
||||
377
docker/grafana/dashboards/backends.json
Normal file
377
docker/grafana/dashboards/backends.json
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
{
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
|
||||
"id": 1,
|
||||
"panels": [],
|
||||
"title": "Docker Containers",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 1 },
|
||||
"id": 2,
|
||||
"options": {
|
||||
"legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "single", "sort": "none" }
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "rate(container_cpu_usage_seconds_total{name=~\".+\"}[5m]) * 100",
|
||||
"legendFormat": "{{name}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Container CPU Usage",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "decbytes"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 1 },
|
||||
"id": 3,
|
||||
"options": {
|
||||
"legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "single", "sort": "none" }
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "container_memory_usage_bytes{name=~\".+\"}",
|
||||
"legendFormat": "{{name}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Container Memory Usage",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 9 },
|
||||
"id": 4,
|
||||
"panels": [],
|
||||
"title": "Backend API Metrics",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 10 },
|
||||
"id": 5,
|
||||
"options": {
|
||||
"legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "single", "sort": "none" }
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "sum(rate(http_requests_total[5m])) by (job)",
|
||||
"legendFormat": "{{job}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Request Rate by Service",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 10 },
|
||||
"id": 6,
|
||||
"options": {
|
||||
"legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "single", "sort": "none" }
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, job))",
|
||||
"legendFormat": "p95 {{job}}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "histogram_quantile(0.50, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, job))",
|
||||
"legendFormat": "p50 {{job}}",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Response Time (p50/p95)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 18 },
|
||||
"id": 7,
|
||||
"options": {
|
||||
"legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "single", "sort": "none" }
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "sum(rate(http_requests_total{status=~\"4..|5..\"}[5m])) by (job)",
|
||||
"legendFormat": "{{job}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Error Rate by Service",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [
|
||||
{
|
||||
"options": {
|
||||
"0": { "color": "red", "index": 1, "text": "Down" },
|
||||
"1": { "color": "green", "index": 0, "text": "Up" }
|
||||
},
|
||||
"type": "value"
|
||||
}
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "green", "value": 1 }
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 18 },
|
||||
"id": 8,
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "horizontal",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "up{job=~\"mana-core-auth|chat-backend|todo-backend|calendar-backend|clock-backend|contacts-backend\"}",
|
||||
"legendFormat": "{{job}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Service Health",
|
||||
"type": "stat"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 38,
|
||||
"tags": ["manacore", "backends", "docker"],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": { "selected": false, "text": "Prometheus", "value": "Prometheus" },
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"multi": false,
|
||||
"name": "datasource",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": { "from": "now-1h", "to": "now" },
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "Backends & Docker",
|
||||
"uid": "backends-docker",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
498
docker/grafana/dashboards/system-overview.json
Normal file
498
docker/grafana/dashboards/system-overview.json
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
{
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
|
||||
"id": 1,
|
||||
"panels": [],
|
||||
"title": "System Overview",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 70 },
|
||||
{ "color": "red", "value": 85 }
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 0, "y": 1 },
|
||||
"id": 2,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "CPU Usage",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 70 },
|
||||
{ "color": "red", "value": 85 }
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 6, "y": 1 },
|
||||
"id": 3,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Memory Usage",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 70 },
|
||||
{ "color": "red", "value": 85 }
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 12, "y": 1 },
|
||||
"id": 4,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "(1 - (node_filesystem_avail_bytes{mountpoint=\"/\"} / node_filesystem_size_bytes{mountpoint=\"/\"})) * 100",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Disk Usage",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 18, "y": 1 },
|
||||
"id": 5,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "count(container_last_seen{name=~\".+\"})",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Running Containers",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 },
|
||||
"id": 6,
|
||||
"options": {
|
||||
"legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "single", "sort": "none" }
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
|
||||
"legendFormat": "CPU",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100",
|
||||
"legendFormat": "Memory",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "CPU & Memory Over Time",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "Bps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 7 },
|
||||
"id": 7,
|
||||
"options": {
|
||||
"legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "single", "sort": "none" }
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "rate(node_network_receive_bytes_total{device!~\"lo|veth.*|br.*|docker.*\"}[5m])",
|
||||
"legendFormat": "Receive {{device}}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "rate(node_network_transmit_bytes_total{device!~\"lo|veth.*|br.*|docker.*\"}[5m])",
|
||||
"legendFormat": "Transmit {{device}}",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Network I/O",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 15 },
|
||||
"id": 8,
|
||||
"panels": [],
|
||||
"title": "Database",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 0, "y": 16 },
|
||||
"id": 9,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "pg_stat_activity_count{state=\"active\"}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "PostgreSQL Active Connections",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 6, "y": 16 },
|
||||
"id": 10,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "redis_connected_clients",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Redis Connected Clients",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "decbytes"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 12, "y": 16 },
|
||||
"id": 11,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "redis_memory_used_bytes",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Redis Memory Usage",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 18, "y": 16 },
|
||||
"id": 12,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||
"expr": "pg_stat_database_blks_hit{datname!~\"template.*|postgres\"} / (pg_stat_database_blks_hit{datname!~\"template.*|postgres\"} + pg_stat_database_blks_read{datname!~\"template.*|postgres\"}) * 100",
|
||||
"legendFormat": "{{datname}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "PostgreSQL Cache Hit Ratio",
|
||||
"type": "stat"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 38,
|
||||
"tags": ["manacore", "system"],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": { "selected": false, "text": "Prometheus", "value": "Prometheus" },
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"multi": false,
|
||||
"name": "datasource",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": { "from": "now-1h", "to": "now" },
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "System Overview",
|
||||
"uid": "system-overview",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
16
docker/grafana/provisioning/dashboards/default.yml
Normal file
16
docker/grafana/provisioning/dashboards/default.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Grafana Dashboard Provisioning
|
||||
# Auto-loads dashboards from the dashboards folder
|
||||
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: 'ManaCore Dashboards'
|
||||
orgId: 1
|
||||
folder: 'ManaCore'
|
||||
folderUid: 'manacore'
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 30
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
15
docker/grafana/provisioning/datasources/prometheus.yml
Normal file
15
docker/grafana/provisioning/datasources/prometheus.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Grafana Datasource Provisioning
|
||||
# Auto-configures Prometheus as the default datasource
|
||||
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: true
|
||||
jsonData:
|
||||
timeInterval: "15s"
|
||||
httpMethod: POST
|
||||
88
docker/prometheus/prometheus.yml
Normal file
88
docker/prometheus/prometheus.yml
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# ManaCore Prometheus Configuration
|
||||
# Scrapes metrics from all services
|
||||
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
# Alertmanager configuration (optional, for future use)
|
||||
# alerting:
|
||||
# alertmanagers:
|
||||
# - static_configs:
|
||||
# - targets: []
|
||||
|
||||
scrape_configs:
|
||||
# Prometheus self-monitoring
|
||||
- job_name: 'prometheus'
|
||||
static_configs:
|
||||
- targets: ['localhost:9090']
|
||||
|
||||
# Host system metrics via node-exporter
|
||||
- job_name: 'node'
|
||||
static_configs:
|
||||
- targets: ['node-exporter:9100']
|
||||
relabel_configs:
|
||||
- source_labels: [__address__]
|
||||
target_label: instance
|
||||
replacement: 'mac-mini'
|
||||
|
||||
# Docker container metrics via cAdvisor
|
||||
- job_name: 'cadvisor'
|
||||
static_configs:
|
||||
- targets: ['cadvisor:8080']
|
||||
|
||||
# PostgreSQL metrics
|
||||
- job_name: 'postgres'
|
||||
static_configs:
|
||||
- targets: ['postgres-exporter:9187']
|
||||
|
||||
# Redis metrics
|
||||
- job_name: 'redis'
|
||||
static_configs:
|
||||
- targets: ['redis-exporter:9121']
|
||||
|
||||
# ============================================
|
||||
# Application Backends (after /metrics added)
|
||||
# ============================================
|
||||
|
||||
# Auth Service
|
||||
- job_name: 'mana-core-auth'
|
||||
static_configs:
|
||||
- targets: ['mana-core-auth:3001']
|
||||
metrics_path: '/metrics'
|
||||
scrape_interval: 30s
|
||||
|
||||
# Chat Backend
|
||||
- job_name: 'chat-backend'
|
||||
static_configs:
|
||||
- targets: ['chat-backend:3002']
|
||||
metrics_path: '/metrics'
|
||||
scrape_interval: 30s
|
||||
|
||||
# Todo Backend
|
||||
- job_name: 'todo-backend'
|
||||
static_configs:
|
||||
- targets: ['todo-backend:3018']
|
||||
metrics_path: '/metrics'
|
||||
scrape_interval: 30s
|
||||
|
||||
# Calendar Backend
|
||||
- job_name: 'calendar-backend'
|
||||
static_configs:
|
||||
- targets: ['calendar-backend:3016']
|
||||
metrics_path: '/metrics'
|
||||
scrape_interval: 30s
|
||||
|
||||
# Clock Backend
|
||||
- job_name: 'clock-backend'
|
||||
static_configs:
|
||||
- targets: ['clock-backend:3017']
|
||||
metrics_path: '/metrics'
|
||||
scrape_interval: 30s
|
||||
|
||||
# Contacts Backend
|
||||
- job_name: 'contacts-backend'
|
||||
static_configs:
|
||||
- targets: ['contacts-backend:3015']
|
||||
metrics_path: '/metrics'
|
||||
scrape_interval: 30s
|
||||
39
packages/shared-nestjs-metrics/package.json
Normal file
39
packages/shared-nestjs-metrics/package.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"name": "@manacore/shared-nestjs-metrics",
|
||||
"version": "1.0.0",
|
||||
"description": "Prometheus metrics module for NestJS backends",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"prepublishOnly": "pnpm build",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"prom-client": "^15.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"@nestjs/core": "^10.0.0 || ^11.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"nestjs",
|
||||
"metrics",
|
||||
"prometheus",
|
||||
"monitoring",
|
||||
"manacore"
|
||||
],
|
||||
"author": "Mana Core Team",
|
||||
"license": "MIT"
|
||||
}
|
||||
62
packages/shared-nestjs-metrics/src/index.ts
Normal file
62
packages/shared-nestjs-metrics/src/index.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* @manacore/shared-nestjs-metrics
|
||||
*
|
||||
* Prometheus metrics module for NestJS backends.
|
||||
* Automatically tracks HTTP requests, duration, and provides custom metrics.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { MetricsModule } from '@manacore/shared-nestjs-metrics';
|
||||
*
|
||||
* @Module({
|
||||
* imports: [
|
||||
* MetricsModule.register({
|
||||
* prefix: 'myapp_',
|
||||
* defaultLabels: { app: 'my-backend' },
|
||||
* }),
|
||||
* ],
|
||||
* })
|
||||
* export class AppModule {}
|
||||
* ```
|
||||
*
|
||||
* The module automatically:
|
||||
* - Exposes a `/metrics` endpoint for Prometheus scraping
|
||||
* - Tracks HTTP request count, duration, and in-flight requests
|
||||
* - Collects default Node.js metrics (CPU, memory, event loop)
|
||||
*
|
||||
* Custom metrics can be created via the MetricsService:
|
||||
* ```typescript
|
||||
* @Injectable()
|
||||
* export class MyService {
|
||||
* private readonly aiRequestCounter: Counter;
|
||||
*
|
||||
* constructor(private readonly metricsService: MetricsService) {
|
||||
* this.aiRequestCounter = metricsService.createCounter(
|
||||
* 'ai_requests_total',
|
||||
* 'Total AI requests',
|
||||
* ['model']
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* async processAI(model: string) {
|
||||
* this.aiRequestCounter.inc({ model });
|
||||
* // ... process
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Module
|
||||
export { MetricsModule, MetricsModuleOptions } from './metrics.module';
|
||||
|
||||
// Service
|
||||
export { MetricsService, MetricsServiceOptions } from './metrics.service';
|
||||
|
||||
// Middleware
|
||||
export { MetricsMiddleware } from './metrics.middleware';
|
||||
|
||||
// Controller
|
||||
export { MetricsController } from './metrics.controller';
|
||||
|
||||
// Re-export prom-client types for convenience
|
||||
export { Counter, Histogram, Gauge, Summary } from 'prom-client';
|
||||
15
packages/shared-nestjs-metrics/src/metrics.controller.ts
Normal file
15
packages/shared-nestjs-metrics/src/metrics.controller.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Controller, Get, Res } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { MetricsService } from './metrics.service';
|
||||
|
||||
@Controller()
|
||||
export class MetricsController {
|
||||
constructor(private readonly metricsService: MetricsService) {}
|
||||
|
||||
@Get('metrics')
|
||||
async getMetrics(@Res() res: Response): Promise<void> {
|
||||
const metrics = await this.metricsService.getMetrics();
|
||||
res.set('Content-Type', this.metricsService.getContentType());
|
||||
res.send(metrics);
|
||||
}
|
||||
}
|
||||
35
packages/shared-nestjs-metrics/src/metrics.middleware.ts
Normal file
35
packages/shared-nestjs-metrics/src/metrics.middleware.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { MetricsService } from './metrics.service';
|
||||
|
||||
@Injectable()
|
||||
export class MetricsMiddleware implements NestMiddleware {
|
||||
constructor(private readonly metricsService: MetricsService) {}
|
||||
|
||||
use(req: Request, res: Response, next: NextFunction): void {
|
||||
// Skip metrics endpoint itself to avoid recursion
|
||||
if (req.path === '/metrics') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const method = req.method;
|
||||
|
||||
// Track in-flight requests
|
||||
this.metricsService.incrementInFlight(method);
|
||||
|
||||
// Hook into response finish
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - startTime;
|
||||
const status = res.statusCode;
|
||||
const path = req.route?.path || req.path;
|
||||
|
||||
// Record metrics
|
||||
this.metricsService.incrementHttpRequests(method, path, status);
|
||||
this.metricsService.observeHttpDuration(method, path, status, duration);
|
||||
this.metricsService.decrementInFlight(method);
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
79
packages/shared-nestjs-metrics/src/metrics.module.ts
Normal file
79
packages/shared-nestjs-metrics/src/metrics.module.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { Module, DynamicModule, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
||||
import { MetricsService, MetricsServiceOptions } from './metrics.service';
|
||||
import { MetricsMiddleware } from './metrics.middleware';
|
||||
import { MetricsController } from './metrics.controller';
|
||||
|
||||
export interface MetricsModuleOptions extends MetricsServiceOptions {
|
||||
/**
|
||||
* Path for metrics endpoint (default: '/metrics')
|
||||
*/
|
||||
path?: string;
|
||||
|
||||
/**
|
||||
* Paths to exclude from metrics collection
|
||||
*/
|
||||
excludePaths?: string[];
|
||||
|
||||
/**
|
||||
* Whether to register the metrics endpoint controller (default: true)
|
||||
*/
|
||||
registerController?: boolean;
|
||||
}
|
||||
|
||||
@Module({})
|
||||
export class MetricsModule implements NestModule {
|
||||
private static options: MetricsModuleOptions = {};
|
||||
|
||||
/**
|
||||
* Register the metrics module with options
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { MetricsModule } from '@manacore/shared-nestjs-metrics';
|
||||
*
|
||||
* @Module({
|
||||
* imports: [
|
||||
* MetricsModule.register({
|
||||
* prefix: 'myapp_',
|
||||
* defaultLabels: { app: 'my-backend' },
|
||||
* }),
|
||||
* ],
|
||||
* })
|
||||
* export class AppModule {}
|
||||
* ```
|
||||
*/
|
||||
static register(options: MetricsModuleOptions = {}): DynamicModule {
|
||||
MetricsModule.options = options;
|
||||
|
||||
const providers = [
|
||||
{
|
||||
provide: MetricsService,
|
||||
useFactory: () => new MetricsService(options),
|
||||
},
|
||||
MetricsMiddleware,
|
||||
];
|
||||
|
||||
const controllers = options.registerController !== false ? [MetricsController] : [];
|
||||
|
||||
return {
|
||||
module: MetricsModule,
|
||||
controllers,
|
||||
providers,
|
||||
exports: [MetricsService],
|
||||
global: true,
|
||||
};
|
||||
}
|
||||
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
const excludePaths = MetricsModule.options.excludePaths || [];
|
||||
const metricsPath = MetricsModule.options.path || '/metrics';
|
||||
|
||||
// Always exclude the metrics endpoint itself
|
||||
const allExcludePaths = [...excludePaths, metricsPath];
|
||||
|
||||
consumer
|
||||
.apply(MetricsMiddleware)
|
||||
.exclude(...allExcludePaths)
|
||||
.forRoutes('*');
|
||||
}
|
||||
}
|
||||
173
packages/shared-nestjs-metrics/src/metrics.service.ts
Normal file
173
packages/shared-nestjs-metrics/src/metrics.service.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { Registry, Counter, Histogram, Gauge, collectDefaultMetrics, register } from 'prom-client';
|
||||
|
||||
export interface MetricsServiceOptions {
|
||||
prefix?: string;
|
||||
defaultLabels?: Record<string, string>;
|
||||
collectDefaultMetrics?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MetricsService implements OnModuleInit {
|
||||
private readonly registry: Registry;
|
||||
private readonly httpRequestsTotal: Counter;
|
||||
private readonly httpRequestDuration: Histogram;
|
||||
private readonly httpRequestsInFlight: Gauge;
|
||||
|
||||
constructor(private readonly options: MetricsServiceOptions = {}) {
|
||||
this.registry = register;
|
||||
|
||||
const prefix = options.prefix || '';
|
||||
|
||||
// Set default labels if provided
|
||||
if (options.defaultLabels) {
|
||||
this.registry.setDefaultLabels(options.defaultLabels);
|
||||
}
|
||||
|
||||
// HTTP Request Counter
|
||||
this.httpRequestsTotal = new Counter({
|
||||
name: `${prefix}http_requests_total`,
|
||||
help: 'Total number of HTTP requests',
|
||||
labelNames: ['method', 'path', 'status'],
|
||||
registers: [this.registry],
|
||||
});
|
||||
|
||||
// HTTP Request Duration Histogram
|
||||
this.httpRequestDuration = new Histogram({
|
||||
name: `${prefix}http_request_duration_seconds`,
|
||||
help: 'HTTP request duration in seconds',
|
||||
labelNames: ['method', 'path', 'status'],
|
||||
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
|
||||
registers: [this.registry],
|
||||
});
|
||||
|
||||
// HTTP Requests In Flight
|
||||
this.httpRequestsInFlight = new Gauge({
|
||||
name: `${prefix}http_requests_in_flight`,
|
||||
help: 'Number of HTTP requests currently being processed',
|
||||
labelNames: ['method'],
|
||||
registers: [this.registry],
|
||||
});
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
// Collect default Node.js metrics (CPU, memory, event loop, etc.)
|
||||
if (this.options.collectDefaultMetrics !== false) {
|
||||
collectDefaultMetrics({
|
||||
register: this.registry,
|
||||
prefix: this.options.prefix || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment HTTP request counter
|
||||
*/
|
||||
incrementHttpRequests(method: string, path: string, status: number): void {
|
||||
this.httpRequestsTotal.inc({
|
||||
method,
|
||||
path: this.normalizePath(path),
|
||||
status: String(status),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe HTTP request duration
|
||||
*/
|
||||
observeHttpDuration(method: string, path: string, status: number, durationMs: number): void {
|
||||
this.httpRequestDuration.observe(
|
||||
{
|
||||
method,
|
||||
path: this.normalizePath(path),
|
||||
status: String(status),
|
||||
},
|
||||
durationMs / 1000 // Convert to seconds
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment in-flight requests
|
||||
*/
|
||||
incrementInFlight(method: string): void {
|
||||
this.httpRequestsInFlight.inc({ method });
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement in-flight requests
|
||||
*/
|
||||
decrementInFlight(method: string): void {
|
||||
this.httpRequestsInFlight.dec({ method });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all metrics as Prometheus text format
|
||||
*/
|
||||
async getMetrics(): Promise<string> {
|
||||
return this.registry.metrics();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content type for metrics endpoint
|
||||
*/
|
||||
getContentType(): string {
|
||||
return this.registry.contentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom counter
|
||||
*/
|
||||
createCounter(name: string, help: string, labelNames: string[] = []): Counter {
|
||||
return new Counter({
|
||||
name: this.options.prefix ? `${this.options.prefix}${name}` : name,
|
||||
help,
|
||||
labelNames,
|
||||
registers: [this.registry],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom histogram
|
||||
*/
|
||||
createHistogram(
|
||||
name: string,
|
||||
help: string,
|
||||
labelNames: string[] = [],
|
||||
buckets?: number[]
|
||||
): Histogram {
|
||||
return new Histogram({
|
||||
name: this.options.prefix ? `${this.options.prefix}${name}` : name,
|
||||
help,
|
||||
labelNames,
|
||||
buckets,
|
||||
registers: [this.registry],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom gauge
|
||||
*/
|
||||
createGauge(name: string, help: string, labelNames: string[] = []): Gauge {
|
||||
return new Gauge({
|
||||
name: this.options.prefix ? `${this.options.prefix}${name}` : name,
|
||||
help,
|
||||
labelNames,
|
||||
registers: [this.registry],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path to prevent high cardinality
|
||||
* Replaces UUIDs and numeric IDs with placeholders
|
||||
*/
|
||||
private normalizePath(path: string): string {
|
||||
return (
|
||||
path
|
||||
// Replace UUIDs
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id')
|
||||
// Replace numeric IDs
|
||||
.replace(/\/\d+/g, '/:id')
|
||||
// Remove query strings
|
||||
.split('?')[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
22
packages/shared-nestjs-metrics/tsconfig.json
Normal file
22
packages/shared-nestjs-metrics/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
"lib": ["ES2021"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
803
pnpm-lock.yaml
generated
803
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
27
scripts/mac-mini/setup-umami-db.sh
Executable file
27
scripts/mac-mini/setup-umami-db.sh
Executable file
|
|
@ -0,0 +1,27 @@
|
|||
#!/bin/bash
|
||||
# Setup Umami database on Mac Mini
|
||||
# Run this script after starting PostgreSQL container
|
||||
|
||||
set -e
|
||||
|
||||
echo "Creating Umami database..."
|
||||
|
||||
# Check if running inside docker network or from host
|
||||
if docker ps | grep -q manacore-postgres; then
|
||||
docker exec -i manacore-postgres psql -U postgres <<EOF
|
||||
SELECT 'CREATE DATABASE umami' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'umami')\gexec
|
||||
EOF
|
||||
echo "Umami database created successfully!"
|
||||
else
|
||||
echo "Error: PostgreSQL container 'manacore-postgres' is not running"
|
||||
echo "Please start it with: docker compose -f docker-compose.macmini.yml up -d postgres"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Start Umami: docker compose -f docker-compose.macmini.yml up -d umami"
|
||||
echo "2. Access Umami at: https://analytics.mana.how"
|
||||
echo "3. Default login: admin / umami"
|
||||
echo "4. Change the password immediately!"
|
||||
echo "5. Create websites and get tracking IDs for your apps"
|
||||
Loading…
Add table
Add a link
Reference in a new issue