feat: Add Headscale Web Admin panel

- Go backend proxying Headscale REST API
- Dashboard: total nodes, online/offline, users count
- Nodes management: list, delete, expire
- Users management: create, delete
- Pre-auth keys: create reusable/ephemeral keys
- Password-protected web UI
- Docker + docker-compose deployment
- Auto-refresh every 30s
- Dark theme UI
This commit is contained in:
2026-04-10 17:44:15 +07:00
parent b9b6b23a2f
commit 5f18a2f925
10 changed files with 989 additions and 0 deletions
BIN
View File
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
# Headscale API key (required)
# Generate: docker exec headscale headscale apikeys create --expiration 365d
HEADSCALE_API_KEY=your_api_key_here
# Admin password for web UI
ADMIN_PASSWORD=admin123
+12
View File
@@ -0,0 +1,12 @@
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /headscale-admin .
FROM alpine:3.19
RUN apk add --no-cache ca-certificates
COPY --from=builder /headscale-admin /usr/local/bin/headscale-admin
EXPOSE 9080
ENTRYPOINT ["headscale-admin"]
+47
View File
@@ -0,0 +1,47 @@
# Headscale Web Admin
Web-based admin panel for managing Headscale VPN nodes, users, and pre-auth keys.
## Features
- **Dashboard**: Total nodes, online/offline count
- **Nodes**: List, delete, expire nodes
- **Users**: Create, delete users
- **Pre-auth Keys**: Create reusable/ephemeral keys for auto-registration
- **Auto-refresh**: Node status updates every 30s
- **Simple auth**: Password-protected admin panel
## Quick Start
```bash
# 1. Copy env file
cp .env.example .env
# 2. Edit .env with your Headscale API key
# Generate key: docker exec headscale headscale apikeys create --expiration 365d
# 3. Start
docker compose up -d
```
Admin panel: `http://your-server:9080`
## Standalone (without Headscale in same compose)
```bash
docker build -t headscale-admin .
docker run -d --name headscale-admin \
-p 9080:9080 \
-e HEADSCALE_URL=http://your-headscale:8080 \
-e HEADSCALE_API_KEY=your_key \
-e ADMIN_PASSWORD=your_password \
headscale-admin
```
## Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `HEADSCALE_URL` | Yes | `http://localhost:8080` | Headscale API URL |
| `HEADSCALE_API_KEY` | Yes | - | Headscale API key |
| `ADMIN_PASSWORD` | No | - | Web UI password (empty = no auth) |
| `LISTEN_ADDR` | No | `:9080` | Listen address |
+28
View File
@@ -0,0 +1,28 @@
version: "3.9"
services:
headscale:
image: headscale/headscale:latest
container_name: headscale
restart: always
volumes:
- ./headscale/config:/etc/headscale
- ./headscale/data:/var/lib/headscale
ports:
- "8080:8080"
- "3478:3478/udp"
command: serve
headscale-admin:
build: .
container_name: headscale-admin
restart: always
ports:
- "9080:9080"
environment:
- HEADSCALE_URL=http://headscale:8080
- HEADSCALE_API_KEY=${HEADSCALE_API_KEY}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- LISTEN_ADDR=:9080
depends_on:
- headscale
+4
View File
@@ -0,0 +1,4 @@
module tailscale-custom/web-admin
go 1.22
+288
View File
@@ -0,0 +1,288 @@
package main
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
)
//go:embed static/*
var staticFS embed.FS
var (
headscaleURL string
apiKey string
listenAddr string
adminPass string
)
func main() {
headscaleURL = getEnv("HEADSCALE_URL", "http://localhost:8080")
apiKey = getEnv("HEADSCALE_API_KEY", "")
listenAddr = getEnv("LISTEN_ADDR", ":9080")
adminPass = getEnv("ADMIN_PASSWORD", "")
if apiKey == "" {
log.Fatal("HEADSCALE_API_KEY is required")
}
headscaleURL = strings.TrimRight(headscaleURL, "/")
mux := http.NewServeMux()
// API routes - proxy to headscale
mux.HandleFunc("/api/nodes", authMiddleware(handleNodes))
mux.HandleFunc("/api/nodes/", authMiddleware(handleNodeByID))
mux.HandleFunc("/api/users", authMiddleware(handleUsers))
mux.HandleFunc("/api/users/", authMiddleware(handleUserByName))
mux.HandleFunc("/api/preauthkeys", authMiddleware(handlePreauthKeys))
mux.HandleFunc("/api/routes", authMiddleware(handleRoutes))
mux.HandleFunc("/api/routes/", authMiddleware(handleRouteByID))
mux.HandleFunc("/api/auth", handleAuth)
// Static files
mux.Handle("/", http.FileServer(http.FS(staticFS)))
log.Printf("Headscale Web Admin starting on %s", listenAddr)
log.Printf("Headscale API: %s", headscaleURL)
log.Fatal(http.ListenAndServe(listenAddr, mux))
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// Simple auth check
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if adminPass != "" {
token := r.Header.Get("X-Admin-Token")
if token != adminPass {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
}
next(w, r)
}
}
func handleAuth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Password string `json:"password"`
}
json.NewDecoder(r.Body).Decode(&req)
if adminPass == "" || req.Password == adminPass {
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
} else {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "wrong password"})
}
}
// --- Nodes ---
func handleNodes(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
proxyGet(w, "/api/v1/node")
case http.MethodPost:
// Register node
proxyPost(w, r, "/api/v1/node/register")
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func handleNodeByID(w http.ResponseWriter, r *http.Request) {
// /api/nodes/{id} or /api/nodes/{id}/action
path := strings.TrimPrefix(r.URL.Path, "/api/nodes/")
parts := strings.SplitN(path, "/", 2)
nodeID := parts[0]
if len(parts) == 2 {
action := parts[1]
switch action {
case "expire":
proxyPost(w, r, fmt.Sprintf("/api/v1/node/%s/expire", nodeID))
case "rename":
proxyPost(w, r, fmt.Sprintf("/api/v1/node/%s/rename", nodeID))
case "routes":
proxyGet(w, fmt.Sprintf("/api/v1/node/%s/routes", nodeID))
default:
http.Error(w, "unknown action", http.StatusBadRequest)
}
return
}
switch r.Method {
case http.MethodGet:
proxyGet(w, fmt.Sprintf("/api/v1/node/%s", nodeID))
case http.MethodDelete:
proxyDelete(w, fmt.Sprintf("/api/v1/node/%s", nodeID))
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// --- Users ---
func handleUsers(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
proxyGet(w, "/api/v1/user")
case http.MethodPost:
proxyPost(w, r, "/api/v1/user")
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func handleUserByName(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/api/users/")
if r.Method == http.MethodDelete {
proxyDelete(w, fmt.Sprintf("/api/v1/user/%s", name))
} else {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// --- Pre-auth Keys ---
func handlePreauthKeys(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
user := r.URL.Query().Get("user")
url := "/api/v1/preauthkey"
if user != "" {
url += "?user=" + user
}
proxyGet(w, url)
case http.MethodPost:
proxyPost(w, r, "/api/v1/preauthkey")
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// --- Routes ---
func handleRoutes(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
proxyGet(w, "/api/v1/routes")
} else {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func handleRouteByID(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/routes/")
parts := strings.SplitN(path, "/", 2)
routeID := parts[0]
if len(parts) == 2 {
action := parts[1]
switch action {
case "enable":
proxyPost(w, r, fmt.Sprintf("/api/v1/routes/%s/enable", routeID))
case "disable":
proxyPost(w, r, fmt.Sprintf("/api/v1/routes/%s/disable", routeID))
default:
http.Error(w, "unknown action", http.StatusBadRequest)
}
return
}
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
// --- Proxy helpers ---
var httpClient = &http.Client{Timeout: 15 * time.Second}
func proxyGet(w http.ResponseWriter, path string) {
url := headscaleURL + path
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
httpError(w, "failed to create request", err)
return
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := httpClient.Do(req)
if err != nil {
httpError(w, "headscale API error", err)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
func proxyPost(w http.ResponseWriter, r *http.Request, path string) {
url := headscaleURL + path
body, _ := io.ReadAll(r.Body)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
httpError(w, "failed to create request", err)
return
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
httpError(w, "headscale API error", err)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
func proxyDelete(w http.ResponseWriter, path string) {
url := headscaleURL + path
req, err := http.NewRequest(http.MethodDelete, url, nil)
if err != nil {
httpError(w, "failed to create request", err)
return
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := httpClient.Do(req)
if err != nil {
httpError(w, "headscale API error", err)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
func httpError(w http.ResponseWriter, msg string, err error) {
log.Printf("ERROR: %s: %v", msg, err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("%s: %v", msg, err)})
}
+331
View File
@@ -0,0 +1,331 @@
// Headscale Web Admin - Frontend
let adminToken = '';
// --- Auth ---
function doLogin() {
const pw = document.getElementById('login-password').value;
fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw })
})
.then(r => r.json())
.then(data => {
if (data.ok) {
adminToken = pw;
sessionStorage.setItem('token', pw);
document.getElementById('login-screen').style.display = 'none';
document.getElementById('app').style.display = 'block';
refreshAll();
} else {
document.getElementById('login-error').textContent = 'Wrong password';
}
})
.catch(() => {
document.getElementById('login-error').textContent = 'Connection error';
});
}
function doLogout() {
adminToken = '';
sessionStorage.removeItem('token');
document.getElementById('app').style.display = 'none';
document.getElementById('login-screen').style.display = 'flex';
document.getElementById('login-password').value = '';
}
// Auto-login from session
(function() {
const saved = sessionStorage.getItem('token');
if (saved) {
adminToken = saved;
document.getElementById('login-screen').style.display = 'none';
document.getElementById('app').style.display = 'block';
refreshAll();
}
})();
// --- API helpers ---
function api(path, opts = {}) {
const headers = { 'X-Admin-Token': adminToken, ...opts.headers };
if (opts.body && typeof opts.body === 'object') {
headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(opts.body);
}
return fetch(path, { ...opts, headers }).then(r => {
if (r.status === 401) { doLogout(); throw new Error('unauthorized'); }
return r.json();
});
}
function toast(msg, type = 'success') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast show ' + type;
setTimeout(() => el.className = 'toast', 3000);
}
// --- Tabs ---
function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
document.querySelectorAll('.tab-content').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name));
}
// --- Data ---
let nodesData = [];
let usersData = [];
function refreshAll() {
refreshNodes();
refreshUsers();
refreshKeys();
}
// --- Nodes ---
function refreshNodes() {
api('/api/nodes').then(data => {
const nodes = data.nodes || [];
nodesData = nodes;
renderNodes(nodes);
renderDashboard(nodes);
}).catch(e => toast('Failed to load nodes: ' + e.message, 'error'));
}
function renderNodes(nodes) {
const tbody = document.getElementById('nodes-table');
if (!nodes.length) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#64748b;padding:40px">No nodes registered</td></tr>';
return;
}
tbody.innerHTML = nodes.map(n => {
const online = isOnline(n);
const ips = (n.ipAddresses || []).join(', ');
const userName = n.user ? (n.user.name || n.user.Name || '-') : '-';
const name = n.givenName || n.name || '-';
return `<tr>
<td>${n.id}</td>
<td><strong>${esc(name)}</strong></td>
<td><code style="font-size:12px">${esc(ips)}</code></td>
<td>${esc(userName)}</td>
<td>${online ? '<span class="badge badge-online">Online</span>' : '<span class="badge badge-offline">Offline</span>'}</td>
<td>${timeAgo(n.lastSeen)}</td>
<td>${fmtDate(n.createdAt)}</td>
<td>
<button class="btn btn-sm btn-danger" onclick="deleteNode('${n.id}','${esc(name)}')">Delete</button>
<button class="btn btn-sm" onclick="expireNode('${n.id}')">Expire</button>
</td>
</tr>`;
}).join('');
}
function renderDashboard(nodes) {
const online = nodes.filter(isOnline).length;
document.getElementById('stat-total').textContent = nodes.length;
document.getElementById('stat-online').textContent = online;
document.getElementById('stat-offline').textContent = nodes.length - online;
// Recent 5 nodes
const recent = [...nodes].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).slice(0, 5);
const tbody = document.getElementById('dashboard-nodes');
tbody.innerHTML = recent.map(n => {
const online = isOnline(n);
const ips = (n.ipAddresses || []).join(', ');
const userName = n.user ? (n.user.name || n.user.Name || '-') : '-';
const name = n.givenName || n.name || '-';
return `<tr>
<td><strong>${esc(name)}</strong></td>
<td><code style="font-size:12px">${esc(ips)}</code></td>
<td>${esc(userName)}</td>
<td>${online ? '<span class="badge badge-online">Online</span>' : '<span class="badge badge-offline">Offline</span>'}</td>
<td>${timeAgo(n.lastSeen)}</td>
</tr>`;
}).join('') || '<tr><td colspan="5" style="text-align:center;color:#64748b">No nodes yet</td></tr>';
}
function deleteNode(id, name) {
if (!confirm(`Delete node "${name}" (ID: ${id})?`)) return;
api('/api/nodes/' + id, { method: 'DELETE' })
.then(() => { toast('Node deleted'); refreshNodes(); })
.catch(e => toast('Delete failed: ' + e.message, 'error'));
}
function expireNode(id) {
api('/api/nodes/' + id + '/expire', { method: 'POST' })
.then(() => { toast('Node expired'); refreshNodes(); })
.catch(e => toast('Expire failed: ' + e.message, 'error'));
}
// --- Users ---
function refreshUsers() {
api('/api/users').then(data => {
const users = data.users || [];
usersData = users;
renderUsers(users);
document.getElementById('stat-users').textContent = users.length;
updateKeyUserSelect(users);
}).catch(e => toast('Failed to load users: ' + e.message, 'error'));
}
function renderUsers(users) {
const tbody = document.getElementById('users-table');
if (!users.length) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:#64748b;padding:40px">No users</td></tr>';
return;
}
tbody.innerHTML = users.map(u => {
const name = u.name || u.Name || '-';
return `<tr>
<td>${u.id}</td>
<td><strong>${esc(name)}</strong></td>
<td>${fmtDate(u.createdAt)}</td>
<td><button class="btn btn-sm btn-danger" onclick="deleteUser('${esc(name)}')">Delete</button></td>
</tr>`;
}).join('');
}
function createUser() {
const input = document.getElementById('new-user-name');
const name = input.value.trim();
if (!name) { toast('Enter a username', 'error'); return; }
api('/api/users', { method: 'POST', body: { name: name } })
.then(() => { toast('User created: ' + name); input.value = ''; refreshUsers(); })
.catch(e => toast('Create failed: ' + e.message, 'error'));
}
function deleteUser(name) {
if (!confirm(`Delete user "${name}"? All nodes of this user will also be removed.`)) return;
api('/api/users/' + encodeURIComponent(name), { method: 'DELETE' })
.then(() => { toast('User deleted'); refreshUsers(); refreshNodes(); })
.catch(e => toast('Delete failed: ' + e.message, 'error'));
}
// --- Pre-auth Keys ---
function updateKeyUserSelect(users) {
const sel = document.getElementById('key-user-select');
sel.innerHTML = users.map(u => {
const name = u.name || u.Name;
return `<option value="${esc(name)}">${esc(name)}</option>`;
}).join('');
}
function refreshKeys() {
// Need to fetch keys per user
if (!usersData.length) {
api('/api/users').then(data => {
usersData = data.users || [];
fetchAllKeys();
});
} else {
fetchAllKeys();
}
}
function fetchAllKeys() {
const promises = usersData.map(u => {
const name = u.name || u.Name;
return api('/api/preauthkeys?user=' + encodeURIComponent(name))
.then(data => (data.preAuthKeys || []).map(k => ({ ...k, _user: name })))
.catch(() => []);
});
Promise.all(promises).then(results => {
const allKeys = results.flat().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
renderKeys(allKeys);
});
}
function renderKeys(keys) {
const tbody = document.getElementById('keys-table');
if (!keys.length) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#64748b;padding:40px">No pre-auth keys</td></tr>';
return;
}
tbody.innerHTML = keys.map(k => {
const expired = k.expiration && new Date(k.expiration) < new Date();
const keyShort = k.key ? k.key.substring(0, 16) + '...' : '-';
return `<tr>
<td><span class="key-text" title="Click to copy full key" onclick="copyKey('${esc(k.key)}')">${esc(keyShort)}</span></td>
<td>${esc(k._user || k.user || '-')}</td>
<td>${k.reusable ? '✅' : '—'}</td>
<td>${k.ephemeral ? '✅' : '—'}</td>
<td>${k.used ? '✅' : '—'}</td>
<td>${expired ? '<span class="badge badge-expired">Expired</span>' : fmtDate(k.expiration)}</td>
<td>${fmtDate(k.createdAt)}</td>
</tr>`;
}).join('');
}
function createKey() {
const user = document.getElementById('key-user-select').value;
if (!user) { toast('Select a user first', 'error'); return; }
const reusable = document.getElementById('key-reusable').checked;
const ephemeral = document.getElementById('key-ephemeral').checked;
const hours = parseInt(document.getElementById('key-expiry').value) || 24;
const expiration = new Date(Date.now() + hours * 3600000).toISOString();
api('/api/preauthkeys', {
method: 'POST',
body: { user: user, reusable, ephemeral, expiration }
}).then(data => {
const key = data.preAuthKey ? data.preAuthKey.key : 'created';
toast('Key created: ' + key.substring(0, 20) + '...');
refreshKeys();
}).catch(e => toast('Create key failed: ' + e.message, 'error'));
}
function copyKey(key) {
navigator.clipboard.writeText(key).then(
() => toast('Key copied to clipboard'),
() => toast('Copy failed', 'error')
);
}
// --- Helpers ---
function isOnline(node) {
if (!node.lastSeen) return false;
const diff = Date.now() - new Date(node.lastSeen).getTime();
return node.online === true || diff < 5 * 60 * 1000; // 5 min
}
function timeAgo(dateStr) {
if (!dateStr) return '-';
const diff = Date.now() - new Date(dateStr).getTime();
if (diff < 0) return 'just now';
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return mins + 'm ago';
const hours = Math.floor(mins / 60);
if (hours < 24) return hours + 'h ago';
const days = Math.floor(hours / 24);
return days + 'd ago';
}
function fmtDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleDateString('vi-VN') + ' ' + d.toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' });
}
function esc(s) {
if (!s) return '';
const div = document.createElement('div');
div.textContent = String(s);
return div.innerHTML;
}
// Auto-refresh every 30s
setInterval(() => {
if (adminToken) refreshNodes();
}, 30000);
+134
View File
@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Headscale Admin</title>
<link rel="stylesheet" href="static/style.css">
</head>
<body>
<!-- Login screen -->
<div id="login-screen" class="login-screen">
<div class="login-box">
<h1>🔒 Headscale Admin</h1>
<input type="password" id="login-password" placeholder="Admin password" onkeydown="if(event.key==='Enter')doLogin()">
<button onclick="doLogin()">Login</button>
<p id="login-error" class="error"></p>
</div>
</div>
<!-- Main app -->
<div id="app" style="display:none">
<nav>
<div class="nav-brand">⚡ Headscale Admin</div>
<div class="nav-tabs">
<button class="tab active" data-tab="dashboard" onclick="switchTab('dashboard')">Dashboard</button>
<button class="tab" data-tab="nodes" onclick="switchTab('nodes')">Nodes</button>
<button class="tab" data-tab="users" onclick="switchTab('users')">Users</button>
<button class="tab" data-tab="keys" onclick="switchTab('keys')">Auth Keys</button>
</div>
<button class="btn-logout" onclick="doLogout()">Logout</button>
</nav>
<!-- Dashboard -->
<div id="tab-dashboard" class="tab-content active">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-number" id="stat-total">-</div>
<div class="stat-label">Total Nodes</div>
</div>
<div class="stat-card stat-online">
<div class="stat-number" id="stat-online">-</div>
<div class="stat-label">Online</div>
</div>
<div class="stat-card stat-offline">
<div class="stat-number" id="stat-offline">-</div>
<div class="stat-label">Offline</div>
</div>
<div class="stat-card stat-users">
<div class="stat-number" id="stat-users">-</div>
<div class="stat-label">Users</div>
</div>
</div>
<div class="card">
<h3>Recent Nodes</h3>
<table>
<thead><tr><th>Name</th><th>IP</th><th>User</th><th>Status</th><th>Last Seen</th></tr></thead>
<tbody id="dashboard-nodes"></tbody>
</table>
</div>
</div>
<!-- Nodes -->
<div id="tab-nodes" class="tab-content">
<div class="toolbar">
<h2>Nodes</h2>
<div>
<button class="btn btn-primary" onclick="refreshNodes()">↻ Refresh</button>
</div>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>IP Addresses</th>
<th>User</th>
<th>Status</th>
<th>Last Seen</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="nodes-table"></tbody>
</table>
</div>
</div>
<!-- Users -->
<div id="tab-users" class="tab-content">
<div class="toolbar">
<h2>Users</h2>
<div>
<input type="text" id="new-user-name" placeholder="Username">
<button class="btn btn-primary" onclick="createUser()">+ Create User</button>
<button class="btn" onclick="refreshUsers()">↻ Refresh</button>
</div>
</div>
<div class="card">
<table>
<thead><tr><th>ID</th><th>Name</th><th>Created</th><th>Actions</th></tr></thead>
<tbody id="users-table"></tbody>
</table>
</div>
</div>
<!-- Pre-auth Keys -->
<div id="tab-keys" class="tab-content">
<div class="toolbar">
<h2>Pre-Auth Keys</h2>
<div>
<select id="key-user-select"></select>
<label><input type="checkbox" id="key-reusable" checked> Reusable</label>
<label><input type="checkbox" id="key-ephemeral"> Ephemeral</label>
<input type="number" id="key-expiry" value="24" min="1" style="width:60px"> hours
<button class="btn btn-primary" onclick="createKey()">+ Create Key</button>
<button class="btn" onclick="refreshKeys()">↻ Refresh</button>
</div>
</div>
<div class="card">
<table>
<thead><tr><th>Key</th><th>User</th><th>Reusable</th><th>Ephemeral</th><th>Used</th><th>Expiration</th><th>Created</th></tr></thead>
<tbody id="keys-table"></tbody>
</table>
</div>
</div>
<!-- Toast -->
<div id="toast" class="toast"></div>
</div>
<script src="static/app.js"></script>
</body>
</html>
+139
View File
@@ -0,0 +1,139 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
}
/* Login */
.login-screen {
display: flex; align-items: center; justify-content: center;
min-height: 100vh; background: #0f172a;
}
.login-box {
background: #1e293b; padding: 40px; border-radius: 12px;
text-align: center; min-width: 320px;
box-shadow: 0 25px 50px rgba(0,0,0,.5);
}
.login-box h1 { margin-bottom: 24px; font-size: 1.5rem; }
.login-box input {
width: 100%; padding: 10px 14px; margin-bottom: 12px;
background: #0f172a; border: 1px solid #334155; border-radius: 6px;
color: #e2e8f0; font-size: 14px;
}
.login-box button {
width: 100%; padding: 10px; background: #3b82f6; color: #fff;
border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;
}
.login-box button:hover { background: #2563eb; }
.error { color: #ef4444; margin-top: 8px; font-size: 13px; }
/* Nav */
nav {
display: flex; align-items: center; gap: 16px;
padding: 0 24px; height: 56px;
background: #1e293b; border-bottom: 1px solid #334155;
}
.nav-brand { font-weight: 700; font-size: 1.1rem; margin-right: 24px; }
.nav-tabs { display: flex; gap: 4px; flex: 1; }
.tab {
padding: 8px 16px; background: transparent; color: #94a3b8;
border: none; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500;
}
.tab:hover { background: #334155; color: #e2e8f0; }
.tab.active { background: #3b82f6; color: #fff; }
.btn-logout {
padding: 6px 12px; background: transparent; color: #94a3b8;
border: 1px solid #475569; border-radius: 6px; cursor: pointer; font-size: 12px;
}
.btn-logout:hover { background: #ef4444; color: #fff; border-color: #ef4444; }
/* Tab content */
.tab-content { display: none; padding: 24px; }
.tab-content.active { display: block; }
/* Stats */
.stats-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px; margin-bottom: 24px;
}
.stat-card {
background: #1e293b; padding: 24px; border-radius: 10px;
border-left: 4px solid #3b82f6;
}
.stat-card.stat-online { border-left-color: #22c55e; }
.stat-card.stat-offline { border-left-color: #ef4444; }
.stat-card.stat-users { border-left-color: #a855f7; }
.stat-number { font-size: 2rem; font-weight: 700; }
.stat-label { color: #94a3b8; font-size: 13px; margin-top: 4px; }
/* Card */
.card {
background: #1e293b; border-radius: 10px; padding: 20px;
overflow-x: auto;
}
/* Table */
table { width: 100%; border-collapse: collapse; }
th, td {
text-align: left; padding: 10px 12px; font-size: 13px;
border-bottom: 1px solid #334155;
}
th { color: #94a3b8; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
tr:hover { background: #263248; }
/* Toolbar */
.toolbar {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px; flex-wrap: wrap; gap: 8px;
}
.toolbar h2 { font-size: 1.2rem; }
.toolbar div { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.toolbar input, .toolbar select {
padding: 6px 10px; background: #0f172a; border: 1px solid #334155;
border-radius: 6px; color: #e2e8f0; font-size: 13px;
}
.toolbar label { font-size: 13px; color: #94a3b8; display: flex; align-items: center; gap: 4px; }
/* Buttons */
.btn {
padding: 6px 14px; border: 1px solid #475569; background: transparent;
color: #e2e8f0; border-radius: 6px; cursor: pointer; font-size: 13px;
}
.btn:hover { background: #334155; }
.btn-primary { background: #3b82f6; border-color: #3b82f6; color: #fff; }
.btn-primary:hover { background: #2563eb; }
.btn-danger { background: #ef4444; border-color: #ef4444; color: #fff; }
.btn-danger:hover { background: #dc2626; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
/* Status badge */
.badge {
display: inline-block; padding: 2px 8px; border-radius: 10px;
font-size: 11px; font-weight: 600;
}
.badge-online { background: #166534; color: #4ade80; }
.badge-offline { background: #7f1d1d; color: #fca5a5; }
.badge-expired { background: #78350f; color: #fcd34d; }
/* Toast */
.toast {
position: fixed; bottom: 24px; right: 24px;
background: #1e293b; border: 1px solid #334155; border-radius: 8px;
padding: 12px 20px; font-size: 13px; opacity: 0;
transition: opacity .3s; pointer-events: none;
z-index: 1000; max-width: 400px;
}
.toast.show { opacity: 1; pointer-events: auto; }
.toast.error { border-color: #ef4444; }
.toast.success { border-color: #22c55e; }
/* Key display */
.key-text {
font-family: monospace; font-size: 11px; background: #0f172a;
padding: 2px 6px; border-radius: 4px; word-break: break-all;
cursor: pointer;
}
.key-text:hover { background: #334155; }