feat: migrate website from Jekyll to Astro

Rebuilds the site using Astro (SSG) with Tailwind CSS v4, replacing the
Jekyll/Cayman theme. Key changes:

- Splits the monolithic /rules page into 110 statically-generated pages
  (92 per-service + 13 group index + homepage + guide pages) for SEO
- URL structure: /rules/[group-slug]/[service-slug]/ with backward-
  compatibility redirect map for old anchor-based URLs (/rules#redis)
- Modern UI: Prometheus-orange accent, dark mode (system + toggle),
  sticky sidebar, responsive layout, copy-to-clipboard per rule/section
- SEO: per-page <title>, <meta description>, Open Graph, Twitter Card,
  canonical URLs, sitemap.xml via @astrojs/sitemap
- GEO: FAQPage JSON-LD schema on each service page (rules as Q&A pairs
  for AI search engines), TechArticle schema, BreadcrumbList
- Search: Pagefind (build-time index, lazy-loaded, ~200KB)
- Zero JS by default; copy buttons and theme toggle use inline scripts
- New CI: .github/workflows/deploy.yml builds Astro + Pagefind and
  deploys to GitHub Pages via actions/deploy-pages
- Existing dist.yml and test.yml workflows are untouched
- _data/rules.yml remains the single source of truth

Note: GitHub Pages source must be changed from "Build from branch"
(Jekyll) to "GitHub Actions" in repository settings.
This commit is contained in:
Samuel Berthe 2026-04-06 21:47:42 +02:00
parent 0d148832d3
commit 6ff7e74524
No known key found for this signature in database
GPG key ID: 64863511FFBD0E3C
37 changed files with 9513 additions and 0 deletions

62
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,62 @@
name: Deploy Astro site to GitHub Pages
on:
push:
branches: [master]
workflow_dispatch:
# Only allow one concurrent deployment
concurrency:
group: pages
cancel-in-progress: false
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: site/package-lock.json
- name: Install dependencies
working-directory: site
run: npm ci
- name: Build Astro site
working-directory: site
env:
ASTRO_TELEMETRY_DISABLED: "1"
run: npm run build
- name: Build Pagefind search index
working-directory: site
run: npx pagefind --site dist
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: site/dist
deploy:
name: Deploy
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

10
.gitignore vendored
View file

@ -1,8 +1,18 @@
# Jekyll (legacy)
_site/
.sass-cache/
.jekyll-cache/
.jekyll-metadata
# Generated data
_data/rules.json
test/rules/
# Node / Astro
/node_modules
site/node_modules/
site/dist/
site/.astro/
# Misc
.worktrees/

39
site/astro.config.mjs Normal file
View file

@ -0,0 +1,39 @@
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import sitemap from '@astrojs/sitemap';
import icon from 'astro-icon';
import { parse as parseYaml } from 'yaml';
import { readFileSync } from 'fs';
import { resolve } from 'path';
/** Custom Vite plugin that parses YAML files using the 'yaml' package,
* which tolerates duplicate keys (last one wins) unlike js-yaml 4.x. */
function yamlPlugin() {
return {
name: 'vite-plugin-yaml-tolerant',
transform(code, id) {
if (!id.endsWith('.yml') && !id.endsWith('.yaml')) return null;
const content = readFileSync(resolve(id), 'utf-8');
const data = parseYaml(content, { merge: true, strict: false, uniqueKeys: false });
return {
code: `export default ${JSON.stringify(data)};`,
map: null,
};
},
};
}
export default defineConfig({
site: 'https://samber.github.io',
base: '/awesome-prometheus-alerts',
output: 'static',
integrations: [
tailwind({ applyBaseStyles: false }),
sitemap(),
icon(),
],
vite: {
plugins: [yamlPlugin()],
assetsInclude: ['**/*.yml'],
},
});

7330
site/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

31
site/package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "awesome-prometheus-alerts-site",
"type": "module",
"version": "1.0.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/sitemap": "^3.0.0",
"@astrojs/tailwind": "^5.0.0",
"@iconify-json/lucide": "^1.0.0",
"@rollup/plugin-yaml": "^4.0.0",
"astro": "^5.0.0",
"astro-icon": "^1.0.0",
"js-yaml": "^4.1.0",
"pagefind": "^1.0.0",
"tailwindcss": "^3.4.0",
"yaml": "^2.8.3"
},
"devDependencies": {
"@types/js-yaml": "^4.0.0"
},
"browserslist": [
"> 0.5%",
"last 2 versions",
"not dead"
]
}

BIN
site/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

4
site/public/robots.txt Normal file
View file

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://samber.github.io/awesome-prometheus-alerts/sitemap-index.xml

View file

@ -0,0 +1,52 @@
---
interface BreadcrumbItem {
label: string;
href?: string;
}
interface Props {
items: BreadcrumbItem[];
base: string;
}
const { items, base } = Astro.props;
const siteUrl = 'https://samber.github.io';
const allItems = [{ label: 'Home', href: `${base}/` }, ...items];
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: allItems.map((item, i) => ({
'@type': 'ListItem',
position: i + 1,
name: item.label,
...(item.href ? { item: `${siteUrl}${item.href}` } : {}),
})),
};
---
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
<nav aria-label="Breadcrumb" class="text-sm text-slate-500 dark:text-slate-400">
<ol class="flex items-center flex-wrap gap-1">
{allItems.map((item, i) => (
<li class="flex items-center gap-1">
{i > 0 && (
<svg class="w-3.5 h-3.5 text-slate-300 dark:text-slate-600 flex-shrink-0" 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>
)}
{item.href && i < allItems.length - 1 ? (
<a href={item.href} class="hover:text-brand dark:hover:text-brand-dark transition-colors">
{item.label}
</a>
) : (
<span class={i === allItems.length - 1 ? 'text-slate-700 dark:text-slate-200 font-medium' : ''}>
{item.label}
</span>
)}
</li>
))}
</ol>
</nav>

View file

@ -0,0 +1,87 @@
---
interface Props {
targetId: string;
label?: string;
variant?: 'icon' | 'text';
class?: string;
}
const { targetId, label = 'Copy', variant = 'icon', class: extraClass = '' } = Astro.props;
const btnId = `copy-btn-${targetId}`;
---
{variant === 'icon' ? (
<button
id={btnId}
data-copy-target={targetId}
aria-label="Copy to clipboard"
class={`copy-btn inline-flex items-center gap-1.5 px-2 py-1 text-xs rounded text-slate-400 dark:text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors ${extraClass}`}
>
<svg class="copy-icon w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<svg class="check-icon w-3.5 h-3.5 hidden text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span class="copy-label sr-only">Copy</span>
<span class="copied-label hidden text-green-500 not-sr-only text-xs">Copied!</span>
</button>
) : (
<button
id={btnId}
data-copy-target={targetId}
class={`copy-btn inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:border-brand dark:hover:border-brand-dark hover:text-brand dark:hover:text-brand-dark transition-colors ${extraClass}`}
>
<svg class="copy-icon w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<svg class="check-icon w-3.5 h-3.5 hidden text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span class="copy-label">{label}</span>
<span class="copied-label hidden text-green-600 dark:text-green-400">Copied!</span>
</button>
)}
<script>
document.querySelectorAll<HTMLButtonElement>('.copy-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const targetId = btn.dataset.copyTarget;
if (!targetId) return;
const target = document.getElementById(targetId);
if (!target) return;
const text = target.textContent ?? '';
try {
await navigator.clipboard.writeText(text.trim());
} catch {
// Fallback for older browsers
const ta = document.createElement('textarea');
ta.value = text.trim();
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
// Visual feedback
const copyIcon = btn.querySelector('.copy-icon');
const checkIcon = btn.querySelector('.check-icon');
const copyLabel = btn.querySelector('.copy-label');
const copiedLabel = btn.querySelector('.copied-label');
copyIcon?.classList.add('hidden');
checkIcon?.classList.remove('hidden');
copyLabel?.classList.add('hidden');
copiedLabel?.classList.remove('hidden');
setTimeout(() => {
copyIcon?.classList.remove('hidden');
checkIcon?.classList.add('hidden');
copyLabel?.classList.remove('hidden');
copiedLabel?.classList.add('hidden');
}, 2000);
});
});
</script>

View file

@ -0,0 +1,122 @@
---
import type { Exporter, Service } from '../data/rules';
import { formatExporterAsYaml, getDistUrl } from '../data/rules';
import RuleCard from './RuleCard.astro';
import CopyButton from './CopyButton.astro';
interface Props {
exporter: Exporter;
service: Service;
groupIndex: number;
serviceIndex: number;
exporterIndex: number;
showExporterNumber: boolean;
}
const {
exporter,
service,
groupIndex,
serviceIndex,
exporterIndex,
showExporterNumber,
} = Astro.props;
const distUrl = getDistUrl(service.name, exporter.slug);
const allRulesId = `exporter-all-${groupIndex}-${serviceIndex}-${exporterIndex}`;
const allRulesYaml = formatExporterAsYaml(exporter);
const wgetCommand = `wget ${distUrl}`;
const wgetId = `wget-${groupIndex}-${serviceIndex}-${exporterIndex}`;
const exporterPrefix = showExporterNumber
? `${groupIndex}.${serviceIndex}.${exporterIndex}.`
: `${groupIndex}.${serviceIndex}.`;
---
<section class="mb-10">
<!-- Hidden pre for copy-all -->
<pre id={allRulesId} class="hidden">{allRulesYaml}</pre>
<!-- Exporter header -->
<div class="flex items-center justify-between flex-wrap gap-3 mb-3 pb-3 border-b border-slate-100 dark:border-slate-800">
<div class="min-w-0">
{exporter.name ? (
<h2 class="text-base font-semibold text-slate-800 dark:text-slate-100 flex items-center gap-2 flex-wrap">
<span class="text-slate-400 dark:text-slate-500 font-normal text-sm">
{exporterPrefix}
</span>
{exporter.doc_url ? (
<a href={exporter.doc_url} target="_blank" rel="noopener noreferrer" class="text-brand dark:text-brand-dark hover:underline">
{exporter.name}
</a>
) : (
<span>{exporter.name}</span>
)}
<span class="text-xs font-normal text-slate-400 dark:text-slate-500">
({exporter.rules?.length ?? 0} rules)
</span>
</h2>
) : (
<h2 class="text-base font-semibold text-slate-800 dark:text-slate-100">
<span class="text-slate-400 dark:text-slate-500 font-normal text-sm mr-1">{exporterPrefix}</span>
{service.name}
<span class="text-xs font-normal text-slate-400 dark:text-slate-500 ml-2">
({exporter.rules?.length ?? 0} rules)
</span>
</h2>
)}
</div>
<div class="flex items-center gap-2">
<CopyButton targetId={allRulesId} label="Copy all" variant="text" />
</div>
</div>
<!-- Exporter comments -->
{exporter.comments && (
<div class="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50 text-sm text-amber-800 dark:text-amber-200">
<pre class="whitespace-pre-wrap font-sans text-sm">{exporter.comments.trim()}</pre>
</div>
)}
<!-- wget download command -->
{(exporter.rules?.length ?? 0) > 0 && (
<div class="mb-4 flex items-center gap-2">
<div class="flex-1 flex items-center gap-2 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-lg px-3 py-2 min-w-0">
<svg class="w-3.5 h-3.5 text-slate-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<pre id={wgetId} class="text-xs font-mono text-slate-600 dark:text-slate-300 overflow-x-auto whitespace-pre">{wgetCommand}</pre>
</div>
<CopyButton targetId={wgetId} variant="icon" />
</div>
)}
<!-- Rules list -->
{(exporter.rules?.length ?? 0) === 0 ? (
<div class="rounded-lg border border-dashed border-slate-200 dark:border-slate-700 p-6 text-center">
<p class="text-sm text-slate-400 dark:text-slate-500">
No rules yet —{' '}
<a href="https://github.com/samber/awesome-prometheus-alerts" target="_blank" rel="noopener noreferrer" class="text-brand dark:text-brand-dark hover:underline">
contribute on GitHub 👋
</a>
</p>
</div>
) : (
<div>
{exporter.rules.map((rule, ruleIdx) => {
const anchorId = `rule-${groupIndex}-${serviceIndex}-${exporterIndex}-${ruleIdx + 1}`;
const ruleNumber = showExporterNumber
? `${groupIndex}.${serviceIndex}.${exporterIndex}.${ruleIdx + 1}.`
: `${groupIndex}.${serviceIndex}.${ruleIdx + 1}.`;
return (
<RuleCard
rule={rule}
anchorId={anchorId}
ruleNumber={ruleNumber}
/>
);
})}
</div>
)}
</section>

View file

@ -0,0 +1,71 @@
---
interface Props {
base: string;
}
const { base } = Astro.props;
---
<footer class="border-t border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-900 mt-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- Column 1: About -->
<div>
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 text-brand" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"/>
</svg>
<span class="font-semibold text-slate-900 dark:text-white text-sm">Awesome Prometheus Alerts</span>
</div>
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed">
A curated collection of copy-pasteable Prometheus alerting rules for 90+ services and exporters.
</p>
<div class="mt-4 flex gap-3">
<a href="https://twitter.com/share?via=samuelberthe&text=🚨 Awesome Prometheus Alerts&url=https://samber.github.io/awesome-prometheus-alerts" target="_blank" rel="noopener noreferrer" class="text-slate-400 hover:text-blue-400 transition-colors" aria-label="Share on Twitter">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.746l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
</a>
<a href="http://www.linkedin.com/shareArticle?mini=true&url=https://samber.github.io/awesome-prometheus-alerts/" target="_blank" rel="noopener noreferrer" class="text-slate-400 hover:text-blue-600 transition-colors" aria-label="Share on LinkedIn">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
</a>
<a href="https://github.com/samber/awesome-prometheus-alerts" target="_blank" rel="noopener noreferrer" class="text-slate-400 hover:text-slate-900 dark:hover:text-white transition-colors" aria-label="GitHub repository">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/></svg>
</a>
</div>
</div>
<!-- Column 2: Quick links -->
<div>
<h3 class="text-sm font-semibold text-slate-900 dark:text-white mb-3">Quick links</h3>
<ul class="space-y-2">
<li><a href={`${base}/rules/`} class="text-sm text-slate-500 dark:text-slate-400 hover:text-brand dark:hover:text-brand-dark transition-colors">Browse all rules</a></li>
<li><a href={`${base}/alertmanager/`} class="text-sm text-slate-500 dark:text-slate-400 hover:text-brand dark:hover:text-brand-dark transition-colors">AlertManager Config</a></li>
<li><a href={`${base}/blackbox-exporter/`} class="text-sm text-slate-500 dark:text-slate-400 hover:text-brand dark:hover:text-brand-dark transition-colors">Blackbox Exporter</a></li>
<li><a href={`${base}/sleep-peacefully/`} class="text-sm text-slate-500 dark:text-slate-400 hover:text-brand dark:hover:text-brand-dark transition-colors">Sleep Peacefully</a></li>
<li><a href="https://github.com/samber/awesome-prometheus-alerts/blob/master/CONTRIBUTING.md" target="_blank" rel="noopener noreferrer" class="text-sm text-slate-500 dark:text-slate-400 hover:text-brand dark:hover:text-brand-dark transition-colors">Contributing Guide</a></li>
</ul>
</div>
<!-- Column 3: Sponsors -->
<div>
<h3 class="text-sm font-semibold text-slate-900 dark:text-white mb-3">Sponsors</h3>
<div class="space-y-4">
<a href="https://cast.ai/samuel" target="_blank" rel="noopener noreferrer" class="block hover:opacity-80 transition-opacity">
<img src={`${base}/images/sponsor-cast-ai.png`} alt="CAST AI — Kubernetes cost optimization" class="h-6" />
</a>
<a href="https://betterstack.com/" target="_blank" rel="noopener noreferrer" class="block hover:opacity-80 transition-opacity">
<img src={`${base}/images/sponsor-betterstack.png`} alt="Better Stack — Uptime monitoring and log management" class="h-6" />
</a>
</div>
</div>
</div>
<div class="mt-10 pt-6 border-t border-slate-200 dark:border-slate-800 flex flex-col sm:flex-row justify-between items-center gap-2 text-xs text-slate-400 dark:text-slate-500">
<span>
<a href="https://github.com/samber/awesome-prometheus-alerts" class="hover:text-brand dark:hover:text-brand-dark transition-colors">awesome-prometheus-alerts</a>
{' '}is maintained by{' '}
<a href="https://github.com/samber" class="hover:text-brand dark:hover:text-brand-dark transition-colors">@samber</a>
</span>
<span>Licensed under <a href="https://github.com/samber/awesome-prometheus-alerts/blob/master/LICENSE" class="hover:text-brand dark:hover:text-brand-dark transition-colors">Creative Commons CC4</a></span>
</div>
</div>
</footer>

View file

@ -0,0 +1,144 @@
---
import ThemeToggle from './ThemeToggle.astro';
interface Props {
base: string;
}
const { base } = Astro.props;
const currentPath = Astro.url.pathname;
function isActive(path: string) {
return currentPath.startsWith(`${base}${path}`);
}
---
<!-- Sponsor banner -->
<div class="bg-slate-50 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 text-xs text-slate-500 dark:text-slate-400 py-1.5 px-4 text-center">
Kindly supported by&nbsp;
<a href="https://cast.ai/samuel" target="_blank" rel="noopener noreferrer" class="inline-flex items-center mx-2 hover:opacity-80">
<img src={`${base}/images/sponsor-cast-ai.png`} alt="CAST AI" class="h-4 inline" />
</a>
and&nbsp;
<a href="https://betterstack.com/" target="_blank" rel="noopener noreferrer" class="inline-flex items-center mx-2 hover:opacity-80">
<img src={`${base}/images/sponsor-betterstack.png`} alt="Better Stack" class="h-4 inline" />
</a>
</div>
<!-- Main header -->
<header class="sticky top-0 z-40 bg-white/95 dark:bg-slate-950/95 backdrop-blur border-b border-slate-200 dark:border-slate-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-14">
<!-- Logo -->
<a href={`${base}/`} class="flex items-center gap-2 font-semibold text-slate-900 dark:text-white hover:text-brand dark:hover:text-brand-dark transition-colors">
<svg class="w-6 h-6 text-brand" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"/>
</svg>
<span class="hidden sm:block text-sm">Awesome Prometheus Alerts</span>
<span class="sm:hidden text-sm">APA</span>
</a>
<!-- Desktop nav -->
<nav class="hidden md:flex items-center gap-6" aria-label="Main navigation">
<a
href={`${base}/rules/`}
class={`text-sm font-medium transition-colors ${isActive('/rules') ? 'text-brand dark:text-brand-dark' : 'text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white'}`}
>
Rules
</a>
<!-- Guides dropdown -->
<div class="relative group">
<button class={`text-sm font-medium transition-colors flex items-center gap-1 ${isActive('/alertmanager') || isActive('/blackbox') || isActive('/sleep') ? 'text-brand dark:text-brand-dark' : 'text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white'}`}>
Guides
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div class="absolute top-full right-0 mt-1 w-52 bg-white dark:bg-slate-900 rounded-lg shadow-lg border border-slate-200 dark:border-slate-700 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-150 py-1">
<a href={`${base}/alertmanager/`} class="block px-4 py-2 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 hover:text-brand dark:hover:text-brand-dark">
AlertManager Config
</a>
<a href={`${base}/blackbox-exporter/`} class="block px-4 py-2 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 hover:text-brand dark:hover:text-brand-dark">
Blackbox Exporter
</a>
<a href={`${base}/sleep-peacefully/`} class="block px-4 py-2 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 hover:text-brand dark:hover:text-brand-dark">
Sleep Peacefully
</a>
</div>
</div>
<a
href="https://github.com/samber/awesome-prometheus-alerts/blob/master/CONTRIBUTING.md"
target="_blank"
rel="noopener noreferrer"
class="text-sm font-medium text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white transition-colors"
>
Contribute
</a>
<a
href="https://github.com/samber/awesome-prometheus-alerts"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub repository"
class="text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd" />
</svg>
</a>
</nav>
<div class="flex items-center gap-2">
<ThemeToggle />
<!-- Mobile hamburger -->
<button
id="mobile-menu-btn"
class="md:hidden p-2 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
aria-label="Toggle menu"
aria-expanded="false"
aria-controls="mobile-menu"
>
<svg id="hamburger-icon" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg id="close-icon" class="w-5 h-5 hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile menu -->
<div
id="mobile-menu"
class="hidden md:hidden border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950"
>
<nav class="px-4 py-3 space-y-1" aria-label="Mobile navigation">
<a href={`${base}/rules/`} class="block px-3 py-2 rounded-lg text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800">Rules</a>
<a href={`${base}/alertmanager/`} class="block px-3 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">AlertManager Config</a>
<a href={`${base}/blackbox-exporter/`} class="block px-3 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">Blackbox Exporter</a>
<a href={`${base}/sleep-peacefully/`} class="block px-3 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">Sleep Peacefully</a>
<a href="https://github.com/samber/awesome-prometheus-alerts/blob/master/CONTRIBUTING.md" target="_blank" rel="noopener noreferrer" class="block px-3 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">Contribute</a>
<a href="https://github.com/samber/awesome-prometheus-alerts" target="_blank" rel="noopener noreferrer" class="block px-3 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">GitHub</a>
</nav>
</div>
</header>
<script>
const btn = document.getElementById('mobile-menu-btn');
const menu = document.getElementById('mobile-menu');
const hamburger = document.getElementById('hamburger-icon');
const closeIcon = document.getElementById('close-icon');
btn?.addEventListener('click', () => {
const isOpen = menu?.classList.toggle('hidden') === false;
btn.setAttribute('aria-expanded', String(isOpen));
hamburger?.classList.toggle('hidden', isOpen);
closeIcon?.classList.toggle('hidden', !isOpen);
});
</script>

View file

@ -0,0 +1,56 @@
---
import type { Rule } from '../data/rules';
import { formatRuleAsYaml } from '../data/rules';
import SeverityBadge from './SeverityBadge.astro';
import CopyButton from './CopyButton.astro';
interface Props {
rule: Rule;
anchorId: string;
ruleNumber: string;
}
const { rule, anchorId, ruleNumber } = Astro.props;
const yamlContent = formatRuleAsYaml(rule);
const codeId = `code-${anchorId}`;
---
<article class="group mb-4 rounded-xl border border-slate-200 dark:border-slate-700/60 bg-white dark:bg-slate-900 hover:border-slate-300 dark:hover:border-slate-600 transition-colors" id={anchorId}>
<!-- Rule header -->
<div class="flex items-start justify-between gap-3 px-4 pt-3 pb-2">
<div class="flex items-center gap-2 flex-wrap min-w-0">
<SeverityBadge severity={rule.severity} />
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-100">
<span class="text-slate-400 dark:text-slate-500 font-normal text-xs mr-1">{ruleNumber}</span>
{rule.name}
</h3>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
<!-- Permalink -->
<a
href={`#${anchorId}`}
class="p-1.5 rounded text-slate-300 dark:text-slate-600 hover:text-slate-500 dark:hover:text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Permalink to this rule"
title="Permalink"
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</a>
<CopyButton targetId={codeId} variant="icon" />
</div>
</div>
<!-- Description -->
<p class="px-4 pb-2 text-sm text-slate-500 dark:text-slate-400 leading-relaxed">
{rule.description}
</p>
<!-- YAML code block -->
<div class="relative mx-4 mb-4">
<pre
id={codeId}
class="rule-code text-xs leading-relaxed whitespace-pre-wrap"
>{yamlContent}</pre>
</div>
</article>

View file

@ -0,0 +1,42 @@
---
interface Props {
title: string;
description: string;
canonicalUrl: string;
ogImage?: string;
jsonLd?: object | object[];
base: string;
siteUrl: string;
}
const { title, description, canonicalUrl, jsonLd, base, siteUrl } = Astro.props;
const ogImage = Astro.props.ogImage ?? `${base}/images/prometheus-logo.png`;
const fullOgImage = ogImage.startsWith('http') ? ogImage : `${siteUrl}${ogImage}`;
const jsonLdArray = jsonLd
? Array.isArray(jsonLd) ? jsonLd : [jsonLd]
: [];
---
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalUrl} />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={fullOgImage} />
<meta property="og:site_name" content="Awesome Prometheus Alerts" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={fullOgImage} />
<meta name="twitter:site" content="@samuelberthe" />
{jsonLdArray.map((schema) => (
<script type="application/ld+json" set:html={JSON.stringify(schema)} />
))}

View file

@ -0,0 +1,45 @@
---
// Pagefind search widget
// The pagefind bundle is generated after `astro build` with: npx pagefind --site dist
interface Props {
class?: string;
}
const { class: extraClass = '' } = Astro.props;
const base = import.meta.env.BASE_URL;
---
<div id="search" class={extraClass}></div>
<link rel="stylesheet" href={`${base}/pagefind/pagefind-ui.css`} />
<script>
// Load Pagefind UI lazily (only when the search div is visible/focused)
function initPagefind() {
const base = import.meta.env.BASE_URL;
// @ts-ignore
import(`${base}/pagefind/pagefind-ui.js`).then((module) => {
const PagefindUI = module.PagefindUI;
new PagefindUI({
element: '#search',
showSubResults: true,
highlightParam: 'highlight',
baseUrl: base,
});
});
}
const searchEl = document.getElementById('search');
if (searchEl) {
// Load when visible via Intersection Observer
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
initPagefind();
observer.disconnect();
}
}, { rootMargin: '200px' });
observer.observe(searchEl);
} else {
initPagefind();
}
}
</script>

View file

@ -0,0 +1,41 @@
---
import type { Service, Group } from '../data/rules';
import { getRuleCount, getGroupSlug, getServiceSlug } from '../data/rules';
interface Props {
service: Service;
group: Group;
base: string;
}
const { service, group, base } = Astro.props;
const ruleCount = getRuleCount(service);
const groupSlug = getGroupSlug(group);
const serviceSlug = getServiceSlug(service);
const href = `${base}/rules/${groupSlug}/${serviceSlug}/`;
const exporterNames = service.exporters
.map((e) => e.name)
.filter(Boolean)
.slice(0, 3);
---
<a
href={href}
class="group block p-4 rounded-xl border border-slate-200 dark:border-slate-700/60 bg-white dark:bg-slate-900 hover:border-brand/40 dark:hover:border-brand-dark/40 hover:shadow-sm transition-all duration-150"
>
<div class="flex items-start justify-between gap-2 mb-2">
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-100 group-hover:text-brand dark:group-hover:text-brand-dark transition-colors leading-snug">
{service.name}
</h3>
<span class="flex-shrink-0 text-xs font-medium px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400">
{ruleCount} rule{ruleCount !== 1 ? 's' : ''}
</span>
</div>
{exporterNames.length > 0 && (
<p class="text-xs text-slate-400 dark:text-slate-500 truncate">
{exporterNames.join(' · ')}{service.exporters.length > 3 ? ` +${service.exporters.length - 3}` : ''}
</p>
)}
</a>

View file

@ -0,0 +1,16 @@
---
interface Props {
severity: 'critical' | 'warning' | 'info';
}
const { severity } = Astro.props;
const classes = {
critical: 'badge-critical',
warning: 'badge-warning',
info: 'badge-info',
}[severity];
---
<span class={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase tracking-wide ${classes}`}>
{severity}
</span>

View file

@ -0,0 +1,52 @@
---
import type { Group } from '../data/rules';
import { getGroupSlug, getServiceSlug } from '../data/rules';
interface Props {
groups: Group[];
currentGroupSlug?: string;
currentServiceSlug?: string;
base: string;
}
const { groups, currentGroupSlug, currentServiceSlug, base } = Astro.props;
---
<aside class="hidden lg:block w-60 flex-shrink-0" aria-label="Rules navigation">
<nav class="sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto pr-2 pb-8">
<ul class="space-y-4">
{groups.map((group) => {
const groupSlug = getGroupSlug(group);
const isGroupActive = groupSlug === currentGroupSlug;
return (
<li>
<p class={`text-xs font-semibold uppercase tracking-wider mb-1.5 ${isGroupActive ? 'text-brand dark:text-brand-dark' : 'text-slate-400 dark:text-slate-500'}`}>
{group.name}
</p>
<ul class="space-y-0.5">
{group.services.map((service) => {
const serviceSlug = getServiceSlug(service);
const isActive = isGroupActive && serviceSlug === currentServiceSlug;
return (
<li>
<a
href={`${base}/rules/${groupSlug}/${serviceSlug}/`}
class={`block text-sm py-0.5 px-2 rounded transition-colors truncate ${
isActive
? 'text-brand dark:text-brand-dark bg-brand/5 dark:bg-brand-dark/10 font-medium'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800/50'
}`}
>
{service.name}
</a>
</li>
);
})}
</ul>
</li>
);
})}
</ul>
</nav>
</aside>

View file

@ -0,0 +1,27 @@
---
import { getTotalRuleCount, getTotalServiceCount, getTotalExporterCount, data } from '../data/rules';
const totalRules = getTotalRuleCount();
const totalServices = getTotalServiceCount();
const totalExporters = getTotalExporterCount();
const totalGroups = data.groups.length;
---
<div class="flex flex-wrap justify-center gap-6 sm:gap-10 py-4 text-center">
<div>
<div class="text-2xl font-bold text-brand dark:text-brand-dark">{totalRules}</div>
<div class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">alert rules</div>
</div>
<div>
<div class="text-2xl font-bold text-brand dark:text-brand-dark">{totalServices}</div>
<div class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">services</div>
</div>
<div>
<div class="text-2xl font-bold text-brand dark:text-brand-dark">{totalExporters}</div>
<div class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">exporters</div>
</div>
<div>
<div class="text-2xl font-bold text-brand dark:text-brand-dark">{totalGroups}</div>
<div class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">categories</div>
</div>
</div>

View file

@ -0,0 +1,25 @@
---
---
<button
id="theme-toggle"
aria-label="Toggle dark mode"
class="p-2 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<!-- Sun icon (shown in dark mode) -->
<svg id="icon-sun" xmlns="http://www.w3.org/2000/svg" class="hidden dark:block w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364-.707.707M6.343 17.657l-.707.707M17.657 17.657l-.707-.707M6.343 6.343l-.707-.707M12 7a5 5 0 100 10 5 5 0 000-10z" />
</svg>
<!-- Moon icon (shown in light mode) -->
<svg id="icon-moon" xmlns="http://www.w3.org/2000/svg" class="block dark:hidden w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</button>
<script>
const btn = document.getElementById('theme-toggle');
btn?.addEventListener('click', () => {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
</script>

164
site/src/data/rules.ts Normal file
View file

@ -0,0 +1,164 @@
// @ts-ignore — Vite YAML plugin provides this at build time
import rulesData from '../../../_data/rules.yml';
export interface Rule {
name: string;
description: string;
query: string;
severity: 'critical' | 'warning' | 'info';
for?: string;
comments?: string;
}
export interface Exporter {
slug: string;
name?: string;
doc_url?: string;
comments?: string;
rules: Rule[];
}
export interface Service {
name: string;
exporters: Exporter[];
}
export interface Group {
name: string;
services: Service[];
}
export interface RulesData {
groups: Group[];
}
export const data: RulesData = rulesData as RulesData;
/** Slugify a name for use in URLs — mirrors the dist/ workflow naming */
export function toSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export function getGroupSlug(group: Group): string {
return toSlug(group.name);
}
export function getServiceSlug(service: Service): string {
return toSlug(service.name);
}
/** CamelCase a rule name for the Prometheus alert name field */
export function toCamelCase(name: string): string {
return name
.split(/\s+/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join('');
}
/** Count all rules across a service's exporters */
export function getRuleCount(service: Service): number {
return service.exporters.reduce((sum, exp) => sum + (exp.rules?.length ?? 0), 0);
}
/** Count all rules in the entire dataset */
export function getTotalRuleCount(): number {
return data.groups.reduce(
(sum, group) => sum + group.services.reduce((s, svc) => s + getRuleCount(svc), 0),
0
);
}
/** Count all services */
export function getTotalServiceCount(): number {
return data.groups.reduce((sum, group) => sum + group.services.length, 0);
}
/** Count all exporters */
export function getTotalExporterCount(): number {
return data.groups.reduce(
(sum, group) =>
sum + group.services.reduce((s, svc) => s + svc.exporters.length, 0),
0
);
}
/** Flat list of all services with routing context */
export function getAllServices(): Array<{
group: Group;
service: Service;
groupSlug: string;
serviceSlug: string;
ruleCount: number;
}> {
return data.groups.flatMap((group) =>
group.services.map((service) => ({
group,
service,
groupSlug: getGroupSlug(group),
serviceSlug: getServiceSlug(service),
ruleCount: getRuleCount(service),
}))
);
}
/** Build a redirect map: old anchor slug -> new path (for /rules/ page redirect) */
export function buildRedirectMap(base: string): Record<string, string> {
const map: Record<string, string> = {};
for (const { group, service, groupSlug, serviceSlug } of getAllServices()) {
// Current site uses: service.name | replace: " ", "-" | downcase
const oldAnchor = service.name.replace(/ /g, '-').toLowerCase();
map[oldAnchor] = `${base}/rules/${groupSlug}/${serviceSlug}/`;
}
return map;
}
/** Format a rule as copy-pasteable Prometheus alert YAML */
export function formatRuleAsYaml(rule: Rule): string {
const alertName = toCamelCase(rule.name);
const forValue = rule.for ?? '0m';
const commentLines = rule.comments
? rule.comments
.trim()
.split('\n')
.map((line) => ` # ${line.trim()}`)
.join('\n') + '\n'
: '';
// Escape double quotes in description
const description = rule.description.replace(/"/g, '\\"');
return `${commentLines}- alert: ${alertName}
expr: ${rule.query}
for: ${forValue}
labels:
severity: ${rule.severity}
annotations:
summary: ${rule.name} (instance {{ $labels.instance }})
description: "${description}\\n VALUE = {{ $value }}\\n LABELS = {{ $labels }}"`;
}
/** Format all rules for an exporter as a complete groups YAML block */
export function formatExporterAsYaml(exporter: Exporter): string {
const groupName = toCamelCase(exporter.slug.replace(/-/g, ' '));
const rulesYaml = (exporter.rules ?? [])
.map((rule) => formatRuleAsYaml(rule))
.join('\n\n');
return `groups:
- name: ${groupName}
rules:
${rulesYaml
.split('\n')
.map((line) => ` ${line}`)
.join('\n')}`;
}
/** Build the raw GitHub URL for a dist file */
export function getDistUrl(serviceName: string, exporterSlug: string): string {
const serviceSlug = serviceName.replace(/ /g, '-').toLowerCase();
return `https://raw.githubusercontent.com/samber/awesome-prometheus-alerts/refs/heads/master/dist/rules/${serviceSlug}/${exporterSlug}.yml`;
}

View file

@ -0,0 +1,82 @@
---
import '../styles/global.css';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import SEO from '../components/SEO.astro';
interface Props {
title: string;
description?: string;
canonicalUrl?: string;
ogImage?: string;
jsonLd?: object | object[];
noIndex?: boolean;
}
const {
title,
description = 'Collection of alerting rules for Prometheus. Copy-pasteable Prometheus alert configurations for 90+ services.',
canonicalUrl,
ogImage,
jsonLd,
noIndex = false,
} = Astro.props;
const base = import.meta.env.BASE_URL;
const siteUrl = 'https://samber.github.io';
const canonical = canonicalUrl ?? `${siteUrl}${base}${Astro.url.pathname.replace(base, '')}`;
---
<!DOCTYPE html>
<html lang="en" class="">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#E6522C" />
{noIndex && <meta name="robots" content="noindex" />}
<SEO
title={title}
description={description}
canonicalUrl={canonical}
ogImage={ogImage}
jsonLd={jsonLd}
base={base}
siteUrl={siteUrl}
/>
<link rel="icon" type="image/x-icon" href={`${base}/favicon.ico`} />
<!-- Dark mode: set class before paint to avoid flash -->
<script is:inline>
(function() {
var theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
<!-- Google Analytics 4 -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script is:inline>
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
</script>
</head>
<body class="min-h-screen flex flex-col bg-white dark:bg-slate-950 text-slate-900 dark:text-slate-100 transition-colors duration-150">
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 bg-brand text-white px-3 py-1 rounded z-50">
Skip to main content
</a>
<Header base={base} />
<main id="main-content" class="flex-1">
<slot />
</main>
<Footer base={base} />
</body>
</html>

View file

@ -0,0 +1,65 @@
---
import BaseLayout from './BaseLayout.astro';
import Breadcrumbs from '../components/Breadcrumbs.astro';
interface BreadcrumbItem {
label: string;
href?: string;
}
interface Props {
title: string;
description?: string;
breadcrumbs?: BreadcrumbItem[];
headings?: Array<{ depth: number; text: string; slug: string }>;
}
const { title, description, breadcrumbs = [], headings = [] } = Astro.props;
const base = import.meta.env.BASE_URL;
---
<BaseLayout title={`${title} | Awesome Prometheus Alerts`} description={description}>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{breadcrumbs.length > 0 && <Breadcrumbs items={breadcrumbs} base={base} />}
<div class="flex gap-12 mt-6">
<!-- TOC sidebar -->
{headings.length > 0 && (
<aside class="hidden xl:block w-56 flex-shrink-0">
<div class="sticky top-20">
<p class="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">On this page</p>
<nav>
<ul class="space-y-1.5">
{headings.filter(h => h.depth <= 3).map(h => (
<li class={h.depth === 3 ? 'pl-3' : ''}>
<a
href={`#${h.slug}`}
class="text-sm text-slate-500 dark:text-slate-400 hover:text-brand dark:hover:text-brand-dark transition-colors block"
>
{h.text}
</a>
</li>
))}
</ul>
</nav>
</div>
</aside>
)}
<!-- Content -->
<article class="flex-1 min-w-0 max-w-3xl prose prose-slate dark:prose-invert prose-code:before:content-none prose-code:after:content-none">
<h1>{title}</h1>
<slot />
</article>
</div>
</div>
</BaseLayout>
<style is:global>
.prose pre {
@apply bg-slate-900 text-slate-100;
}
.prose code {
@apply bg-slate-100 dark:bg-slate-800 text-slate-800 dark:text-slate-200 px-1.5 py-0.5 rounded text-xs;
}
</style>

View file

@ -0,0 +1,144 @@
---
import GuideLayout from '../layouts/GuideLayout.astro';
const base = import.meta.env.BASE_URL;
---
<GuideLayout
title="AlertManager Configuration"
description="Prometheus and AlertManager configuration examples, recorded rules, and troubleshooting guide for alert timing and notification routing."
breadcrumbs={[{ label: 'Guides' }, { label: 'AlertManager Config' }]}
>
<p>
If you notice a delay between an event and the first notification, read this post:
{' '}<a href="https://pracucci.com/prometheus-understanding-the-delays-on-alerting.html" target="_blank" rel="noopener noreferrer">
Understanding the delays on alerting
</a>.
</p>
<h2 id="prometheus-config">Prometheus configuration</h2>
<pre class="rule-code"><code>{`# prometheus.yml
global:
scrape_interval: 20s
# A short evaluation_interval will check alerting rules very often.
# It can be costly if you run Prometheus with 100+ alerts.
evaluation_interval: 20s
...
rule_files:
- 'alerts/*.yml'
scrape_configs:
...`}</code></pre>
<pre class="rule-code"><code>{`# alerts/example-redis.yml
groups:
- name: ExampleRedisGroup
rules:
- alert: ExampleRedisDown
expr: redis_up{} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Redis instance down"
description: "Whatever"`}</code></pre>
<h2 id="alertmanager-config">AlertManager configuration</h2>
<pre class="rule-code"><code>{`# alertmanager.yml
route:
# When a new group of alerts is created by an incoming alert, wait at
# least 'group_wait' to send the initial notification.
# This way ensures that you get multiple alerts for the same group that start
# firing shortly after another are batched together on the first notification.
group_wait: 10s
# When the first notification was sent, wait 'group_interval' to send a batch
# of new alerts that started firing for that group.
group_interval: 30s
# If an alert has successfully been sent, wait 'repeat_interval' to
# resend them.
repeat_interval: 30m
# A default receiver
receiver: "slack"
# All the above attributes are inherited by all child routes and can
# overwritten on each.
routes:
- receiver: "slack"
group_wait: 10s
match_re:
severity: critical|warning
continue: true
- receiver: "pager"
group_wait: 10s
match_re:
severity: critical
continue: true
receivers:
- name: "slack"
slack_configs:
- api_url: 'https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXX/xxxxxxxxxxxxxxxxxxxxxxxxxxx'
send_resolved: true
channel: 'monitoring'
text: "{{ range .Alerts }}<!channel> {{ .Annotations.summary }}\\n{{ .Annotations.description }}\\n{{ end }}"
- name: "pager"
webhook_configs:
- url: http://a.b.c.d:8080/send/sms
send_resolved: true`}</code></pre>
<h2 id="recorded-rules">Reduce Prometheus server load</h2>
<p>
For expensive or frequent PromQL queries, Prometheus allows you to precompute rules.
</p>
<pre class="rule-code"><code>{`groups:
# first define the recorded rule
- name: ExampleRecordedGroup
rules:
- record: job:rabbitmq_queue_messages_delivered_total:rate:5m
expr: rate(rabbitmq_queue_messages_delivered_total[5m])
# then use it in alerts
- name: ExampleAlertingGroup
rules:
- alert: ExampleRabbitmqLowMessageDelivery
expr: sum(job:rabbitmq_queue_messages_delivered_total:rate:5m) < 10
for: 2m
labels:
severity: critical
annotations:
summary: "Low delivery rate in Rabbitmq queues"`}</code></pre>
<h2 id="troubleshooting">Troubleshooting</h2>
<p>If the notification takes too much time to be triggered, check the following delays:</p>
<ul>
<li><code>scrape_interval = 20s</code> (prometheus.yml)</li>
<li><code>evaluation_interval = 20s</code> (prometheus.yml)</li>
<li><code>increase(mysql_global_status_slow_queries[1m]) &gt; 0</code> (alerts/example-mysql.yml)</li>
<li><code>for: 5m</code> (alerts/example-mysql.yml)</li>
<li><code>group_wait = 10s</code> (alertmanager.yml)</li>
</ul>
<p>Also read:</p>
<ul>
<li><a href="https://pracucci.com/prometheus-understanding-the-delays-on-alerting.html" target="_blank" rel="noopener noreferrer">Understanding the delays on alerting</a></li>
<li><a href="https://hodovi.cc/blog/creating-awesome-alertmanager-templates-for-slack/" target="_blank" rel="noopener noreferrer">Creating awesome AlertManager templates for Slack</a></li>
<li><a href="https://grafana.com/blog/2024/10/03/how-to-use-prometheus-to-efficiently-detect-anomalies-at-scale/" target="_blank" rel="noopener noreferrer">How to use Prometheus to efficiently detect anomalies at scale</a></li>
</ul>
</GuideLayout>

View file

@ -0,0 +1,156 @@
---
import GuideLayout from '../layouts/GuideLayout.astro';
const base = import.meta.env.BASE_URL;
---
<GuideLayout
title="Blackbox Exporter"
description="Deploy blackbox exporters worldwide for endpoint probing over HTTP, HTTPS, DNS, TCP and ICMP. Prometheus configuration and Grafana geohash map setup."
breadcrumbs={[{ label: 'Guides' }, { label: 'Blackbox Exporter' }]}
>
<h2 id="worldwide-probes">Worldwide probes</h2>
<p>
<a href="https://github.com/prometheus/blackbox_exporter" target="_blank" rel="noopener noreferrer">Blackbox Exporter</a>
gives you the ability to probe endpoints over HTTP, HTTPS, DNS, TCP and ICMP.
</p>
<p>
You should deploy blackbox exporters in multiple Points of Presence around the globe to monitor latency.
Feel free to use the following endpoints for your own projects:
</p>
<ul>
<li><code>https://probe-<strong>montreal</strong>.cleverapps.io</code></li>
<li><code>https://probe-<strong>paris</strong>.cleverapps.io</code></li>
<li><code>https://probe-<strong>jeddah</strong>.cleverapps.io</code></li>
<li><code>https://probe-<strong>singapore</strong>.cleverapps.io</code></li>
<li><code>https://probe-<strong>sydney</strong>.cleverapps.io</code></li>
<li><code>https://probe-<strong>warsaw</strong>.cleverapps.io</code></li>
</ul>
<p class="text-sm text-slate-500 dark:text-slate-400">
☝️ Logs have been disabled. More probes from the community would be appreciated —
<a href="https://github.com/samber/awesome-prometheus-alerts/" target="_blank" rel="noopener noreferrer">contribute here</a>!
These blackbox exporters use the following
<a href="https://github.com/samber/blackbox_exporter/blob/master/samber.yml" target="_blank" rel="noopener noreferrer">configuration</a>.
</p>
<h2 id="prometheus-config">Prometheus Configuration</h2>
<p>
Blackbox exporters and endpoints must be declared in Prometheus. Here is a simple configuration,
inspired by <a href="https://medium.com/geekculture/single-prometheus-job-for-dozens-of-blackbox-exporters-2a7ba492d6c8" target="_blank" rel="noopener noreferrer">Hayk Davtyan's medium post</a>:
</p>
<pre class="rule-code"><code>{`# sd/blackbox.yml
- targets:
#
# Montreal
#
# http
- probe-montreal.cleverapps.io:_:http_2xx:_:Montreal:_:f229cy:_:https://api.screeb.app
- probe-montreal.cleverapps.io:_:http_2xx:_:Montreal:_:f229cy:_:https://t.screeb.app/tag.js
# icmp
- probe-montreal.cleverapps.io:_:icmp_ipv4:_:Montreal:_:f229cy:_:api.screeb.app
- probe-montreal.cleverapps.io:_:icmp_ipv4:_:Montreal:_:f229cy:_:t.screeb.app
#
# Paris
#
# http
- probe-paris.cleverapps.io:_:http_2xx:_:Paris:_:u09tgy:_:https://api.screeb.app
- probe-paris.cleverapps.io:_:http_2xx:_:Paris:_:u09tgy:_:https://t.screeb.app/tag.js
# icmp
- probe-paris.cleverapps.io:_:icmp_ipv4:_:Paris:_:u09tgy:_:api.screeb.app
- probe-paris.cleverapps.io:_:icmp_ipv4:_:Paris:_:u09tgy:_:t.screeb.app
#
# Sydney
#
# http
- probe-sydney.cleverapps.io:_:http_2xx:_:Sydney:_:r3gpkn:_:https://api.screeb.app
- probe-sydney.cleverapps.io:_:http_2xx:_:Sydney:_:r3gpkn:_:https://t.screeb.app/tag.js
# icmp
- probe-sydney.cleverapps.io:_:icmp_ipv4:_:Sydney:_:r3gpkn:_:api.screeb.app
- probe-sydney.cleverapps.io:_:icmp_ipv4:_:Sydney:_:r3gpkn:_:t.screeb.app
# ...`}</code></pre>
<pre class="rule-code"><code>{`# prometheus.yml
global:
# ...
scrape_configs:
- job_name: 'blackbox'
metrics_path: /probe
scrape_interval: 30s
scheme: https
file_sd_configs:
- files:
- /etc/prometheus/sd/blackbox.yml
relabel_configs:
# adds "module" label in the final labelset
- source_labels: [__address__]
regex: '.*:_:(.*):_:.*:_:.*:_:.*'
target_label: module
# adds "geohash" label in the final labelset
- source_labels: [__address__]
regex: '.*:_:.*:_:.*:_:(.*):_:.*'
target_label: geohash
# rewrites "instance" label with corresponding URL
- source_labels: [__address__]
regex: '.*:_:.*:_:.*:_:.*:_:(.*)'
target_label: instance
# rewrites "pop" label with corresponding location name
- source_labels: [__address__]
regex: '.*:_:.*:_:(.*):_:.*:_:.*'
target_label: pop
# passes "module" parameter to Blackbox exporter
- source_labels: [module]
target_label: __param_module
# passes "target" parameter to Blackbox exporter
- source_labels: [instance]
target_label: __param_target
# the Blackbox exporter's real hostname:port
- source_labels: [__address__]
regex: '(.*):_:.*:_:.*:_:.*:_:.*'
target_label: __address__
# ...`}</code></pre>
<h2 id="geohash">Geohash</h2>
<img
src={`${base}/images/grafana-map-panel.png`}
alt="Grafana map panel showing worldwide probe locations"
class="rounded-lg border border-slate-200 dark:border-slate-700 my-4"
loading="lazy"
/>
<p>To display nice maps in Grafana, you need to instruct blackbox exporters about the location. Grafana map panel speaks the "geohash" format:</p>
<ol>
<li>Go to Google Maps</li>
<li>Extract the lat/long from the URL</li>
<li>Convert lat/long to geohash at <a href="http://geohash.co" target="_blank" rel="noopener noreferrer">geohash.co</a></li>
</ol>
<h2 id="grafana">Grafana</h2>
<p>
Some great dashboards have been created by the community:
<a href="https://grafana.com/grafana/dashboards/?search=blackbox" target="_blank" rel="noopener noreferrer">grafana.com/grafana/dashboards/?search=blackbox</a>
</p>
<p>
Since Grafana v5.0.0, a map panel is available:
<a href="https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/geomap/" target="_blank" rel="noopener noreferrer">Geomap panel documentation</a>
</p>
</GuideLayout>

152
site/src/pages/index.astro Normal file
View file

@ -0,0 +1,152 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import StatsBar from '../components/StatsBar.astro';
import ServiceCard from '../components/ServiceCard.astro';
import SearchWidget from '../components/SearchWidget.astro';
import { data, getGroupSlug, getRuleCount, getTotalRuleCount, getTotalServiceCount } from '../data/rules';
const base = import.meta.env.BASE_URL;
const totalRules = getTotalRuleCount();
const totalServices = getTotalServiceCount();
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Awesome Prometheus Alerts',
url: 'https://samber.github.io/awesome-prometheus-alerts/',
description: `Collection of ${totalRules} copy-pasteable Prometheus alerting rules for ${totalServices} services.`,
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: 'https://samber.github.io/awesome-prometheus-alerts/rules/?q={search_term_string}',
},
'query-input': 'required name=search_term_string',
},
};
---
<BaseLayout
title="Awesome Prometheus Alerts | Copy-pasteable Prometheus alerting rules"
description={`Collection of ${totalRules} copy-pasteable Prometheus alerting rules for ${totalServices} services — covering databases, Kubernetes, cloud providers, message brokers, and more.`}
jsonLd={jsonLd}
>
<!-- Hero -->
<section class="bg-gradient-to-b from-slate-50 dark:from-slate-900/50 to-white dark:to-slate-950 border-b border-slate-200 dark:border-slate-800">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<div class="flex justify-center mb-6">
<img
src={`${base}/images/prometheus-logo.png`}
alt="Prometheus"
class="h-16 w-auto"
width="64"
height="64"
/>
</div>
<h1 class="text-3xl sm:text-4xl font-bold text-slate-900 dark:text-white mb-4">
Awesome Prometheus Alerts
</h1>
<p class="text-lg text-slate-500 dark:text-slate-400 mb-8 max-w-2xl mx-auto">
{totalRules} copy-pasteable Prometheus alerting rules for {totalServices} services.
Find, copy, and deploy alerts in seconds.
</p>
<!-- Search -->
<div class="max-w-xl mx-auto mb-8">
<SearchWidget />
</div>
<!-- CTA buttons -->
<div class="flex flex-wrap justify-center gap-3">
<a
href={`${base}/rules/`}
class="inline-flex items-center gap-2 px-5 py-2.5 bg-brand hover:bg-brand/90 text-white font-medium rounded-lg transition-colors"
>
Browse all rules
<svg class="w-4 h-4" 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>
<a
href="https://github.com/samber/awesome-prometheus-alerts"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-5 py-2.5 border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-200 font-medium rounded-lg hover:border-slate-300 dark:hover:border-slate-600 transition-colors"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/></svg>
GitHub
</a>
</div>
</div>
</section>
<!-- Stats -->
<section class="border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<StatsBar />
</div>
</section>
<!-- Categories grid -->
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h2 class="text-xl font-bold text-slate-900 dark:text-white mb-8">Browse by category</h2>
<div class="space-y-10">
{data.groups.map((group) => {
const groupSlug = getGroupSlug(group);
const groupRuleCount = group.services.reduce((sum, svc) => sum + getRuleCount(svc), 0);
return (
<div>
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-slate-700 dark:text-slate-300">
<a href={`${base}/rules/${groupSlug}/`} class="hover:text-brand dark:hover:text-brand-dark transition-colors">
{group.name}
</a>
</h3>
<span class="text-xs text-slate-400 dark:text-slate-500">
{group.services.length} services · {groupRuleCount} rules
</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{group.services.map((service) => (
<ServiceCard service={service} group={group} base={base} />
))}
</div>
</div>
);
})}
</div>
</section>
<!-- Guides section -->
<section class="bg-slate-50 dark:bg-slate-900/50 border-t border-slate-200 dark:border-slate-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h2 class="text-xl font-bold text-slate-900 dark:text-white mb-6">Guides</h2>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<a href={`${base}/alertmanager/`} class="group block p-5 rounded-xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700/60 hover:border-brand/40 dark:hover:border-brand-dark/40 hover:shadow-sm transition-all">
<div class="text-brand dark:text-brand-dark mb-2">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
</div>
<h3 class="font-semibold text-slate-800 dark:text-slate-100 group-hover:text-brand dark:group-hover:text-brand-dark transition-colors mb-1">AlertManager Config</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">Prometheus and AlertManager configuration examples and troubleshooting.</p>
</a>
<a href={`${base}/blackbox-exporter/`} class="group block p-5 rounded-xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700/60 hover:border-brand/40 dark:hover:border-brand-dark/40 hover:shadow-sm transition-all">
<div class="text-brand dark:text-brand-dark mb-2">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
<h3 class="font-semibold text-slate-800 dark:text-slate-100 group-hover:text-brand dark:group-hover:text-brand-dark transition-colors mb-1">Blackbox Exporter</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">Worldwide probes, Prometheus config, geohash/Grafana map setup.</p>
</a>
<a href={`${base}/sleep-peacefully/`} class="group block p-5 rounded-xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700/60 hover:border-brand/40 dark:hover:border-brand-dark/40 hover:shadow-sm transition-all">
<div class="text-brand dark:text-brand-dark mb-2">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
</div>
<h3 class="font-semibold text-slate-800 dark:text-slate-100 group-hover:text-brand dark:group-hover:text-brand-dark transition-colors mb-1">Sleep Peacefully</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">Time-based alert suppression and timezone-aware PromQL patterns.</p>
</a>
</div>
</div>
</section>
</BaseLayout>

View file

@ -0,0 +1,137 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import Breadcrumbs from '../../../components/Breadcrumbs.astro';
import Sidebar from '../../../components/Sidebar.astro';
import ExporterSection from '../../../components/ExporterSection.astro';
import { data, getGroupSlug, getServiceSlug, getRuleCount, getAllServices } from '../../../data/rules';
export function getStaticPaths() {
return getAllServices().map(({ group, service, groupSlug, serviceSlug }) => ({
params: { group: groupSlug, service: serviceSlug },
props: { group, service },
}));
}
const { group, service } = Astro.props;
const base = import.meta.env.BASE_URL;
const groupSlug = getGroupSlug(group);
const serviceSlug = getServiceSlug(service);
const ruleCount = getRuleCount(service);
const groupIndex = data.groups.findIndex((g) => getGroupSlug(g) === groupSlug) + 1;
const serviceIndex = group.services.findIndex((s) => getServiceSlug(s) === serviceSlug) + 1;
// Build exporters summary for meta description
const exporterNames = service.exporters.map((e) => e.name).filter(Boolean).join(', ');
const metaDesc = `${ruleCount} Prometheus alerting rules for ${service.name}${exporterNames ? ` via ${exporterNames}` : ''}. Copy-paste ready YAML configurations for critical and warning alerts.`;
// FAQ JSON-LD for GEO (AI search engines)
const faqItems = service.exporters.flatMap((exp) =>
(exp.rules ?? []).map((rule) => ({
'@type': 'Question',
name: `What is the Prometheus alert rule for "${rule.name}"?`,
acceptedAnswer: {
'@type': 'Answer',
text: `${rule.description} PromQL expression: ${rule.query}. Severity: ${rule.severity}${rule.for ? `. Duration: ${rule.for}` : ''}.`,
},
}))
);
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'TechArticle',
headline: `${service.name} Prometheus Alert Rules`,
description: metaDesc,
about: `Prometheus monitoring for ${service.name}`,
url: `https://samber.github.io${base}/rules/${groupSlug}/${serviceSlug}/`,
isPartOf: {
'@type': 'WebSite',
name: 'Awesome Prometheus Alerts',
url: 'https://samber.github.io/awesome-prometheus-alerts/',
},
},
...(faqItems.length > 0 ? [{
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqItems,
}] : []),
];
---
<BaseLayout
title={`${service.name} Prometheus Alert Rules | Awesome Prometheus Alerts`}
description={metaDesc}
jsonLd={jsonLd}
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Breadcrumbs
items={[
{ label: 'Rules', href: `${base}/rules/` },
{ label: group.name, href: `${base}/rules/${groupSlug}/` },
{ label: service.name },
]}
base={base}
/>
<div class="flex gap-10 mt-6">
<!-- Left sidebar -->
<Sidebar
groups={data.groups}
currentGroupSlug={groupSlug}
currentServiceSlug={serviceSlug}
base={base}
/>
<!-- Main content -->
<div class="flex-1 min-w-0">
<!-- Page header -->
<div class="mb-6 pb-6 border-b border-slate-200 dark:border-slate-800">
<h1 class="text-2xl font-bold text-slate-900 dark:text-white mb-2">{service.name}</h1>
<p class="text-slate-500 dark:text-slate-400 text-sm leading-relaxed">
{ruleCount} Prometheus alerting rule{ruleCount !== 1 ? 's' : ''} for {service.name}.
{exporterNames && `Exported via ${exporterNames}.`}
These rules cover critical and warning conditions — copy and paste the YAML into your Prometheus configuration.
</p>
</div>
<!-- Exporters -->
{service.exporters.map((exporter, expIdx) => (
<ExporterSection
exporter={exporter}
service={service}
groupIndex={groupIndex}
serviceIndex={serviceIndex}
exporterIndex={expIdx + 1}
showExporterNumber={service.exporters.length > 1}
/>
))}
<!-- Prev/Next navigation -->
<nav class="mt-10 pt-6 border-t border-slate-200 dark:border-slate-800 flex justify-between gap-4" aria-label="Service navigation">
{(() => {
const allSvcs = getAllServices();
const idx = allSvcs.findIndex(s => s.groupSlug === groupSlug && s.serviceSlug === serviceSlug);
const prev = idx > 0 ? allSvcs[idx - 1] : null;
const next = idx < allSvcs.length - 1 ? allSvcs[idx + 1] : null;
return (
<>
{prev ? (
<a href={`${base}/rules/${prev.groupSlug}/${prev.serviceSlug}/`} class="group flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-brand dark:hover:text-brand-dark transition-colors">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
<span>{prev.service.name}</span>
</a>
) : <div />}
{next ? (
<a href={`${base}/rules/${next.groupSlug}/${next.serviceSlug}/`} class="group flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-brand dark:hover:text-brand-dark transition-colors">
<span>{next.service.name}</span>
<svg class="w-4 h-4" 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>
) : <div />}
</>
);
})()}
</nav>
</div>
</div>
</div>
</BaseLayout>

View file

@ -0,0 +1,46 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import Breadcrumbs from '../../../components/Breadcrumbs.astro';
import ServiceCard from '../../../components/ServiceCard.astro';
import { data, getGroupSlug, getServiceSlug, getRuleCount } from '../../../data/rules';
export function getStaticPaths() {
return data.groups.map((group) => ({
params: { group: getGroupSlug(group) },
props: { group },
}));
}
const { group } = Astro.props;
const base = import.meta.env.BASE_URL;
const groupSlug = getGroupSlug(group);
const totalRules = group.services.reduce((sum, svc) => sum + getRuleCount(svc), 0);
---
<BaseLayout
title={`${group.name} — Prometheus Alert Rules | Awesome Prometheus Alerts`}
description={`${totalRules} Prometheus alerting rules for ${group.services.length} services in the ${group.name} category.`}
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Breadcrumbs
items={[
{ label: 'Rules', href: `${base}/rules/` },
{ label: group.name },
]}
base={base}
/>
<div class="mt-6 mb-8">
<h1 class="text-2xl font-bold text-slate-900 dark:text-white mb-2">{group.name}</h1>
<p class="text-slate-500 dark:text-slate-400">
{group.services.length} service{group.services.length !== 1 ? 's' : ''} · {totalRules} rules
</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{group.services.map((service) => (
<ServiceCard service={service} group={group} base={base} />
))}
</div>
</div>
</BaseLayout>

View file

@ -0,0 +1,84 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import ServiceCard from '../../components/ServiceCard.astro';
import SearchWidget from '../../components/SearchWidget.astro';
import { data, getGroupSlug, getRuleCount, getTotalRuleCount, getTotalServiceCount, buildRedirectMap } from '../../data/rules';
const base = import.meta.env.BASE_URL;
const totalRules = getTotalRuleCount();
const totalServices = getTotalServiceCount();
const redirectMap = buildRedirectMap(base);
---
<BaseLayout
title="Prometheus Alerting Rules | Awesome Prometheus Alerts"
description={`Browse ${totalRules} Prometheus alerting rules across ${totalServices} services. Organized by category: databases, Kubernetes, cloud providers, message brokers, and more.`}
>
<!-- Old-anchor redirect handler -->
<script define:vars={{ redirectMap }}>
if (window.location.hash) {
const anchor = window.location.hash.slice(1);
const target = redirectMap[anchor];
if (target) {
window.location.replace(target);
}
}
</script>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-slate-900 dark:text-white mb-2">Alert Rules</h1>
<p class="text-slate-500 dark:text-slate-400">
{totalRules} alerting rules across {totalServices} services and {data.groups.length} categories.
</p>
</div>
<!-- Search -->
<div class="mb-8 max-w-xl">
<SearchWidget />
</div>
<!-- Caution banner -->
<div class="mb-8 p-4 rounded-xl border border-amber-200 dark:border-amber-800/50 bg-amber-50 dark:bg-amber-900/20">
<div class="flex gap-3">
<span class="text-amber-500 flex-shrink-0 mt-0.5">⚠️</span>
<div>
<h2 class="text-sm font-semibold text-amber-800 dark:text-amber-200 mb-1">Caution</h2>
<p class="text-sm text-amber-700 dark:text-amber-300">
Alert thresholds depend on the nature of your applications.
Some queries may have arbitrary tolerance thresholds.
Building an efficient monitoring platform takes time. 😉
</p>
</div>
</div>
</div>
<!-- Groups -->
<div class="space-y-10">
{data.groups.map((group) => {
const groupSlug = getGroupSlug(group);
const groupRuleCount = group.services.reduce((sum, svc) => sum + getRuleCount(svc), 0);
return (
<section>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-slate-800 dark:text-slate-100">
<a href={`${base}/rules/${groupSlug}/`} class="hover:text-brand dark:hover:text-brand-dark transition-colors">
{group.name}
</a>
</h2>
<span class="text-sm text-slate-400 dark:text-slate-500">
{group.services.length} services · {groupRuleCount} rules
</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{group.services.map((service) => (
<ServiceCard service={service} group={group} base={base} />
))}
</div>
</section>
);
})}
</div>
</div>
</BaseLayout>

View file

@ -0,0 +1,113 @@
---
import GuideLayout from '../layouts/GuideLayout.astro';
---
<GuideLayout
title="Sleep Peacefully"
description="Time-based alert suppression with PromQL. Prevent alert fatigue using day-of-week, hour-of-day filters, and timezone-aware recording rules."
breadcrumbs={[{ label: 'Guides' }, { label: 'Sleep Peacefully' }]}
>
<h2 id="alerting-time-window">Alerting time window</h2>
<p>
In some applications, load and activity can vary over the day, week, or year.
To prevent alarm fatigue and busy pager, alerts can be disabled during certain periods
(such as nights or weekends).
</p>
<p>Examples:</p>
<ul>
<li>Weekday: <code>node_load5 &gt; 10 and ON() (0 &lt; day_of_week() &lt; 6)</code></li>
<li>Day time: <code>node_load5 &gt; 10 and ON() (8 &lt; hour() &lt; 18)</code></li>
<li>Exclude December: <code>node_load5 &gt; 10 and ON() (month() != 12)</code></li>
</ul>
<h2 id="advanced-timezones">Advanced time windows and timezones</h2>
<pre class="rule-code"><code>{`# rules.yml
groups:
- name: timezones
rules:
- record: european_summer_time_offset
expr: |
(vector(1) and (month() > 3 and month() < 10))
or
(vector(1) and (month() == 3 and (day_of_month() - day_of_week()) >= 25) and absent((day_of_month() >= 25) and (day_of_week() == 0)))
or
(vector(1) and (month() == 10 and (day_of_month() - day_of_week()) < 25) and absent((day_of_month() >= 25) and (day_of_week() == 0)))
or
(vector(1) and ((month() == 10 and hour() < 1) or (month() == 3 and hour() > 0)) and ((day_of_month() >= 25) and (day_of_week() == 0)))
or
vector(0)
- record: europe_london_time
expr: time() + 3600 * european_summer_time_offset
- record: europe_paris_time
expr: time() + 3600 * (1 + european_summer_time_offset)
- record: europe_london_hour
expr: hour(europe_london_time)
- record: europe_paris_hour
expr: hour(europe_paris_time)
- record: europe_london_weekday
expr: 0 < day_of_week(europe_london_time) < 6
- record: europe_paris_weekday
expr: 0 < day_of_week(europe_paris_time) < 6
# opposite
- record: not_europe_london_weekday
expr: absent(europe_london_weekday)
- record: not_europe_paris_weekday
expr: absent(europe_paris_weekday)
- record: europe_london_business_hours
expr: 9 <= europe_london_hour < 18
- record: europe_paris_business_hours
expr: 9 <= europe_paris_hour < 18
# opposite
- record: not_europe_london_business_hours
expr: absent(europe_london_business_hours)
- record: not_europe_paris_business_hours
expr: absent(europe_paris_business_hours)
# new year's day / xmas / labor day / all saints' day / ...
- record: europe_french_public_holidays
expr: |
(vector(1) and month(europe_paris_time) == 1 and day_of_month(europe_paris_time) == 1)
or
(vector(1) and month(europe_paris_time) == 12 and day_of_month(europe_paris_time) == 25)
or
(vector(1) and month(europe_paris_time) == 5 and day_of_month(europe_paris_time) == 1)
or
(vector(1) and month(europe_paris_time) == 11 and day_of_month(europe_paris_time) == 1)
or
vector(0)
# opposite
- record: not_europe_french_public_holidays
expr: absent(europe_french_public_holidays)`}</code></pre>
<pre class="rule-code"><code>{`# alerts.yml
groups:
- name: CPU Load
rules:
- alert: HighLoadQuietDuringWeekendAndNight
expr: node_load5 > 10 and ON() (europe_london_weekday and europe_paris_weekday)
- alert: HighLoadQuietDuringBackup
expr: node_load5 > 10 and ON() absent(hour() == 2)
- alert: HighLoad
expr: |
node_load5 > 20 and ON() (europe_london_weekday and europe_paris_weekday)
or
node_load5 > 10`}</code></pre>
<h2 id="sources">Sources</h2>
<ul>
<li><a href="https://medium.com/@tom.fawcett/time-of-day-based-notifications-with-prometheus-and-alertmanager-1bf7a23b7695" target="_blank" rel="noopener noreferrer">Time of day based notifications with Prometheus and AlertManager</a></li>
<li><a href="https://promcon.io/2019-munich/slides/improved-alerting-with-prometheus-and-alertmanager.pdf" target="_blank" rel="noopener noreferrer">Improved alerting with Prometheus and AlertManager (PromCon 2019)</a></li>
</ul>
</GuideLayout>

View file

@ -0,0 +1,85 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-brand: #E6522C;
--color-brand-dark: #f06840;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-white text-slate-900 antialiased;
}
.dark body {
@apply bg-slate-950 text-slate-100;
}
/* Code block base styles */
pre {
@apply overflow-x-auto rounded-lg text-sm leading-relaxed;
}
code {
@apply font-mono text-sm;
}
/* Inline code */
:not(pre) > code {
@apply bg-slate-100 dark:bg-slate-800 text-slate-800 dark:text-slate-200 px-1.5 py-0.5 rounded text-xs;
}
}
@layer components {
/* Rule YAML code block — always dark */
.rule-code {
@apply bg-slate-900 text-slate-100 rounded-lg p-4 text-xs font-mono leading-relaxed overflow-x-auto;
}
/* Severity badge */
.badge-critical {
@apply bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400 border border-red-200 dark:border-red-800;
}
.badge-warning {
@apply bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400 border border-amber-200 dark:border-amber-800;
}
.badge-info {
@apply bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400 border border-blue-200 dark:border-blue-800;
}
/* Navigation links */
.nav-link {
@apply text-slate-600 dark:text-slate-300 hover:text-brand dark:hover:text-brand-dark transition-colors duration-150 text-sm font-medium;
}
.nav-link-active {
@apply text-brand dark:text-brand-dark;
}
}
/* Pagefind UI overrides */
#search {
--pagefind-ui-scale: 0.9;
--pagefind-ui-primary: #E6522C;
--pagefind-ui-text: #1e293b;
--pagefind-ui-background: #ffffff;
--pagefind-ui-border: #e2e8f0;
--pagefind-ui-tag: #f1f5f9;
--pagefind-ui-border-width: 1px;
--pagefind-ui-border-radius: 8px;
--pagefind-ui-font: inherit;
}
.dark #search {
--pagefind-ui-text: #e2e8f0;
--pagefind-ui-background: #0f172a;
--pagefind-ui-border: #334155;
--pagefind-ui-tag: #1e293b;
}

19
site/tailwind.config.mjs Normal file
View file

@ -0,0 +1,19 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {
colors: {
brand: {
DEFAULT: '#E6522C',
dark: '#f06840',
},
},
fontFamily: {
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', 'monospace'],
},
},
},
plugins: [],
};

10
site/tsconfig.json Normal file
View file

@ -0,0 +1,10 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@data/*": ["src/data/*"]
}
}
}