Initial commit: Nerd Monitor - Cross-platform system monitoring application
Features: - Multi-platform agents (Linux, macOS, Windows - AMD64 & ARM64) - Real-time CPU, RAM, and disk usage monitoring - Responsive web dashboard with live status indicators - Session-based authentication with secure credentials - Stale agent detection and removal (6+ months inactive) - Auto-refresh dashboard (5 second intervals) - 15-second agent reporting intervals - Auto-generated agent IDs from hostnames - In-memory storage (zero database setup) - Minimal dependencies (Chi router + Templ templating) Project Structure: - cmd/: Agent and Server executables - internal/: API, Auth, Stats, Storage, and UI packages - views/: Templ templates for dashboard UI - Makefile: Build automation for all platforms Ready for deployment with comprehensive documentation: - README.md: Full project documentation - QUICKSTART.md: Getting started guide - AGENTS.md: Development guidelines
This commit is contained in:
67
views/agent_detail.templ
Normal file
67
views/agent_detail.templ
Normal file
@@ -0,0 +1,67 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nerd-monitor/internal/store"
|
||||
)
|
||||
|
||||
templ AgentDetail(agent *store.AgentStats) {
|
||||
@BaseLayout(agent.Hostname, agentDetailContent(agent))
|
||||
}
|
||||
|
||||
templ agentDetailContent(agent *store.AgentStats) {
|
||||
<a href="/" class="back-link">← Back to Dashboard</a>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">{ agent.Hostname }</h2>
|
||||
@AgentStatusBadge(agent.LastSeen)
|
||||
</div>
|
||||
|
||||
<div class="grid" style="grid-template-columns: 1fr;">
|
||||
<div class="card">
|
||||
<div class="card-title">CPU Usage</div>
|
||||
<div class="metric-value" style="font-size: 2rem; margin: 1rem 0;">
|
||||
{ FormatPercent(agent.CPUUsage) }
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class={ "progress-fill", calcProgressClass(agent.CPUUsage/100) } style={ fmt.Sprintf("width: %.1f%%", agent.CPUUsage) }></div>
|
||||
</div>
|
||||
<div style="margin-top: 1rem; font-size: 0.875rem; color: #94a3b8;">
|
||||
Last updated: { FormatTime(agent.LastSeen) }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid" style="margin-top: 1.5rem;">
|
||||
<div class="card">
|
||||
<div class="card-title">Memory Usage</div>
|
||||
@UsageBar("RAM", agent.RAMUsage, agent.RAMTotal)
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">Disk Usage</div>
|
||||
@UsageBar("Disk", agent.DiskUsage, agent.DiskTotal)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top: 1.5rem;">
|
||||
<div class="card-title">Agent Information</div>
|
||||
<div class="metric-row">
|
||||
<span class="metric-label">Agent ID</span>
|
||||
<span class="metric-value" style="font-family: monospace; font-size: 0.875rem;">{ agent.ID }</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<span class="metric-label">Hostname</span>
|
||||
<span class="metric-value">{ agent.Hostname }</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<span class="metric-label">Last Seen</span>
|
||||
<span class="metric-value">{ agent.LastSeen.Format("2006-01-02 15:04:05") }</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group" style="margin-top: 1.5rem;">
|
||||
<form method="POST" action={ templ.SafeURL(fmt.Sprintf("/api/agents/%s/delete", agent.ID)) } style="margin: 0;" onsubmit="return confirm('Are you sure you want to remove this agent?');">
|
||||
<button type="submit" class="btn btn-danger">Delete Agent</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
138
views/components.templ
Normal file
138
views/components.templ
Normal file
@@ -0,0 +1,138 @@
|
||||
package views
|
||||
|
||||
import "fmt"
|
||||
import "time"
|
||||
|
||||
// FormatBytes converts bytes to human-readable format
|
||||
func FormatBytes(bytes uint64) string {
|
||||
const (
|
||||
kb = 1024
|
||||
mb = kb * 1024
|
||||
gb = mb * 1024
|
||||
tb = gb * 1024
|
||||
)
|
||||
|
||||
switch {
|
||||
case bytes >= tb:
|
||||
return fmt.Sprintf("%.2f TB", float64(bytes)/float64(tb))
|
||||
case bytes >= gb:
|
||||
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(gb))
|
||||
case bytes >= mb:
|
||||
return fmt.Sprintf("%.2f MB", float64(bytes)/float64(mb))
|
||||
case bytes >= kb:
|
||||
return fmt.Sprintf("%.2f KB", float64(bytes)/float64(kb))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// FormatPercent formats percentage with 1 decimal place
|
||||
func FormatPercent(percent float64) string {
|
||||
return fmt.Sprintf("%.1f%%", percent)
|
||||
}
|
||||
|
||||
// FormatTime formats time as relative or absolute
|
||||
func FormatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "Never"
|
||||
}
|
||||
|
||||
duration := time.Since(t)
|
||||
switch {
|
||||
case duration < time.Minute:
|
||||
return "Just now"
|
||||
case duration < time.Hour:
|
||||
return fmt.Sprintf("%d minutes ago", int(duration.Minutes()))
|
||||
case duration < 24*time.Hour:
|
||||
return fmt.Sprintf("%d hours ago", int(duration.Hours()))
|
||||
case duration < 30*24*time.Hour:
|
||||
return fmt.Sprintf("%d days ago", int(duration.Hours()/24))
|
||||
default:
|
||||
return fmt.Sprintf("%d months ago", int(duration.Hours()/24/30))
|
||||
}
|
||||
}
|
||||
|
||||
// StatusColor returns CSS class for status based on conditions
|
||||
func StatusColor(cpuUsage, ramPercent, lastSeenAge time.Duration) string {
|
||||
if lastSeenAge > 24*time.Hour {
|
||||
return "status-red"
|
||||
}
|
||||
if cpuUsage > 80 || ramPercent > 80 {
|
||||
return "status-yellow"
|
||||
}
|
||||
return "status-green"
|
||||
}
|
||||
|
||||
// IsAgentOnline checks if agent is currently online (reported within 15 seconds)
|
||||
func IsAgentOnline(lastSeen time.Time) bool {
|
||||
return time.Since(lastSeen) < 15*time.Second
|
||||
}
|
||||
|
||||
templ StatusCard(label string, value string, status string) {
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
{ label }
|
||||
<span class={ "status-badge", status }>{ status }</span>
|
||||
</div>
|
||||
<div class="metric-value" style="font-size: 1.5rem; margin-top: 0.5rem;">
|
||||
{ value }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ UsageBar(label string, used uint64, total uint64) {
|
||||
<div class="metric-row">
|
||||
<span class="metric-label">{ label }</span>
|
||||
<span class="metric-value">{ FormatBytes(used) } / { FormatBytes(total) }</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
if total > 0 {
|
||||
<div class={ "progress-fill", calcProgressClass(float64(used)/float64(total)) } style={ fmt.Sprintf("width: %.1f%%", float64(used)/float64(total)*100) }></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// calcProgressClass returns the CSS class based on percentage
|
||||
func calcProgressClass(percent float64) string {
|
||||
if percent > 0.8 {
|
||||
return "progress-high"
|
||||
}
|
||||
if percent > 0.6 {
|
||||
return "progress-medium"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
templ AgentRow(id string, hostname string, cpuUsage float64, ramUsage uint64, ramTotal uint64, diskUsage uint64, diskTotal uint64, lastSeen time.Time) {
|
||||
<tr>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
@AgentStatusBadge(lastSeen)
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/agents/%s", id)) }>{ hostname }</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>{ FormatPercent(cpuUsage) }</td>
|
||||
<td>{ FormatBytes(ramUsage) } / { FormatBytes(ramTotal) }</td>
|
||||
<td>{ FormatBytes(diskUsage) } / { FormatBytes(diskTotal) }</td>
|
||||
<td class="timestamp">{ FormatTime(lastSeen) }</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
templ AgentStatusBadge(lastSeen time.Time) {
|
||||
if IsAgentOnline(lastSeen) {
|
||||
<span class="status-badge status-green" style="margin: 0;">Online</span>
|
||||
} else {
|
||||
<span class="status-badge status-red" style="margin: 0;">Offline</span>
|
||||
}
|
||||
}
|
||||
|
||||
templ StaleAgentAlert(count int, agents []interface{}) {
|
||||
if count > 0 {
|
||||
<div class="alert alert-warning">
|
||||
<strong>⚠️ { count } agent(s) haven't reported for 6+ months</strong>
|
||||
<p style="margin-top: 0.5rem; font-size: 0.875rem;">
|
||||
These agents may be offline. You can remove them to keep your dashboard clean.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
81
views/dashboard.templ
Normal file
81
views/dashboard.templ
Normal file
@@ -0,0 +1,81 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"nerd-monitor/internal/store"
|
||||
)
|
||||
|
||||
templ Dashboard(agents []*store.AgentStats, staleAgents []*store.AgentStats) {
|
||||
@BaseLayout("Dashboard", dashboardContent(agents, staleAgents))
|
||||
}
|
||||
|
||||
templ dashboardContent(agents []*store.AgentStats, staleAgents []*store.AgentStats) {
|
||||
<h2 style="margin-bottom: 1rem;">Agent Status Overview</h2>
|
||||
|
||||
@StaleAgentAlert(len(staleAgents), nil)
|
||||
|
||||
if len(agents) == 0 {
|
||||
<div class="alert alert-info">
|
||||
<strong>ℹ️ No agents connected</strong>
|
||||
<p style="margin-top: 0.5rem; font-size: 0.875rem;">
|
||||
Start an agent to see its statistics appear here.
|
||||
</p>
|
||||
</div>
|
||||
} else {
|
||||
<div class="card">
|
||||
<div class="card-title">Active Agents</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<th>CPU Usage</th>
|
||||
<th>Memory</th>
|
||||
<th>Disk</th>
|
||||
<th>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, agent := range agents {
|
||||
@AgentRow(agent.ID, agent.Hostname, agent.CPUUsage, agent.RAMUsage, agent.RAMTotal, agent.DiskUsage, agent.DiskTotal, agent.LastSeen)
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
if len(staleAgents) > 0 {
|
||||
<div class="card" style="margin-top: 2rem; border-color: #d97706;">
|
||||
<div class="card-title" style="color: #f59e0b;">
|
||||
Stale Agents ({ len(staleAgents) })
|
||||
</div>
|
||||
<form method="POST" action="/api/agents/remove-stale" style="margin-top: 1rem;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 30px;"><input type="checkbox" id="select-all" onchange="toggleAll(this)" /></th>
|
||||
<th>Hostname</th>
|
||||
<th>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, agent := range staleAgents {
|
||||
<tr>
|
||||
<td><input type="checkbox" name="agent_ids" value={ agent.ID } class="agent-checkbox" /></td>
|
||||
<td>{ agent.Hostname }</td>
|
||||
<td class="timestamp">{ FormatTime(agent.LastSeen) }</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-danger" style="margin-top: 1rem;">Remove Selected</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
function toggleAll(checkbox) {
|
||||
const checkboxes = document.querySelectorAll('.agent-checkbox');
|
||||
checkboxes.forEach(cb => cb.checked = checkbox.checked);
|
||||
}
|
||||
</script>
|
||||
}
|
||||
}
|
||||
3
views/generate.go
Normal file
3
views/generate.go
Normal file
@@ -0,0 +1,3 @@
|
||||
//go:generate templ generate
|
||||
|
||||
package views
|
||||
297
views/layout.templ
Normal file
297
views/layout.templ
Normal file
@@ -0,0 +1,297 @@
|
||||
package views
|
||||
|
||||
templ BaseLayout(title string, content templ.Component) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="refresh" content="5" />
|
||||
<title>{ title } - Nerd Monitor</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: #1e293b;
|
||||
border-bottom: 1px solid #334155;
|
||||
padding: 1rem 2rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-green {
|
||||
background-color: #10b981;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.status-yellow {
|
||||
background-color: #f59e0b;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.status-red {
|
||||
background-color: #ef4444;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.metric-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-color: #334155;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: #3b82f6;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.progress-high {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
.progress-medium {
|
||||
background-color: #f59e0b;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #3b82f6;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: #7c2d12;
|
||||
border-color: #f59e0b;
|
||||
color: #fed7aa;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: #0c4a6e;
|
||||
border-color: #3b82f6;
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #334155;
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #475569;
|
||||
font-size: 0.875rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #1e293b;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
background-color: #64748b;
|
||||
color: #ffffff;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background-color: #475569;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-container">
|
||||
<h1>Nerd Monitor</h1>
|
||||
<div class="nav-links">
|
||||
<a href="/">Dashboard</a>
|
||||
<form method="POST" action="/logout" style="margin: 0;">
|
||||
<button type="submit" class="logout-btn">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div class="container">
|
||||
@content
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
153
views/login.templ
Normal file
153
views/login.templ
Normal file
@@ -0,0 +1,153 @@
|
||||
package views
|
||||
|
||||
templ LoginPage(errorMsg string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Login - Nerd Monitor</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background-color: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background-color: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.375rem;
|
||||
color: #e2e8f0;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background-color: #3b82f6;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-login:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #7c2d12;
|
||||
border: 1px solid #dc2626;
|
||||
color: #fed7aa;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.info {
|
||||
background-color: #0c4a6e;
|
||||
border: 1px solid #3b82f6;
|
||||
color: #bfdbfe;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<h1>Nerd Monitor</h1>
|
||||
<p class="subtitle">System Monitoring Dashboard</p>
|
||||
|
||||
if errorMsg != "" {
|
||||
<div class="error">{ errorMsg }</div>
|
||||
}
|
||||
|
||||
<div class="info">
|
||||
<strong>Demo Credentials:</strong><br/>
|
||||
Username: <code>admin</code><br/>
|
||||
Password: <code>admin</code>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/login">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required />
|
||||
</div>
|
||||
<button type="submit" class="btn-login">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
Reference in New Issue
Block a user