Claude Code session 1.
This commit is contained in:
869
manager/handlers.go
Normal file
869
manager/handlers.go
Normal file
@@ -0,0 +1,869 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
workerStore *WorkerStore
|
||||
healthPoller *HealthPoller
|
||||
apiKeyStore *APIKeyStore
|
||||
proxyManager *ProxyManager
|
||||
)
|
||||
|
||||
// ServiceDiscoveryInfo matches the service-info response from workers
|
||||
type ServiceDiscoveryInfo struct {
|
||||
ServiceType string `json:"service_type"`
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
||||
// detectWorkerType tries to auto-detect worker type by calling /service-info
|
||||
func detectWorkerType(baseURL string) (WorkerType, string, error) {
|
||||
// Try both /service-info and /health/service-info (for services with separate health ports)
|
||||
endpoints := []string{"/service-info", "/health/service-info"}
|
||||
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, endpoint := range endpoints {
|
||||
url := baseURL + endpoint
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
var info ServiceDiscoveryInfo
|
||||
if err := json.Unmarshal(body, &info); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
// Map service_type to WorkerType
|
||||
var workerType WorkerType
|
||||
switch info.ServiceType {
|
||||
case "input":
|
||||
workerType = WorkerTypeInput
|
||||
case "ping":
|
||||
workerType = WorkerTypePing
|
||||
case "output":
|
||||
workerType = WorkerTypeOutput
|
||||
default:
|
||||
lastErr = fmt.Errorf("unknown service type: %s", info.ServiceType)
|
||||
continue
|
||||
}
|
||||
|
||||
// Generate name from service info if empty
|
||||
name := fmt.Sprintf("%s (%s)", info.Name, info.InstanceID)
|
||||
return workerType, name, nil
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return "", "", fmt.Errorf("auto-detection failed: %v", lastErr)
|
||||
}
|
||||
return "", "", fmt.Errorf("auto-detection failed: no endpoints responded")
|
||||
}
|
||||
|
||||
// Dashboard handler - shows all workers and their status
|
||||
func handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
workers := workerStore.List()
|
||||
dashStats := workerStore.GetDashboardStats()
|
||||
|
||||
data := struct {
|
||||
Workers []*WorkerInstance
|
||||
Stats map[string]interface{}
|
||||
}{
|
||||
Workers: workers,
|
||||
Stats: dashStats,
|
||||
}
|
||||
|
||||
tmpl := template.Must(template.New("dashboard").Parse(dashboardTemplate))
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
logger.Error("Failed to render dashboard: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// API: List all workers
|
||||
func handleAPIWorkersList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
workers := workerStore.List()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(workers)
|
||||
}
|
||||
|
||||
// API: Register a new worker
|
||||
func handleAPIWorkersRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var worker WorkerInstance
|
||||
if err := json.NewDecoder(r.Body).Decode(&worker); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if worker.URL == "" {
|
||||
http.Error(w, "Missing required field: url", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-detect worker type if not provided
|
||||
if worker.Type == "" {
|
||||
logger.Info("Auto-detecting worker type for %s", worker.URL)
|
||||
detectedType, suggestedName, err := detectWorkerType(worker.URL)
|
||||
if err != nil {
|
||||
logger.Warn("Auto-detection failed for %s: %v", worker.URL, err)
|
||||
http.Error(w, fmt.Sprintf("Auto-detection failed: %v. Please specify 'type' manually.", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
worker.Type = detectedType
|
||||
// Use suggested name if name is empty
|
||||
if worker.Name == "" {
|
||||
worker.Name = suggestedName
|
||||
}
|
||||
logger.Info("Auto-detected type: %s, name: %s", worker.Type, worker.Name)
|
||||
}
|
||||
|
||||
// Validate type
|
||||
if worker.Type != WorkerTypeInput && worker.Type != WorkerTypePing && worker.Type != WorkerTypeOutput {
|
||||
http.Error(w, "Invalid worker type. Must be: input, ping, or output", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate default name if still empty
|
||||
if worker.Name == "" {
|
||||
worker.Name = fmt.Sprintf("%s-worker-%d", worker.Type, time.Now().Unix())
|
||||
}
|
||||
|
||||
if err := workerStore.Add(&worker); err != nil {
|
||||
logger.Error("Failed to add worker: %v", err)
|
||||
http.Error(w, "Failed to add worker", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Registered new worker: %s (%s) at %s", worker.Name, worker.Type, worker.URL)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(worker)
|
||||
}
|
||||
|
||||
// API: Remove a worker
|
||||
func handleAPIWorkersRemove(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
id := r.URL.Query().Get("id")
|
||||
if id == "" {
|
||||
http.Error(w, "Missing id parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := workerStore.Remove(id); err != nil {
|
||||
logger.Error("Failed to remove worker: %v", err)
|
||||
http.Error(w, "Failed to remove worker", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Removed worker: %s", id)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "removed": id})
|
||||
}
|
||||
|
||||
// API: Get worker details
|
||||
func handleAPIWorkersGet(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
id := r.URL.Query().Get("id")
|
||||
if id == "" {
|
||||
http.Error(w, "Missing id parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
worker, ok := workerStore.Get(id)
|
||||
if !ok {
|
||||
http.Error(w, "Worker not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(worker)
|
||||
}
|
||||
|
||||
// ==================== GATEWAY HANDLERS ====================
|
||||
|
||||
// Gateway: Get next target IP (proxies to input service)
|
||||
func handleGatewayTarget(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := proxyManager.ProxyGetTarget(w, r); err != nil {
|
||||
logger.Error("Gateway proxy failed (target): %v", err)
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
}
|
||||
}
|
||||
|
||||
// Gateway: Submit ping/traceroute result (proxies to output service)
|
||||
func handleGatewayResult(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := proxyManager.ProxyPostResult(w, r); err != nil {
|
||||
logger.Error("Gateway proxy failed (result): %v", err)
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
}
|
||||
}
|
||||
|
||||
// Gateway: Get pool statistics
|
||||
func handleGatewayStats(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
stats := proxyManager.GetPoolStats()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(stats)
|
||||
}
|
||||
|
||||
// ==================== API KEY MANAGEMENT HANDLERS ====================
|
||||
|
||||
// API: Generate a new API key (admin only)
|
||||
func handleAPIKeyGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
WorkerType string `json:"worker_type"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" || req.WorkerType == "" {
|
||||
http.Error(w, "Missing required fields: name, worker_type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
key, err := apiKeyStore.Add(req.Name, req.WorkerType)
|
||||
if err != nil {
|
||||
logger.Error("Failed to generate API key: %v", err)
|
||||
http.Error(w, "Failed to generate API key", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Generated API key: %s (type: %s)", req.Name, req.WorkerType)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"key": key,
|
||||
"name": req.Name,
|
||||
"worker_type": req.WorkerType,
|
||||
"note": "⚠️ Save this key! It won't be shown again.",
|
||||
})
|
||||
}
|
||||
|
||||
// API: List all API keys (admin only)
|
||||
func handleAPIKeyList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
keys := apiKeyStore.List()
|
||||
|
||||
// Mask the actual keys for security (show only first/last 8 chars)
|
||||
type MaskedKey struct {
|
||||
KeyPreview string `json:"key_preview"`
|
||||
Name string `json:"name"`
|
||||
WorkerType string `json:"worker_type"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsedAt string `json:"last_used_at,omitempty"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
masked := make([]MaskedKey, len(keys))
|
||||
for i, key := range keys {
|
||||
preview := "****"
|
||||
if len(key.Key) >= 16 {
|
||||
preview = key.Key[:8] + "..." + key.Key[len(key.Key)-8:]
|
||||
}
|
||||
|
||||
lastUsed := ""
|
||||
if !key.LastUsedAt.IsZero() {
|
||||
lastUsed = key.LastUsedAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
masked[i] = MaskedKey{
|
||||
KeyPreview: preview,
|
||||
Name: key.Name,
|
||||
WorkerType: key.WorkerType,
|
||||
CreatedAt: key.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
LastUsedAt: lastUsed,
|
||||
RequestCount: key.RequestCount,
|
||||
Enabled: key.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(masked)
|
||||
}
|
||||
|
||||
// API: Revoke an API key (admin only)
|
||||
func handleAPIKeyRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
key := r.URL.Query().Get("key")
|
||||
if key == "" {
|
||||
http.Error(w, "Missing key parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := apiKeyStore.Revoke(key); err != nil {
|
||||
logger.Error("Failed to revoke API key: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Revoked API key: %s", key)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "revoked": key})
|
||||
}
|
||||
|
||||
const dashboardTemplate = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Ping Service Manager - Control Panel</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
header {
|
||||
margin-bottom: 40px;
|
||||
border-bottom: 2px solid #334155;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
margin-bottom: 10px;
|
||||
color: #60a5fa;
|
||||
}
|
||||
.subtitle {
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #1e293b;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #60a5fa;
|
||||
}
|
||||
.stat-value.healthy {
|
||||
color: #34d399;
|
||||
}
|
||||
.stat-value.unhealthy {
|
||||
color: #f87171;
|
||||
}
|
||||
.controls {
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #475569;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #334155;
|
||||
}
|
||||
.workers-section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
margin-bottom: 20px;
|
||||
color: #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.type-input { background: #7c3aed; color: white; }
|
||||
.type-ping { background: #0ea5e9; color: white; }
|
||||
.type-output { background: #f59e0b; color: white; }
|
||||
.workers-grid {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
}
|
||||
.worker-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.worker-card:hover {
|
||||
border-color: #475569;
|
||||
}
|
||||
.worker-card.unhealthy {
|
||||
border-left: 4px solid #f87171;
|
||||
}
|
||||
.worker-card.healthy {
|
||||
border-left: 4px solid #34d399;
|
||||
}
|
||||
.worker-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.worker-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.worker-url {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
font-family: 'Courier New', monospace;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.status-dot.healthy {
|
||||
background: #34d399;
|
||||
box-shadow: 0 0 8px #34d399;
|
||||
}
|
||||
.status-dot.unhealthy {
|
||||
background: #f87171;
|
||||
}
|
||||
.worker-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #334155;
|
||||
}
|
||||
.meta-item {
|
||||
font-size: 12px;
|
||||
}
|
||||
.meta-label {
|
||||
color: #94a3b8;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.meta-value {
|
||||
color: #e2e8f0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.error-msg {
|
||||
background: #7f1d1d;
|
||||
border: 1px solid #991b1b;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-top: 10px;
|
||||
color: #fca5a5;
|
||||
}
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
.modal-content {
|
||||
background: #1e293b;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #334155;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.form-input, .form-select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 4px;
|
||||
color: #e2e8f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.form-input:focus, .form-select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.refresh-info {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
text-align: right;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🌐 Ping Service Control Panel</h1>
|
||||
<div class="subtitle">Distributed Internet Network Mapping System</div>
|
||||
</header>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Workers</div>
|
||||
<div class="stat-value">{{.Stats.total_workers}}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Healthy</div>
|
||||
<div class="stat-value healthy">{{.Stats.healthy}}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Unhealthy</div>
|
||||
<div class="stat-value unhealthy">{{.Stats.unhealthy}}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Pings</div>
|
||||
<div class="stat-value">{{.Stats.total_pings}}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Results</div>
|
||||
<div class="stat-value">{{.Stats.total_results}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn" onclick="openAddModal()">➕ Add Worker</button>
|
||||
<button class="btn btn-secondary" onclick="location.reload()">🔄 Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="workers-section">
|
||||
<div class="section-title">
|
||||
📍 Registered Workers
|
||||
</div>
|
||||
<div class="workers-grid">
|
||||
{{range .Workers}}
|
||||
<div class="worker-card {{if .Healthy}}healthy{{else}}unhealthy{{end}}">
|
||||
<div class="worker-header">
|
||||
<div>
|
||||
<div class="worker-title">
|
||||
{{.Name}}
|
||||
<span class="type-badge type-{{.Type}}">{{.Type}}</span>
|
||||
</div>
|
||||
<div class="worker-url">{{.URL}}</div>
|
||||
{{if .Location}}<div class="worker-url">📍 {{.Location}}</div>{{end}}
|
||||
</div>
|
||||
<div class="status-indicator">
|
||||
<span class="status-dot {{if .Healthy}}healthy{{else}}unhealthy{{end}}"></span>
|
||||
{{if .Healthy}}Online{{else}}Offline{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .LastError}}
|
||||
<div class="error-msg">
|
||||
⚠️ {{.LastError}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="worker-meta">
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">Response Time</div>
|
||||
<div class="meta-value">{{.ResponseTime}}ms</div>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">Last Check</div>
|
||||
<div class="meta-value">{{.LastCheck.Format "15:04:05"}}</div>
|
||||
</div>
|
||||
{{if .Stats}}
|
||||
{{if index .Stats "total_consumers"}}
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">Consumers</div>
|
||||
<div class="meta-value">{{index .Stats "total_consumers"}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if index .Stats "total_pings"}}
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">Pings</div>
|
||||
<div class="meta-value">{{index .Stats "total_pings"}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if index .Stats "successful_pings"}}
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">Success</div>
|
||||
<div class="meta-value">{{index .Stats "successful_pings"}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if index .Stats "total_results"}}
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">Results</div>
|
||||
<div class="meta-value">{{index .Stats "total_results"}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if index .Stats "hops_discovered"}}
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">Hops Found</div>
|
||||
<div class="meta-value">{{index .Stats "hops_discovered"}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="worker-card">
|
||||
<div style="text-align: center; padding: 40px; color: #64748b;">
|
||||
No workers registered yet. Click "Add Worker" to get started.
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="refresh-info">
|
||||
Auto-refresh every 30 seconds • Health checks every 60 seconds
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Worker Modal -->
|
||||
<div id="addModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-title">Add New Worker</div>
|
||||
<form id="addWorkerForm">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Base URL *</label>
|
||||
<input type="text" class="form-input" id="workerURL" placeholder="http://10.0.0.5:8080" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Worker Name (optional - auto-generated if empty)</label>
|
||||
<input type="text" class="form-input" id="workerName" placeholder="e.g., Input Service EU-1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Worker Type (optional - auto-detected from service)</label>
|
||||
<select class="form-select" id="workerType">
|
||||
<option value="">Auto-detect from service...</option>
|
||||
<option value="input">Input Service (manual)</option>
|
||||
<option value="ping">Ping Service (manual)</option>
|
||||
<option value="output">Output Service (manual)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Location (optional)</label>
|
||||
<input type="text" class="form-input" id="workerLocation" placeholder="e.g., Helsinki, Finland">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Description (optional)</label>
|
||||
<input type="text" class="form-input" id="workerDescription" placeholder="e.g., Raspberry Pi 4, Home network">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeAddModal()">Cancel</button>
|
||||
<button type="submit" class="btn">Add Worker</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-refresh page every 30 seconds
|
||||
setTimeout(function() {
|
||||
location.reload();
|
||||
}, 30000);
|
||||
|
||||
function openAddModal() {
|
||||
document.getElementById('addModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeAddModal() {
|
||||
document.getElementById('addModal').classList.remove('active');
|
||||
document.getElementById('addWorkerForm').reset();
|
||||
}
|
||||
|
||||
document.getElementById('addWorkerForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const worker = {
|
||||
name: document.getElementById('workerName').value,
|
||||
type: document.getElementById('workerType').value,
|
||||
url: document.getElementById('workerURL').value,
|
||||
location: document.getElementById('workerLocation').value,
|
||||
description: document.getElementById('workerDescription').value
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/workers/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(worker)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
closeAddModal();
|
||||
location.reload();
|
||||
} else {
|
||||
const error = await response.text();
|
||||
alert('Failed to add worker: ' + error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to add worker: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on background click
|
||||
document.getElementById('addModal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'addModal') {
|
||||
closeAddModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
Reference in New Issue
Block a user