870 lines
27 KiB
Go
870 lines
27 KiB
Go
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>
|
||
`
|