Add operational documentation

CloudBeaver database manager guide, Ecija intranet deployment,
Gitea-Coolify auto-deploy and integration docs, monitoring setup
with presentation, remote access guide, security architecture,
and Turbostarter deployment procedure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-18 15:17:18 +01:00
parent 1aa7ebcde3
commit 8b503a549c
9 changed files with 3817 additions and 0 deletions

View File

@@ -0,0 +1,665 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NUC Monitoring & Recovery System</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--background: #0C0A09;
--foreground: #FAFAF9;
--surface-card: #1C1917;
--surface-muted: #292524;
--accent-cyan: #06B6D4;
--accent-green: #22C55E;
--accent-orange: #F97316;
--accent-red: #EF4444;
--accent-purple: #A855F7;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', sans-serif;
background: var(--background);
color: var(--foreground);
min-height: 100vh;
overflow: hidden;
}
.slide {
min-height: 100vh;
padding: 3rem;
display: flex;
flex-direction: column;
justify-content: center;
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
h1 { font-size: 3rem; font-weight: 700; margin-bottom: 1rem; }
h2 { font-size: 2rem; font-weight: 600; margin-bottom: 1.5rem; color: var(--accent-cyan); }
h3 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.75rem; }
.subtitle { font-size: 1.25rem; color: #A8A29E; margin-bottom: 2rem; }
.card {
background: var(--surface-card);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1rem;
border: 1px solid var(--surface-muted);
}
.grid { display: grid; gap: 1rem; }
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.grid-4 { grid-template-columns: repeat(4, 1fr); }
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
gap: 0.375rem;
}
.badge-green { background: rgba(34, 197, 94, 0.2); color: var(--accent-green); }
.badge-cyan { background: rgba(6, 182, 212, 0.2); color: var(--accent-cyan); }
.badge-orange { background: rgba(249, 115, 22, 0.2); color: var(--accent-orange); }
.badge-red { background: rgba(239, 68, 68, 0.2); color: var(--accent-red); }
.badge-purple { background: rgba(168, 85, 247, 0.2); color: var(--accent-purple); }
.dot { width: 8px; height: 8px; border-radius: 50%; }
.dot-green { background: var(--accent-green); }
.dot-cyan { background: var(--accent-cyan); }
.dot-orange { background: var(--accent-orange); }
.dot-red { background: var(--accent-red); }
.icon { font-size: 2rem; margin-bottom: 0.5rem; }
.flow-diagram {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
padding: 2rem 0;
}
.flow-box {
background: var(--surface-card);
border: 2px solid var(--surface-muted);
border-radius: 8px;
padding: 1rem 1.5rem;
text-align: center;
min-width: 140px;
}
.flow-arrow {
color: var(--accent-cyan);
font-size: 1.5rem;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
th, td {
text-align: left;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-muted);
}
th { color: #A8A29E; font-weight: 500; font-size: 0.875rem; }
code {
background: var(--surface-muted);
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.875rem;
}
.nav {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.5rem;
background: var(--surface-card);
padding: 0.5rem;
border-radius: 9999px;
border: 1px solid var(--surface-muted);
z-index: 100;
}
.nav-btn {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--foreground);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
transition: background 0.2s;
}
.nav-btn:hover { background: var(--surface-muted); }
.nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.nav-dots {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0 0.5rem;
}
.nav-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--surface-muted);
cursor: pointer;
transition: all 0.2s;
}
.nav-dot.active { background: var(--accent-cyan); width: 24px; border-radius: 4px; }
.highlight { color: var(--accent-cyan); }
.text-muted { color: #A8A29E; }
.text-sm { font-size: 0.875rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-4 { margin-top: 1rem; }
.mb-4 { margin-bottom: 1rem; }
.text-center { text-align: center; }
.logo {
font-size: 1rem;
font-weight: 600;
color: var(--accent-cyan);
position: fixed;
top: 2rem;
left: 2rem;
}
.slide-number {
position: fixed;
top: 2rem;
right: 2rem;
color: #A8A29E;
font-size: 0.875rem;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect } = React;
const slides = [
// Slide 1: Title
() => (
<div className="slide" style={{ justifyContent: 'center', alignItems: 'center', textAlign: 'center' }}>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🖥</div>
<h1>NUC Monitoring & Recovery</h1>
<p className="subtitle">Production-grade infrastructure monitoring for home lab</p>
<div style={{ display: 'flex', gap: '1rem', marginTop: '2rem' }}>
<span className="badge badge-cyan"><span className="dot dot-cyan"></span>Prometheus</span>
<span className="badge badge-green"><span className="dot dot-green"></span>Grafana</span>
<span className="badge badge-orange"><span className="dot dot-orange"></span>Alertmanager</span>
<span className="badge badge-purple"><span className="dot dot-purple"></span>Auto-Recovery</span>
</div>
</div>
),
// Slide 2: Architecture Overview
() => (
<div className="slide">
<h2>Architecture Overview</h2>
<div className="flow-diagram">
<div className="flow-box">
<div>📡</div>
<div>OpenWrt</div>
<div className="text-sm text-muted">:9100</div>
</div>
<div className="flow-arrow"></div>
<div className="flow-box" style={{ borderColor: 'var(--accent-cyan)' }}>
<div>📊</div>
<div>Prometheus</div>
<div className="text-sm text-muted">:9091</div>
</div>
<div className="flow-arrow"></div>
<div className="flow-box">
<div>🔔</div>
<div>Alertmanager</div>
<div className="text-sm text-muted">:9093</div>
</div>
<div className="flow-arrow"></div>
<div className="flow-box" style={{ borderColor: 'var(--accent-orange)' }}>
<div>📱</div>
<div>ntfy</div>
<div className="text-sm text-muted">push alerts</div>
</div>
</div>
<div className="flow-diagram">
<div className="flow-box">
<div>🖥</div>
<div>NUC Node</div>
<div className="text-sm text-muted">:9100</div>
</div>
<div className="flow-arrow"></div>
<div className="flow-box" style={{ borderColor: 'var(--accent-green)' }}>
<div>📈</div>
<div>Grafana</div>
<div className="text-sm text-muted">:3333</div>
</div>
<div className="flow-arrow"></div>
<div className="flow-box">
<div>🌐</div>
<div>Tailscale</div>
<div className="text-sm text-muted">Funnel</div>
</div>
<div className="flow-arrow"></div>
<div className="flow-box" style={{ borderColor: 'var(--accent-purple)' }}>
<div>📲</div>
<div>Remote Access</div>
<div className="text-sm text-muted">anywhere</div>
</div>
</div>
</div>
),
// Slide 3: Metrics Collection
() => (
<div className="slide">
<h2>Metrics Collection</h2>
<div className="grid grid-3">
<div className="card">
<div className="icon">📡</div>
<h3>OpenWrt Router</h3>
<p className="text-muted text-sm mb-4">prometheus-node-exporter-lua</p>
<table>
<tbody>
<tr><td>CPU</td><td className="highlight"></td></tr>
<tr><td>Memory</td><td className="highlight"></td></tr>
<tr><td>Network</td><td className="highlight"></td></tr>
<tr><td>Thermal</td><td className="highlight"></td></tr>
<tr><td>Conntrack</td><td className="highlight"></td></tr>
</tbody>
</table>
</div>
<div className="card">
<div className="icon">🖥</div>
<h3>NUC Server</h3>
<p className="text-muted text-sm mb-4">prometheus-node-exporter</p>
<table>
<tbody>
<tr><td>CPU / Load</td><td className="highlight"></td></tr>
<tr><td>Memory</td><td className="highlight"></td></tr>
<tr><td>Disk I/O</td><td className="highlight"></td></tr>
<tr><td>Network</td><td className="highlight"></td></tr>
<tr><td>Filesystem</td><td className="highlight"></td></tr>
</tbody>
</table>
</div>
<div className="card">
<div className="icon">📊</div>
<h3>Prometheus</h3>
<p className="text-muted text-sm mb-4">Scrape & Store</p>
<table>
<tbody>
<tr><td>Interval</td><td><code>15s</code></td></tr>
<tr><td>Retention</td><td><code>30 days</code></td></tr>
<tr><td>Targets</td><td><code>3</code></td></tr>
<tr><td>Alert Rules</td><td><code>6</code></td></tr>
</tbody>
</table>
</div>
</div>
</div>
),
// Slide 4: Alert Rules
() => (
<div className="slide">
<h2>Alert Rules</h2>
<div className="grid grid-2">
<div className="card">
<h3>🖥 NUC Alerts</h3>
<table>
<thead>
<tr><th>Alert</th><th>Condition</th><th>Severity</th></tr>
</thead>
<tbody>
<tr>
<td>NUCDown</td>
<td><code>up == 0</code> for 1m</td>
<td><span className="badge badge-red">critical</span></td>
</tr>
<tr>
<td>HighCPULoad</td>
<td><code>CPU > 80%</code> for 5m</td>
<td><span className="badge badge-orange">warning</span></td>
</tr>
<tr>
<td>HighMemory</td>
<td><code>Memory > 85%</code> for 5m</td>
<td><span className="badge badge-orange">warning</span></td>
</tr>
<tr>
<td>DiskSpaceLow</td>
<td><code>Disk > 85%</code> for 5m</td>
<td><span className="badge badge-orange">warning</span></td>
</tr>
</tbody>
</table>
</div>
<div className="card">
<h3>📡 OpenWrt Alerts</h3>
<table>
<thead>
<tr><th>Alert</th><th>Condition</th><th>Severity</th></tr>
</thead>
<tbody>
<tr>
<td>OpenWrtDown</td>
<td><code>up == 0</code> for 1m</td>
<td><span className="badge badge-red">critical</span></td>
</tr>
<tr>
<td>HighLoad</td>
<td><code>load > 2</code> for 5m</td>
<td><span className="badge badge-orange">warning</span></td>
</tr>
</tbody>
</table>
<div className="mt-4">
<h3>📱 Notification Flow</h3>
<p className="text-muted text-sm mt-2">
Alertmanager Bridge ntfy.sh/nuc-watchdog Phone
</p>
</div>
</div>
</div>
</div>
),
// Slide 5: Auto-Recovery System
() => (
<div className="slide">
<h2>Auto-Recovery System</h2>
<p className="subtitle">Multi-layer recovery ensures maximum uptime</p>
<div className="grid grid-4">
<div className="card text-center">
<div style={{ fontSize: '3rem' }}>1</div>
<h3 className="highlight">Wake-on-LAN</h3>
<p className="text-sm text-muted mt-2">OpenWrt sends magic packet every 2 min while NUC is down</p>
<div className="mt-4">
<code>etherwake 94:c6:91:1f:c9:c5</code>
</div>
</div>
<div className="card text-center">
<div style={{ fontSize: '3rem' }}>2</div>
<h3 className="highlight">SSH Reboot</h3>
<p className="text-sm text-muted mt-2">Passwordless sudo configured for remote reboot</p>
<div className="mt-4">
<code>sudo reboot</code>
</div>
</div>
<div className="card text-center">
<div style={{ fontSize: '3rem' }}>3</div>
<h3 className="highlight">Hardware Watchdog</h3>
<p className="text-sm text-muted mt-2">Kernel watchdog auto-reboots on system freeze</p>
<div className="mt-4">
<code>systemd watchdog</code>
</div>
</div>
<div className="card text-center">
<div style={{ fontSize: '3rem' }}>4</div>
<h3 className="highlight">Panic Reboot</h3>
<p className="text-sm text-muted mt-2">Kernel auto-reboots 10s after panic</p>
<div className="mt-4">
<code>kernel.panic=10</code>
</div>
</div>
</div>
</div>
),
// Slide 6: OpenWrt Monitor
() => (
<div className="slide">
<h2>OpenWrt Health Monitor</h2>
<div className="grid grid-2">
<div className="card">
<h3>🔍 Check Logic</h3>
<div style={{ background: 'var(--surface-muted)', padding: '1rem', borderRadius: '8px', marginTop: '1rem' }}>
<pre style={{ fontFamily: 'monospace', fontSize: '0.8rem', lineHeight: '1.6' }}>
{`Every 30 seconds:
├─ HTTP check (port 3000)
├─ Ping check
├─ Both OK → Reset failures
├─ Ping OK, HTTP fail → Service degraded
└─ Both fail → NUC down
├─ Alert via ntfy
└─ Send WoL (every 2 min)`}
</pre>
</div>
</div>
<div className="card">
<h3> Configuration</h3>
<table>
<tbody>
<tr><td>Location</td><td><code>/opt/monitor-nuc.sh</code></td></tr>
<tr><td>Check Interval</td><td><code>30 seconds</code></td></tr>
<tr><td>Fail Threshold</td><td><code>3 checks</code></td></tr>
<tr><td>WoL Retry</td><td><code>2 minutes</code></td></tr>
<tr><td>NUC MAC</td><td><code>94:c6:91:1f:c9:c5</code></td></tr>
<tr><td>Alert Channel</td><td><code>ntfy.sh/nuc-watchdog</code></td></tr>
</tbody>
</table>
</div>
</div>
</div>
),
// Slide 7: Remote Access
() => (
<div className="slide">
<h2>Remote Access</h2>
<div className="grid grid-2">
<div className="card">
<div className="icon">🔷</div>
<h3>Tailscale (NUC)</h3>
<p className="text-muted text-sm mb-4">Zero-config mesh VPN with Funnel</p>
<table>
<tbody>
<tr><td>Grafana URL</td><td><code>alezmad-nuc.tail58f5ad.ts.net</code></td></tr>
<tr><td>Access</td><td>Anywhere, no VPN app needed</td></tr>
<tr><td>Protocol</td><td>HTTPS (auto-cert)</td></tr>
</tbody>
</table>
<div className="mt-4">
<span className="badge badge-cyan">Best for: Services</span>
</div>
</div>
<div className="card">
<div className="icon">🔐</div>
<h3>WireGuard (OpenWrt)</h3>
<p className="text-muted text-sm mb-4">Full LAN access, works if NUC is down</p>
<table>
<tbody>
<tr><td>Endpoint</td><td><code>5.224.196.245:51820</code></td></tr>
<tr><td>VPN Subnet</td><td><code>10.10.10.0/24</code></td></tr>
<tr><td>Config</td><td><code>~/wireguard/home-vpn.conf</code></td></tr>
</tbody>
</table>
<div className="mt-4">
<span className="badge badge-green">Best for: Full LAN / Recovery</span>
</div>
</div>
</div>
</div>
),
// Slide 8: Access URLs
() => (
<div className="slide">
<h2>Quick Reference</h2>
<div className="card">
<h3>🔗 Access URLs</h3>
<table>
<thead>
<tr><th>Service</th><th>Local</th><th>Remote</th><th>Credentials</th></tr>
</thead>
<tbody>
<tr>
<td><span className="badge badge-green">Grafana</span></td>
<td><code>192.168.1.3:3333</code></td>
<td><code>alezmad-nuc.tail58f5ad.ts.net</code></td>
<td>admin / nucmonitoring</td>
</tr>
<tr>
<td><span className="badge badge-cyan">Prometheus</span></td>
<td><code>192.168.1.3:9091</code></td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td><span className="badge badge-orange">Alertmanager</span></td>
<td><code>192.168.1.3:9093</code></td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td><span className="badge badge-purple">ntfy</span></td>
<td colspan="2"><code>ntfy.sh/nuc-watchdog</code></td>
<td>Subscribe in app</td>
</tr>
</tbody>
</table>
</div>
<div className="card mt-4">
<h3>🛠 Maintenance Commands</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem', marginTop: '1rem' }}>
<code>curl localhost:9091/api/v1/targets</code>
<span className="text-muted text-sm">Check Prometheus targets</span>
<code>curl localhost:9093/api/v2/alerts</code>
<span className="text-muted text-sm">View active alerts</span>
<code>docker restart prometheus-*</code>
<span className="text-muted text-sm">Restart monitoring</span>
<code>curl -d "test" ntfy.sh/nuc-watchdog</code>
<span className="text-muted text-sm">Test notification</span>
</div>
</div>
</div>
),
// Slide 9: Summary
() => (
<div className="slide" style={{ justifyContent: 'center', alignItems: 'center', textAlign: 'center' }}>
<h1>System Status</h1>
<div className="grid grid-4 mt-4" style={{ maxWidth: '900px' }}>
<div className="card text-center">
<span className="badge badge-green"><span className="dot dot-green"></span>Prometheus</span>
<div style={{ fontSize: '2rem', marginTop: '1rem' }}></div>
<p className="text-sm text-muted mt-2">3 targets scraped</p>
</div>
<div className="card text-center">
<span className="badge badge-green"><span className="dot dot-green"></span>Grafana</span>
<div style={{ fontSize: '2rem', marginTop: '1rem' }}></div>
<p className="text-sm text-muted mt-2">Remote access on</p>
</div>
<div className="card text-center">
<span className="badge badge-green"><span className="dot dot-green"></span>Alerts</span>
<div style={{ fontSize: '2rem', marginTop: '1rem' }}></div>
<p className="text-sm text-muted mt-2">6 rules active</p>
</div>
<div className="card text-center">
<span className="badge badge-green"><span className="dot dot-green"></span>Recovery</span>
<div style={{ fontSize: '2rem', marginTop: '1rem' }}></div>
<p className="text-sm text-muted mt-2">4 layers ready</p>
</div>
</div>
<p className="subtitle mt-4">Production-ready monitoring for home infrastructure</p>
</div>
),
];
function Presentation() {
const [currentSlide, setCurrentSlide] = useState(0);
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'ArrowRight' || e.key === ' ') {
setCurrentSlide(s => Math.min(s + 1, slides.length - 1));
} else if (e.key === 'ArrowLeft') {
setCurrentSlide(s => Math.max(s - 1, 0));
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
const SlideComponent = slides[currentSlide];
return (
<>
<div className="logo">NUC Portal</div>
<div className="slide-number">{currentSlide + 1} / {slides.length}</div>
<SlideComponent key={currentSlide} />
<nav className="nav">
<button
className="nav-btn"
onClick={() => setCurrentSlide(s => Math.max(s - 1, 0))}
disabled={currentSlide === 0}
>
</button>
<div className="nav-dots">
{slides.map((_, i) => (
<div
key={i}
className={`nav-dot ${i === currentSlide ? 'active' : ''}`}
onClick={() => setCurrentSlide(i)}
/>
))}
</div>
<button
className="nav-btn"
onClick={() => setCurrentSlide(s => Math.min(s + 1, slides.length - 1))}
disabled={currentSlide === slides.length - 1}
>
</button>
</nav>
</>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<Presentation />);
</script>
</body>
</html>