Add Vue 3 dashboard frontend with nginx reverse proxy, charts and LLM analysis UI

This commit is contained in:
Arch Agent
2026-05-07 10:19:36 +02:00
parent 695d715d5b
commit 7d9bad9ead
7 changed files with 478 additions and 25 deletions
+28 -17
View File
@@ -1,21 +1,22 @@
# Log Analyzer Backend
# Log Analyzer Backend + Frontend
CPU-LLM-gestütztes Backend zur Auswertung von Firewall- und Proxy-Logs. Parst Dateien, aggregiert Statistiken (Top-Hits, Quellen, Ziele, Ports, URLs) und nutzt ein lokales LLM (via Ollama) für die Analyse.
CPU-LLM-gestütztes Dashboard zur Auswertung von Firewall- und Proxy-Logs. Parst Dateien, zeigt übersichtliche Statistiken (Top-Hits, Quellen, Ziele, Ports, URLs, Zeitverlauf) und nutzt ein lokales LLM (via Ollama) für die Analyse.
## Features
- **Log-Parsing:** Unterstützt iptables, pfSense, Cisco ASA, Squid, nginx
- **Statistiken:** Top Quellen, Ziele, Ports, URLs, Actions, Timeline
- **LLM-Analyse:** Zusammenfassung und Anomalie-Erkennung via lokalem Ollama-Modell
- **REST-API:** FastAPI mit automatischer OpenAPI-Doku unter `/docs`
- **Docker-Compose:** Schneller Start mit Backend + nginx Reverse Proxy
- **Web-Dashboard:** Vue 3 + Chart.js SPA, erreichbar auf Port 80
- **Docker-Compose:** Ein Befehl zum Starten von Backend + Frontend
## Architektur
```
┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐
Client │──────▶│ nginx:80 │──────▶│ FastAPI Backend:8000 │
└──────────────┘ └──────────────┘ │ - SQLite (Volumen) │
│ - Log-Parser │
│ - Ollama-Client │
Browser │──────▶│ Frontend:80 │──────▶│ FastAPI Backend:8000 │
└──────────────┘ │ (nginx + │ │ - SQLite (Volumen) │
│ Vue SPA) │ - Log-Parser │
└──────────────┘ │ - Ollama-Client │
└───────────────────────┘
@@ -29,7 +30,7 @@ CPU-LLM-gestütztes Backend zur Auswertung von Firewall- und Proxy-Logs. Parst D
## Voraussetzungen
- Docker + Docker Compose
- Laufende Ollama-Instanz auf dem Host (Port 11434) mit Modell `llava:7b`
- Ports: 8080 (nginx), 11434 (Ollama auf Host)
- Ports: 80 (Dashboard), 11434 (Ollama auf Host)
## Installation & Start
@@ -42,30 +43,38 @@ cd log-analyzer-backend
docker-compose up -d --build
```
Die API ist dann unter `http://localhost:8080/api` erreichbar.
Das Dashboard ist dann unter `http://localhost` erreichbar. Die API-Doku unter `http://localhost/docs`.
## Nutzung
### Log-Datei hochladen
### Dashboard
1. Browser öffnen: `http://localhost`
2. Log-Datei über das Upload-Feld hochladen
3. Statistiken und Charts werden automatisch geladen (alle 15 Sek. refreshed)
4. LLM-Analyse über den Bereich unten starten
### API (cURL)
#### Log-Datei hochladen
```bash
curl -X POST http://localhost:8080/api/upload \
curl -X POST http://localhost/api/upload \
-H "Content-Type: multipart/form-data" \
-F "file=@/var/log/iptables.log"
```
### Statistiken abrufen
#### Statistiken abrufen
```bash
curl "http://localhost:8080/api/stats?limit=20"
curl "http://localhost/api/stats?limit=20"
```
### LLM-Analyse starten
#### LLM-Analyse starten
```bash
curl -X POST "http://localhost:8080/api/analyze?log_type=firewall&limit=100"
curl -X POST "http://localhost/api/analyze?log_type=firewall&limit=100"
```
### Gesundheitscheck
#### Gesundheitscheck
```bash
curl http://localhost:8080/health
curl http://localhost/health
```
## API-Endpunkte
@@ -79,6 +88,7 @@ curl http://localhost:8080/health
| GET | `/api/stats/ports` | Top Ports |
| POST | `/api/analyze` | LLM-Analyse der Logs |
| GET | `/health` | Healthcheck |
| GET | `/docs` | Swagger UI (FastAPI) |
## Umgebungsvariablen
@@ -103,6 +113,7 @@ curl http://localhost:8080/health
- Das Backend erwartet Ollama auf dem **Host** (nicht im Container). Für Linux ggf. `extra_hosts: ["host.docker.internal:host-gateway"]` nutzen.
- Große Logdateien werden in Batches von 500 Zeilen verarbeitet.
- Die SQLite-Datenbank wird im Docker-Volumen `logdata` persistiert.
- Das Frontend aktualisiert die Statistiken automatisch alle 15 Sekunden.
## Lizenz
MIT
+4 -6
View File
@@ -14,13 +14,11 @@ services:
- "host.docker.internal:host-gateway"
restart: unless-stopped
nginx:
image: nginx:alpine
container_name: log-analyzer-nginx
frontend:
build: ./frontend
container_name: log-analyzer-frontend
ports:
- "8080:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- "80:80"
depends_on:
- backend
restart: unless-stopped
+3
View File
@@ -0,0 +1,3 @@
FROM nginx:alpine
COPY nginx.conf /etc/nginx/nginx.conf
COPY . /usr/share/nginx/html/
+141
View File
@@ -0,0 +1,141 @@
:root {
--bg: #0f1117;
--panel: #181b23;
--accent: #38bdf8;
--accent2: #22d3ee;
--text: #e2e8f0;
--muted: #94a3b8;
--border: #27303f;
--success: #22c55e;
--danger: #ef4444;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
line-height: 1.4;
}
header {
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--border);
background: linear-gradient(90deg, rgba(56,189,248,0.15), transparent);
}
header h1 { margin: 0; font-size: 1.8rem; }
.subtitle { color: var(--muted); font-size: 0.95rem; }
main { padding: 1.5rem 2rem; max-width: 1400px; margin: 0 auto; }
.upload-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.upload-card h2 { margin-top: 0; font-size: 1.15rem; }
.upload-card input[type=file] { margin-right: 0.75rem; }
button {
background: linear-gradient(90deg, var(--accent), var(--accent2));
color: #0f1117;
border: none;
padding: 0.55rem 1rem;
border-radius: 0.5rem;
font-weight: 700;
cursor: pointer;
}
button:disabled { opacity: 0.5; cursor: not-allowed; }
.msg { margin-top: 0.75rem; padding: 0.5rem 0.75rem; border-radius: 0.5rem; }
.msg.ok { background: rgba(34,197,94,0.15); color: var(--success); border: 1px solid rgba(34,197,94,0.3); }
.msg.err { background: rgba(239,68,68,0.15); color: var(--danger); border: 1px solid rgba(239,68,68,0.3); }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1rem;
text-align: center;
}
.stat-label { color: var(--muted); font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; }
.stat-value { font-size: 2rem; font-weight: 800; margin-top: 0.25rem; color: var(--accent); }
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.chart-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1rem;
}
.chart-card.wide { grid-column: 1 / -1; }
.chart-card h3 { margin-top: 0; font-size: 1rem; color: var(--muted); }
canvas { max-height: 260px; }
.tables h3, .actions h3 { margin: 0 0 0.5rem; }
table {
width: 100%;
border-collapse: collapse;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.75rem;
overflow: hidden;
}
th, td { padding: 0.6rem 0.8rem; text-align: left; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 600; font-size: 0.85rem; text-transform: uppercase; }
tr:last-child td { border-bottom: none; }
.url { word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; color: var(--accent2); }
.action-tags { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.tag {
background: rgba(56,189,248,0.12);
color: var(--accent);
border: 1px solid rgba(56,189,248,0.25);
padding: 0.35rem 0.65rem;
border-radius: 999px;
font-size: 0.85rem;
}
.analyze {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1.25rem;
margin-top: 1.5rem;
}
.analyze-controls { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; margin-bottom: 1rem; }
.analyze-controls label { display: flex; align-items: center; gap: 0.5rem; color: var(--muted); }
.analyze-controls input, .analyze-controls select {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.4rem 0.6rem;
}
.analysis-result {
background: #0b0d12;
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1rem;
white-space: pre-wrap;
}
.analysis-result pre { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; color: #c7d2e2; }
footer { text-align: center; padding: 2rem; color: var(--muted); }
footer a { color: var(--accent); }
+122
View File
@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Log Analyzer Dashboard</title>
<link rel="stylesheet" href="css/style.css">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<div id="app">
<header>
<h1>🔥 Log Analyzer</h1>
<span class="subtitle">Firewall &amp; Proxy Log Dashboard</span>
</header>
<main>
<section class="upload-card">
<h2>📤 Log-Datei hochladen</h2>
<input type="file" @change="handleFile" accept=".log,.txt,.csv">
<button @click="uploadFile" :disabled="!selectedFile || uploading">
{{ uploading ? 'Hochladen...' : 'Hochladen & Parsen' }}
</button>
<div v-if="uploadMsg" :class="['msg', uploadOk ? 'ok' : 'err']">{{ uploadMsg }}</div>
</section>
<section class="stats-grid" v-if="stats.overview.total_entries > 0">
<div class="stat-card">
<div class="stat-label">Gesamt Logs</div>
<div class="stat-value">{{ stats.overview.total_entries }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Firewall</div>
<div class="stat-value">{{ stats.overview.firewall_entries }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Proxy</div>
<div class="stat-value">{{ stats.overview.proxy_entries }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Unique Sources</div>
<div class="stat-value">{{ stats.unique_counts.unique_sources }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Unique Destinations</div>
<div class="stat-value">{{ stats.unique_counts.unique_destinations }}</div>
</div>
</section>
<section class="charts-grid" v-if="stats.overview.total_entries > 0">
<div class="chart-card">
<h3>Top Quellen</h3>
<canvas id="chartSources"></canvas>
</div>
<div class="chart-card">
<h3>Top Ziele</h3>
<canvas id="chartDestinations"></canvas>
</div>
<div class="chart-card">
<h3>Top Ports</h3>
<canvas id="chartPorts"></canvas>
</div>
<div class="chart-card wide">
<h3>Zeitverlauf (pro Stunde)</h3>
<canvas id="chartTimeline"></canvas>
</div>
</section>
<section class="tables" v-if="stats.top_urls.length > 0">
<h3>🔝 Top URLs</h3>
<table>
<thead><tr><th>URL</th><th>Hits</th></tr></thead>
<tbody>
<tr v-for="u in stats.top_urls.slice(0,20)" :key="u.url">
<td class="url">{{ u.url }}</td>
<td>{{ u.count }}</td>
</tr>
</tbody>
</table>
</section>
<section class="actions" v-if="stats.actions.length > 0">
<h3>⚡ Actions</h3>
<div class="action-tags">
<span v-for="a in stats.actions" :key="a.action" class="tag">
{{ a.action }}: {{ a.count }}
</span>
</div>
</section>
<section class="analyze">
<h2>🤖 LLM-Analyse</h2>
<div class="analyze-controls">
<label>Log-Typ:
<select v-model="analyzeType">
<option value="all">Alle</option>
<option value="firewall">Firewall</option>
<option value="proxy">Proxy</option>
</select>
</label>
<label>Zeilen:
<input type="number" v-model.number="analyzeLimit" min="10" max="500" step="10">
</label>
<button @click="runAnalysis" :disabled="analyzing || stats.overview.total_entries === 0">
{{ analyzing ? 'Analysiere...' : 'Analyse starten' }}
</button>
</div>
<div v-if="analysisResult" class="analysis-result">
<pre>{{ analysisResult }}</pre>
</div>
</section>
</main>
<footer>
<small>Log Analyzer Backend — <a href="/docs">API Docs</a></small>
</footer>
</div>
<script src="js/app.js"></script>
</body>
</html>
+165
View File
@@ -0,0 +1,165 @@
const { createApp } = Vue;
const api = axios.create({ baseURL: '/api' });
const chartColors = [
'#38bdf8','#22d3ee','#818cf8','#c084fc','#f472b6','#fb7185','#34d399','#a3e635','#fbbf24','#f87171'
];
function destroyChart(chart) { if(chart) { chart.destroy(); } }
function makeBarConfig(label, labels, data) {
return {
type: 'bar',
data: {
labels,
datasets: [{
label,
data,
backgroundColor: chartColors,
borderRadius: 4,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, grid: { color: '#27303f' }, ticks: { color: '#94a3b8' } },
x: { grid: { display: false }, ticks: { color: '#94a3b8' } }
}
}
};
}
function makeLineConfig(labels, data) {
return {
type: 'line',
data: {
labels,
datasets: [{
label: 'Events',
data,
borderColor: '#38bdf8',
backgroundColor: 'rgba(56,189,248,0.15)',
fill: true,
tension: 0.3,
pointRadius: 3,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, grid: { color: '#27303f' }, ticks: { color: '#94a3b8' } },
x: { grid: { color: '#27303f' }, ticks: { color: '#94a3b8' } }
}
}
};
}
createApp({
data() {
return {
selectedFile: null,
uploading: false,
uploadMsg: '',
uploadOk: false,
stats: {
overview: { total_entries: 0, firewall_entries: 0, proxy_entries: 0 },
top_sources: [],
top_destinations: [],
top_ports: [],
top_urls: [],
actions: [],
timeline: [],
unique_counts: { unique_sources: 0, unique_destinations: 0 }
},
analyzeType: 'all',
analyzeLimit: 100,
analyzing: false,
analysisResult: '',
charts: {}
};
},
mounted() {
this.fetchStats();
setInterval(() => this.fetchStats(), 15000);
},
methods: {
handleFile(e) {
this.selectedFile = e.target.files[0];
},
async uploadFile() {
if (!this.selectedFile) return;
this.uploading = true;
this.uploadMsg = '';
const form = new FormData();
form.append('file', this.selectedFile);
try {
const res = await api.post('/upload', form, { headers: { 'Content-Type': 'multipart/form-data' } });
this.uploadOk = true;
this.uploadMsg = res.data.message;
this.selectedFile = null;
this.$el.querySelector('input[type=file]').value = '';
this.fetchStats();
} catch (e) {
this.uploadOk = false;
this.uploadMsg = e.response?.data?.detail || 'Upload fehlgeschlagen.';
} finally {
this.uploading = false;
}
},
async fetchStats() {
try {
const res = await api.get('/stats', { params: { limit: 20 } });
this.stats = res.data;
this.$nextTick(() => this.renderCharts());
} catch (e) {
console.error('Stats fetch error', e);
}
},
renderCharts() {
const s = this.stats;
destroyChart(this.charts.sources);
if (s.top_sources.length) {
const cfg = makeBarConfig('Hits', s.top_sources.map(x=>x.source_ip||'N/A'), s.top_sources.map(x=>x.count));
this.charts.sources = new Chart(document.getElementById('chartSources'), cfg);
}
destroyChart(this.charts.dests);
if (s.top_destinations.length) {
const cfg = makeBarConfig('Hits', s.top_destinations.map(x=>x.destination_ip||'N/A'), s.top_destinations.map(x=>x.count));
this.charts.dests = new Chart(document.getElementById('chartDestinations'), cfg);
}
destroyChart(this.charts.ports);
if (s.top_ports.length) {
const cfg = makeBarConfig('Hits', s.top_ports.map(x=>x.destination_port||'N/A'), s.top_ports.map(x=>x.count));
this.charts.ports = new Chart(document.getElementById('chartPorts'), cfg);
}
destroyChart(this.charts.timeline);
if (s.timeline.length) {
const cfg = makeLineConfig(s.timeline.map(x=>x.time_bucket), s.timeline.map(x=>x.count));
this.charts.timeline = new Chart(document.getElementById('chartTimeline'), cfg);
}
},
async runAnalysis() {
this.analyzing = true;
this.analysisResult = '';
try {
const res = await api.post('/analyze', null, {
params: { log_type: this.analyzeType, limit: this.analyzeLimit }
});
this.analysisResult = res.data.analysis;
} catch (e) {
this.analysisResult = e.response?.data?.detail || 'Analyse fehlgeschlagen.';
} finally {
this.analyzing = false;
}
}
}
}).mount('#app');
+15 -2
View File
@@ -3,6 +3,10 @@ events {
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
upstream backend {
server backend:8000;
}
@@ -10,13 +14,22 @@ http {
server {
listen 80;
client_max_body_size 100M;
root /usr/share/nginx/html;
index index.html;
location / {
proxy_pass http://backend;
location /api/ {
proxy_pass http://backend/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location / {
try_files $uri $uri/ /index.html;
}
}
}