base dashboard and login
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
{{define "base"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} — CrowdSec Dashy</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;600&display=swap">
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div style="display:flex;height:100vh;overflow:hidden">
|
||||
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<div class="logo-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#00d4ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="logo-name">CrowdSec</span>
|
||||
<span class="logo-sub">Dashy</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
{{range .Nav}}
|
||||
{{if .Divider}}<div class="nav-divider"></div>{{end}}
|
||||
<a href="{{.Path}}" class="nav-item{{if eq $.CurrentPath .Path}} nav-item--active{{end}}">
|
||||
<span class="nav-icon">{{safeHTML .Icon}}</span>
|
||||
{{.Label}}
|
||||
</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
{{if .CLIAvailable}}
|
||||
<div class="cli-badge cli-badge--ok">cscli: available</div>
|
||||
{{else}}
|
||||
<div class="cli-badge cli-badge--warn">cscli: unavailable</div>
|
||||
{{end}}
|
||||
<form method="POST" action="/logout" style="margin-top:10px">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn-ghost-sm" style="width:100%;text-align:center;padding:6px 0">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;flex:1;overflow:hidden">
|
||||
<header class="topbar">
|
||||
<button class="topbar-menu-btn" id="menu-btn" aria-label="Toggle sidebar">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="topbar-breadcrumb">
|
||||
<span class="topbar-page">{{.Title}}</span>
|
||||
</div>
|
||||
<div id="health-badge" class="status-pill status-pill--loading">
|
||||
<span class="status-dot"></span>checking
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{if .Flash.Message}}
|
||||
<div class="flash flash--{{.Flash.Type}}" id="flash-msg">
|
||||
<span class="flash-text">{{.Flash.Message}}</span>
|
||||
<button class="flash-close" onclick="document.getElementById('flash-msg').remove()">dismiss</button>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<main style="flex:1;overflow-y:auto;padding:20px">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
document.body.classList.add('ready');
|
||||
var btn = document.getElementById('menu-btn');
|
||||
var sb = document.getElementById('sidebar');
|
||||
var ov = document.getElementById('sidebar-overlay');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function() { sb.classList.toggle('open'); ov.classList.toggle('open'); });
|
||||
ov.addEventListener('click', function() { sb.classList.remove('open'); ov.classList.remove('open'); });
|
||||
}
|
||||
function checkHealth() {
|
||||
var badge = document.getElementById('health-badge');
|
||||
if (!badge) return;
|
||||
fetch('/api/v1/health').then(function(r) { return r.json(); }).then(function(d) {
|
||||
if (d.healthy) {
|
||||
badge.className = 'status-pill status-pill--healthy';
|
||||
badge.innerHTML = '<span class="status-dot"></span>healthy';
|
||||
} else {
|
||||
badge.className = 'status-pill status-pill--unhealthy';
|
||||
badge.innerHTML = '<span class="status-dot"></span>unhealthy';
|
||||
}
|
||||
}).catch(function() {
|
||||
badge.className = 'status-pill status-pill--unhealthy';
|
||||
badge.innerHTML = '<span class="status-dot"></span>offline';
|
||||
});
|
||||
}
|
||||
checkHealth();
|
||||
setInterval(checkHealth, 30000);
|
||||
})();
|
||||
</script>
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,16 @@
|
||||
{{define "base"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{if .Title}}{{.Title}} — {{end}}CrowdSec Dashy</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body style="background:#080b10;min-height:100vh">
|
||||
{{template "content" .}}
|
||||
{{block "scripts" .}}{{end}}
|
||||
<script>document.body.classList.add('ready');</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,104 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="max-width:1400px">
|
||||
|
||||
<div style="margin-bottom:16px">
|
||||
<div class="page-title">Alerts</div>
|
||||
<div class="page-sub">Security events detected by CrowdSec agents</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-bottom:16px">
|
||||
<div class="panel-body" style="padding:12px 18px">
|
||||
<form method="GET" action="/alerts" class="filter-bar">
|
||||
<input class="filter-input" type="text" name="scenario" placeholder="Scenario filter..." value="{{.Filter.Scenario}}">
|
||||
<input class="filter-input" type="text" name="ip" placeholder="Source IP..." value="{{.Filter.IP}}">
|
||||
<select class="filter-select" name="since">
|
||||
<option value="">All time</option>
|
||||
<option value="1h" {{if eq .Filter.Since "1h"}}selected{{end}}>Last 1h</option>
|
||||
<option value="24h" {{if eq .Filter.Since "24h"}}selected{{end}}>Last 24h</option>
|
||||
<option value="7d" {{if eq .Filter.Since "7d"}}selected{{end}}>Last 7d</option>
|
||||
</select>
|
||||
<button type="submit" class="btn-secondary">Filter</button>
|
||||
{{if or .Filter.Scenario .Filter.IP .Filter.Since}}<a href="/alerts" class="btn-ghost">Clear</a>{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<form method="POST" action="/alerts/delete" id="bulk-form">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Alerts ({{len .Alerts}} shown)</span>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button type="button" class="btn-ghost-sm" onclick="toggleAll()">Select all</button>
|
||||
<button type="submit" class="btn-danger-sm" onclick="return confirmBulkDelete()">Delete selected</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Alerts}}
|
||||
<div style="overflow-x:auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:36px"><input type="checkbox" id="chk-all" onchange="selectAll(this)"></th>
|
||||
<th>ID</th>
|
||||
<th>Scenario</th>
|
||||
<th>Source</th>
|
||||
<th>Country</th>
|
||||
<th>Events</th>
|
||||
<th>Decisions</th>
|
||||
<th>Date</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Alerts}}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="id" value="{{.ID}}" class="row-chk"></td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.ID}}</td>
|
||||
<td style="font-size:12px" title="{{.Scenario}}">{{truncate .Scenario 36}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.Source.Value}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{.Source.CN}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace">{{.EventsCount}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace">{{len .Decisions}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{truncate .StartAt 16}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/alerts/delete" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="btn-danger-sm" onclick="return confirm('Delete alert #{{.ID}}?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No alerts found</div>
|
||||
<div class="empty-sub">No security events match the current filters</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
function selectAll(cb) {
|
||||
document.querySelectorAll('.row-chk').forEach(function(c) { c.checked = cb.checked; });
|
||||
}
|
||||
function toggleAll() {
|
||||
var cb = document.getElementById('chk-all');
|
||||
cb.checked = !cb.checked;
|
||||
selectAll(cb);
|
||||
}
|
||||
function confirmBulkDelete() {
|
||||
var n = document.querySelectorAll('.row-chk:checked').length;
|
||||
if (n === 0) { alert('Select at least one alert.'); return false; }
|
||||
return confirm('Delete ' + n + ' alert(s)?');
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,131 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="max-width:1200px">
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
<div>
|
||||
<div class="page-title">Bouncers</div>
|
||||
<div class="page-sub">Enforcement agents that consume CrowdSec decisions</div>
|
||||
</div>
|
||||
{{if .CLIAvailable}}
|
||||
<button class="btn-primary" onclick="document.getElementById('add-modal').classList.remove('hidden')">Add Bouncer</button>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if not .CLIAvailable}}
|
||||
<div class="cli-unavail-banner" style="margin-bottom:16px">
|
||||
<div>
|
||||
<strong style="display:block;margin-bottom:4px">cscli unavailable</strong>
|
||||
Bouncer management requires the cscli binary. Mount it at the CSCLI_PATH configured in your environment.
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .NewBouncer}}
|
||||
<div class="apikey-reveal" style="margin-bottom:16px">
|
||||
<div class="apikey-header">
|
||||
<strong>Bouncer "{{.NewBouncer.Name}}" registered.</strong>
|
||||
Copy the API key below — it will not be shown again.
|
||||
</div>
|
||||
<div class="apikey-box" id="apikey-box" onclick="copyApiKey()" title="Click to copy">
|
||||
<code style="font-family:'JetBrains Mono',monospace;font-size:13px;color:var(--safe);word-break:break-all">{{.NewBouncer.APIKey}}</code>
|
||||
<span class="apikey-copy-hint" id="copy-hint">click to copy</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Bouncers ({{len .Bouncers}})</span>
|
||||
</div>
|
||||
{{if .Bouncers}}
|
||||
<div style="overflow-x:auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>IP Address</th>
|
||||
<th>Last Pull</th>
|
||||
<th>Version</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
{{if $.CLIAvailable}}<th>Action</th>{{end}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Bouncers}}
|
||||
<tr>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:600">{{.Name}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.IPAddress}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{truncate .LastPull 16}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--muted)">{{.Version}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{.Type}}</td>
|
||||
<td>
|
||||
{{if .Revoked}}
|
||||
<span class="badge badge-red">revoked</span>
|
||||
{{else}}
|
||||
<span class="badge badge-green">valid</span>
|
||||
{{end}}
|
||||
</td>
|
||||
{{if $.CLIAvailable}}
|
||||
<td>
|
||||
<form method="POST" action="/bouncers/delete" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="name" value="{{.Name}}">
|
||||
<button type="submit" class="btn-danger-sm" onclick="return confirm('Delete bouncer "{{.Name}}"? This cannot be undone.')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No bouncers registered</div>
|
||||
<div class="empty-sub">Add a bouncer to enforce CrowdSec decisions</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .CLIAvailable}}
|
||||
<div class="modal-backdrop hidden" id="add-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Add Bouncer</span>
|
||||
<button class="modal-close" onclick="document.getElementById('add-modal').classList.add('hidden')">close</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form method="POST" action="/bouncers/add">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<div style="margin-bottom:16px">
|
||||
<label class="field-label">Bouncer Name</label>
|
||||
<input class="field-input" type="text" name="name" placeholder="my-bouncer" required
|
||||
pattern="[a-zA-Z0-9_\-]{1,64}" autofocus>
|
||||
<div class="field-hint">1-64 characters: letters, digits, dash, underscore</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-ghost" onclick="document.getElementById('add-modal').classList.add('hidden')">Cancel</button>
|
||||
<button type="submit" class="btn-primary">Register Bouncer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
function copyApiKey() {
|
||||
var code = document.querySelector('#apikey-box code');
|
||||
var hint = document.getElementById('copy-hint');
|
||||
if (!code || !navigator.clipboard) return;
|
||||
navigator.clipboard.writeText(code.textContent.trim()).then(function() {
|
||||
if (hint) { hint.textContent = 'copied!'; setTimeout(function() { hint.textContent = 'click to copy'; }, 2500); }
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,108 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="max-width:1400px">
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin-bottom:24px">
|
||||
<div class="stat-card stat-card--threat">
|
||||
<div class="stat-label">Active Bans</div>
|
||||
<div class="stat-value" id="stat-decisions">—</div>
|
||||
<div class="stat-sub">decisions in effect</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--warn">
|
||||
<div class="stat-label">Recent Alerts</div>
|
||||
<div class="stat-value" id="stat-alerts">—</div>
|
||||
<div class="stat-sub">up to 500 counted</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--accent">
|
||||
<div class="stat-label">Bouncers</div>
|
||||
<div class="stat-value" id="stat-bouncers">{{if .CLIAvailable}}—{{else}}n/a{{end}}</div>
|
||||
<div class="stat-sub">registered agents</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--safe">
|
||||
<div class="stat-label">Machines</div>
|
||||
<div class="stat-value" id="stat-machines">{{if .CLIAvailable}}—{{else}}n/a{{end}}</div>
|
||||
<div class="stat-sub">registered agents</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Recent Decisions</span>
|
||||
<a href="/decisions" class="panel-link">View all</a>
|
||||
</div>
|
||||
{{if .RecentDecisions}}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
<th>Origin</th>
|
||||
<th>Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .RecentDecisions}}
|
||||
<tr>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.Value}}</td>
|
||||
<td><span class="badge {{decisionBadgeClass .Type}}">{{.Type}}</span></td>
|
||||
<td><span class="badge {{originBadgeClass .Origin}}">{{.Origin}}</span></td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{truncate .Until 16}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No active decisions</div>
|
||||
<div class="empty-sub">CrowdSec has not issued any bans</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Recent Alerts</span>
|
||||
<a href="/alerts" class="panel-link">View all</a>
|
||||
</div>
|
||||
{{if .RecentAlerts}}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Scenario</th>
|
||||
<th>Source</th>
|
||||
<th>Events</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .RecentAlerts}}
|
||||
<tr>
|
||||
<td style="font-size:12px">{{truncate .Scenario 32}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.Source.Value}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace">{{.EventsCount}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{truncate .StartAt 16}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No recent alerts</div>
|
||||
<div class="empty-sub">No threat activity detected</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
<script>
|
||||
window._pollInterval = {{.PollInterval}};
|
||||
window._cliAvailable = {{.CLIAvailable}};
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,176 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="max-width:1400px">
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
<div>
|
||||
<div class="page-title">Decisions</div>
|
||||
<div class="page-sub">Active bans, captchas, and manual decisions</div>
|
||||
</div>
|
||||
<button class="btn-primary" onclick="toggleAddForm()">Add Decision</button>
|
||||
</div>
|
||||
|
||||
<div id="add-form" class="panel" style="display:none;margin-bottom:16px">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">New Decision</span>
|
||||
<button class="btn-ghost-sm" onclick="toggleAddForm()">Cancel</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form method="POST" action="/decisions/add">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:16px;margin-bottom:16px">
|
||||
<div>
|
||||
<label class="field-label">Value</label>
|
||||
<input class="field-input" type="text" name="value" placeholder="1.2.3.4 or 1.2.3.0/24 or US" required autofocus>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Scope</label>
|
||||
<select class="field-input" name="scope">
|
||||
<option value="Ip">IP</option>
|
||||
<option value="Range">Range / CIDR</option>
|
||||
<option value="Country">Country (2-letter)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Type</label>
|
||||
<select class="field-input" name="type">
|
||||
<option value="ban">ban</option>
|
||||
<option value="captcha">captcha</option>
|
||||
<option value="throttle">throttle</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Duration</label>
|
||||
<input class="field-input" type="text" name="duration" placeholder="24h" value="24h" required>
|
||||
<div class="field-hint">Units: s m h d w</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Scenario (optional)</label>
|
||||
<input class="field-input" type="text" name="scenario" placeholder="manual">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end">
|
||||
<button type="submit" class="btn-primary">Add Decision</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-bottom:16px">
|
||||
<div class="panel-body" style="padding:12px 18px">
|
||||
<form method="GET" action="/decisions" class="filter-bar">
|
||||
<select class="filter-select" name="type">
|
||||
<option value="">All types</option>
|
||||
<option value="ban" {{if eq .Filter.Type "ban"}}selected{{end}}>ban</option>
|
||||
<option value="captcha" {{if eq .Filter.Type "captcha"}}selected{{end}}>captcha</option>
|
||||
<option value="throttle" {{if eq .Filter.Type "throttle"}}selected{{end}}>throttle</option>
|
||||
</select>
|
||||
<select class="filter-select" name="scope">
|
||||
<option value="">All scopes</option>
|
||||
<option value="Ip" {{if eq .Filter.Scope "Ip"}}selected{{end}}>IP</option>
|
||||
<option value="Range" {{if eq .Filter.Scope "Range"}}selected{{end}}>Range</option>
|
||||
<option value="Country" {{if eq .Filter.Scope "Country"}}selected{{end}}>Country</option>
|
||||
</select>
|
||||
<input class="filter-input" type="text" name="value" placeholder="Search value..." value="{{.Filter.Value}}">
|
||||
<button type="submit" class="btn-secondary">Filter</button>
|
||||
{{if or .Filter.Type .Filter.Scope .Filter.Value}}<a href="/decisions" class="btn-ghost">Clear</a>{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<form method="POST" action="/decisions/delete" id="bulk-form">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
Page {{.Page}}{{if .HasNext}} (more available){{end}}
|
||||
</span>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button type="button" class="btn-ghost-sm" onclick="toggleAll()">Select all</button>
|
||||
<button type="submit" class="btn-danger-sm" onclick="return confirmBulkDelete()">Delete selected</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Decisions}}
|
||||
<div style="overflow-x:auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:36px"><input type="checkbox" id="chk-all" onchange="selectAll(this)"></th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
<th>Scope</th>
|
||||
<th>Origin</th>
|
||||
<th>Scenario</th>
|
||||
<th>Duration</th>
|
||||
<th>Expires</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Decisions}}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="id" value="{{.ID}}" class="row-chk"></td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.Value}}</td>
|
||||
<td><span class="badge {{decisionBadgeClass .Type}}">{{.Type}}</span></td>
|
||||
<td><span class="badge badge-gray">{{.Scope}}</span></td>
|
||||
<td><span class="badge {{originBadgeClass .Origin}}">{{.Origin}}</span></td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{truncate .Scenario 24}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.Duration}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{truncate .Until 16}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/decisions/delete" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="btn-danger-sm" onclick="return confirm('Delete this decision?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No decisions found</div>
|
||||
<div class="empty-sub">No active decisions match the current filters</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
|
||||
{{if or (gt .Page 1) .HasNext}}
|
||||
<div class="pagination">
|
||||
{{if gt .Page 1}}
|
||||
<a href="/decisions?page={{dec .Page}}&type={{.Filter.Type}}&scope={{.Filter.Scope}}&value={{.Filter.Value}}" class="btn-ghost-sm">Prev</a>
|
||||
{{end}}
|
||||
<span style="font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--muted)">Page {{.Page}}</span>
|
||||
{{if .HasNext}}
|
||||
<a href="/decisions?page={{inc .Page}}&type={{.Filter.Type}}&scope={{.Filter.Scope}}&value={{.Filter.Value}}" class="btn-ghost-sm">Next</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
function toggleAddForm() {
|
||||
var f = document.getElementById('add-form');
|
||||
f.style.display = f.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
function selectAll(cb) {
|
||||
document.querySelectorAll('.row-chk').forEach(function(c) { c.checked = cb.checked; });
|
||||
}
|
||||
function toggleAll() {
|
||||
var cb = document.getElementById('chk-all');
|
||||
cb.checked = !cb.checked;
|
||||
selectAll(cb);
|
||||
}
|
||||
function confirmBulkDelete() {
|
||||
var n = document.querySelectorAll('.row-chk:checked').length;
|
||||
if (n === 0) { alert('Select at least one decision.'); return false; }
|
||||
return confirm('Delete ' + n + ' decision(s)?');
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:60vh;text-align:center">
|
||||
<div style="font-family:'JetBrains Mono',monospace;font-size:64px;font-weight:600;color:var(--threat);line-height:1;margin-bottom:16px">{{.Code}}</div>
|
||||
<div class="page-title" style="margin-bottom:8px">{{.Message}}</div>
|
||||
<div class="page-sub" style="margin-bottom:24px">Something went wrong. Try going back or returning to the dashboard.</div>
|
||||
<a href="/" class="btn-primary">Dashboard</a>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,103 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="max-width:1400px">
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
<div>
|
||||
<div class="page-title">Hub</div>
|
||||
<div class="page-sub">Collections, parsers, scenarios, and postoverflows</div>
|
||||
</div>
|
||||
{{if .CLIAvailable}}
|
||||
<form method="POST" action="/hub/update">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn-secondary"
|
||||
onclick="return confirm('Run hub update + upgrade? This may take a minute.')">Update All</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if not .CLIAvailable}}
|
||||
<div class="cli-unavail-banner" style="margin-bottom:16px">
|
||||
<div>
|
||||
<strong style="display:block;margin-bottom:4px">cscli unavailable</strong>
|
||||
Hub management requires the cscli binary. Mount it at the CSCLI_PATH configured in your environment.
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="panel">
|
||||
<div class="tab-bar">
|
||||
<a href="/hub?tab=collections" class="tab-item{{if eq .Tab "collections"}} tab-item--active{{end}}">Collections</a>
|
||||
<a href="/hub?tab=parsers" class="tab-item{{if eq .Tab "parsers"}} tab-item--active{{end}}">Parsers</a>
|
||||
<a href="/hub?tab=scenarios" class="tab-item{{if eq .Tab "scenarios"}} tab-item--active{{end}}">Scenarios</a>
|
||||
<a href="/hub?tab=postoverflows" class="tab-item{{if eq .Tab "postoverflows"}} tab-item--active{{end}}">Postoverflows</a>
|
||||
</div>
|
||||
|
||||
{{if .CLIAvailable}}
|
||||
{{if .Items}}
|
||||
<div style="overflow-x:auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Version</th>
|
||||
<th>State</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{$tab := .Tab}}
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:600">{{.Name}}</div>
|
||||
{{if .Description}}<div style="font-size:11px;color:var(--muted);margin-top:2px">{{truncate .Description 64}}</div>{{end}}
|
||||
</td>
|
||||
<td><span class="badge {{hubStatusClass .Status}}">{{.Status}}</span></td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--muted)">{{.Version}}</td>
|
||||
<td>
|
||||
{{if .Tainted}}
|
||||
<span class="badge badge-amber">tainted</span>
|
||||
{{else if .UpToDate}}
|
||||
<span class="badge badge-green">up to date</span>
|
||||
{{else if .Installed}}
|
||||
<span class="badge badge-amber">update avail</span>
|
||||
{{else}}
|
||||
<span class="badge badge-gray">not installed</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .Installed}}
|
||||
<form method="POST" action="/hub/remove" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="kind" value="{{$tab}}">
|
||||
<input type="hidden" name="tab" value="{{$tab}}">
|
||||
<input type="hidden" name="name" value="{{.Name}}">
|
||||
<button type="submit" class="btn-danger-sm" onclick="return confirm('Remove {{.Name}}?')">Remove</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="POST" action="/hub/install" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="kind" value="{{$tab}}">
|
||||
<input type="hidden" name="tab" value="{{$tab}}">
|
||||
<input type="hidden" name="name" value="{{.Name}}">
|
||||
<button type="submit" class="btn-safe-sm">Install</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No {{.Tab}} found</div>
|
||||
<div class="empty-sub">Run "Update All" to refresh the hub index</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,42 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px">
|
||||
<div style="width:100%;max-width:380px">
|
||||
|
||||
<div style="text-align:center;margin-bottom:28px">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#00d4ff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" style="display:block;margin:0 auto 12px"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
<div style="font-size:22px;font-weight:600;color:#e2e8f0">CrowdSec Dashy</div>
|
||||
<div style="font-size:13px;color:#64748b;margin-top:4px">Sign in to continue</div>
|
||||
</div>
|
||||
|
||||
<div style="background:#0d1117;border:1px solid rgba(255,255,255,0.07);border-radius:10px;padding:28px">
|
||||
|
||||
{{if .Error}}
|
||||
<div style="background:rgba(255,59,59,0.08);color:#ff7b7b;border-left:3px solid #ff3b3b;padding:10px 14px;border-radius:4px;font-size:13px;margin-bottom:20px">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/login" autocomplete="off" novalidate>
|
||||
<div style="margin-bottom:16px">
|
||||
<label style="display:block;font-size:12px;font-weight:500;color:#8b949e;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:6px" for="username">Username</label>
|
||||
<input style="display:block;width:100%;background:#111823;border:1px solid rgba(255,255,255,0.1);border-radius:6px;padding:9px 12px;font-size:14px;color:#e2e8f0;outline:none;box-sizing:border-box"
|
||||
type="text" id="username" name="username"
|
||||
required autofocus maxlength="128" autocomplete="username" spellcheck="false">
|
||||
</div>
|
||||
<div style="margin-bottom:22px">
|
||||
<label style="display:block;font-size:12px;font-weight:500;color:#8b949e;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:6px" for="password">Password</label>
|
||||
<input style="display:block;width:100%;background:#111823;border:1px solid rgba(255,255,255,0.1);border-radius:6px;padding:9px 12px;font-size:14px;color:#e2e8f0;outline:none;box-sizing:border-box"
|
||||
type="password" id="password" name="password"
|
||||
required maxlength="128" autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit"
|
||||
style="display:block;width:100%;background:#00d4ff;color:#080b10;font-size:14px;font-weight:600;border:none;border-radius:6px;padding:10px 0;cursor:pointer;letter-spacing:0.3px">
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,81 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="max-width:1200px">
|
||||
|
||||
<div style="margin-bottom:16px">
|
||||
<div class="page-title">Machines</div>
|
||||
<div class="page-sub">CrowdSec agent registrations</div>
|
||||
</div>
|
||||
|
||||
{{if not .CLIAvailable}}
|
||||
<div class="cli-unavail-banner" style="margin-bottom:16px">
|
||||
<div>
|
||||
<strong style="display:block;margin-bottom:4px">cscli unavailable</strong>
|
||||
Machine management requires the cscli binary. Mount it at the CSCLI_PATH configured in your environment.
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Machines ({{len .Machines}})</span>
|
||||
</div>
|
||||
{{if .Machines}}
|
||||
<div style="overflow-x:auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Machine ID</th>
|
||||
<th>IP</th>
|
||||
<th>Last Heartbeat</th>
|
||||
<th>Version</th>
|
||||
<th>Auth</th>
|
||||
<th>Status</th>
|
||||
{{if $.CLIAvailable}}<th>Actions</th>{{end}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Machines}}
|
||||
<tr>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:600" title="{{.MachineID}}">{{truncate .MachineID 32}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.IPAddress}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{truncate .LastHeartbeat 16}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--muted)">{{.Version}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{.AuthType}}</td>
|
||||
<td>
|
||||
{{if .IsValidated}}
|
||||
<span class="badge badge-green">validated</span>
|
||||
{{else}}
|
||||
<span class="badge badge-amber">pending</span>
|
||||
{{end}}
|
||||
</td>
|
||||
{{if $.CLIAvailable}}
|
||||
<td style="display:flex;gap:6px;flex-wrap:wrap">
|
||||
{{if not .IsValidated}}
|
||||
<form method="POST" action="/machines/validate" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="id" value="{{.MachineID}}">
|
||||
<button type="submit" class="btn-safe-sm" onclick="return confirm('Validate machine "{{.MachineID}}"?')">Validate</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="POST" action="/machines/delete" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="id" value="{{.MachineID}}">
|
||||
<button type="submit" class="btn-danger-sm" onclick="return confirm('Delete machine "{{.MachineID}}"?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No machines registered</div>
|
||||
<div class="empty-sub">Register an agent: cscli machines add <name> -a</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,62 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="max-width:1200px">
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
<div>
|
||||
<div class="page-title">Metrics</div>
|
||||
<div class="page-sub">Real-time CrowdSec statistics from cscli metrics</div>
|
||||
</div>
|
||||
{{if .CLIAvailable}}
|
||||
<a href="/metrics-ui" class="btn-secondary">Refresh</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if not .CLIAvailable}}
|
||||
<div class="cli-unavail-banner" style="margin-bottom:16px">
|
||||
<div>
|
||||
<strong style="display:block;margin-bottom:4px">cscli unavailable</strong>
|
||||
Metrics require the cscli binary. Mount it at the CSCLI_PATH configured in your environment.
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Sections}}
|
||||
{{range .Sections}}
|
||||
<div class="panel" style="margin-bottom:16px">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">{{.Title}}</span>
|
||||
</div>
|
||||
{{if and .Headers .Rows}}
|
||||
<div style="overflow-x:auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{{range .Headers}}<th>{{.}}</th>{{end}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Rows}}
|
||||
<tr>
|
||||
{{range .}}<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.}}</td>{{end}}
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No data in this section</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{else if .CLIAvailable}}
|
||||
<div class="empty-state" style="padding:48px">
|
||||
<div class="empty-text">No metrics available</div>
|
||||
<div class="empty-sub">CrowdSec may not have processed any data yet</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user