feat: complete agent monitoring - hook, UI, and backend filter
- Add event_type and framework filters to events query endpoint - Add /agents SPA route to web-ui server - Add Agents nav link and route in frontend - Add agents page CSS (timeline, VM pills, stats panel) - Build VM status strip, activity timeline, and real-time stats - Add agentmon hook for OpenClaw (HOOK.md + handler.ts) - Add docker-compose, Dockerfile, and supporting infra files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"agentmon/internal/monitor/openclaw"
|
||||
qnats "agentmon/internal/queue/nats"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Schema map[string]any `json:"schema"`
|
||||
Event map[string]any `json:"event"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
natsURL := envDefault("NATS_URL", "nats://nats:4222")
|
||||
natsTopic := envDefault("NATS_TOPIC", "agentmon.events.v1")
|
||||
registryPath := envDefault("OPENCLAW_REGISTRY", "/home/will/.claude/state/openclaw-instances.json")
|
||||
interval := envDefault("POLL_INTERVAL", "30s")
|
||||
|
||||
pub, err := qnats.NewPublisher(natsURL, natsTopic)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to NATS: %v", err)
|
||||
}
|
||||
defer pub.Close()
|
||||
|
||||
pollDuration, err := time.ParseDuration(interval)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid poll interval: %v", err)
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(pollDuration)
|
||||
defer ticker.Stop()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
log.Printf("openclaw-monitor started, polling every %s", pollDuration)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := pollInstances(ctx, pub, registryPath); err != nil {
|
||||
log.Printf("poll error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pollInstances(ctx context.Context, pub *qnats.Publisher, registryPath string) error {
|
||||
instances, err := openclaw.LoadInstances(registryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, instance := range instances {
|
||||
metrics := openclaw.Metrics{
|
||||
Instance: instance,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
hostMetrics, err := openclaw.CollectHostMetrics(instance.Domain)
|
||||
if err != nil {
|
||||
metrics.Error = err.Error()
|
||||
emitEvent(ctx, pub, instance.Name, metrics)
|
||||
continue
|
||||
}
|
||||
metrics.Host = hostMetrics
|
||||
|
||||
if hostMetrics.State == "running" && instance.Host != nil {
|
||||
guestMetrics, err := openclaw.CollectGuestMetrics(*instance.Host, instance.User)
|
||||
if err != nil {
|
||||
log.Printf("guest collection failed for %s: %v", instance.Name, err)
|
||||
} else {
|
||||
metrics.Guest = guestMetrics
|
||||
}
|
||||
}
|
||||
|
||||
backupStatus, err := openclaw.CollectBackupStatus(instance.Name)
|
||||
if err != nil {
|
||||
log.Printf("backup collection failed for %s: %v", instance.Name, err)
|
||||
} else {
|
||||
metrics.Backup = backupStatus
|
||||
}
|
||||
|
||||
issues := openclaw.DetectIssues(metrics)
|
||||
if anyIssues(issues) {
|
||||
log.Printf("issues detected for %s: %+v", instance.Name, issues)
|
||||
}
|
||||
|
||||
emitEvent(ctx, pub, instance.Name, metrics)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func emitEvent(ctx context.Context, pub *qnats.Publisher, instanceName string, metrics openclaw.Metrics) {
|
||||
event := Event{
|
||||
Schema: map[string]any{
|
||||
"name": "agentmon.openclaw",
|
||||
"version": 1,
|
||||
},
|
||||
Event: map[string]any{
|
||||
"id": generateID(),
|
||||
"type": "openclaw.snapshot",
|
||||
"ts": metrics.Timestamp.UTC().Format(time.RFC3339Nano),
|
||||
},
|
||||
Payload: map[string]any{
|
||||
"instance": metrics.Instance,
|
||||
"host": metrics.Host,
|
||||
},
|
||||
}
|
||||
|
||||
if metrics.Guest != nil {
|
||||
event.Payload["guest"] = metrics.Guest
|
||||
}
|
||||
if metrics.Backup != nil {
|
||||
event.Payload["backup"] = metrics.Backup
|
||||
}
|
||||
if metrics.Error != "" {
|
||||
event.Payload["error"] = metrics.Error
|
||||
}
|
||||
|
||||
issues := openclaw.DetectIssues(metrics)
|
||||
if anyIssues(issues) {
|
||||
event.Payload["issues"] = issues
|
||||
}
|
||||
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
log.Printf("failed to marshal event for %s: %v", instanceName, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := pub.Publish(ctx, data); err != nil {
|
||||
log.Printf("failed to publish event for %s: %v", instanceName, err)
|
||||
}
|
||||
}
|
||||
|
||||
func anyIssues(issues openclaw.Issues) bool {
|
||||
return issues.GuestDiskUsageHigh ||
|
||||
issues.GuestMemoryUsageHigh ||
|
||||
issues.HostDiskUsageHigh ||
|
||||
issues.GatewayDown ||
|
||||
issues.HTTPUnhealthy ||
|
||||
issues.VersionMismatch ||
|
||||
issues.VMNotRunning ||
|
||||
issues.BackupStale
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
return time.Now().Format("20060102150405") + "-" + randomString(8)
|
||||
}
|
||||
|
||||
func randomString(n int) string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = chars[time.Now().Nanosecond()%len(chars)]
|
||||
time.Sleep(time.Nanosecond)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func envDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
+86
-1
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"agentmon/internal/httpx"
|
||||
@@ -13,11 +14,80 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
var (
|
||||
wsUpgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
wsClients = make(map[*websocket.Conn]bool)
|
||||
wsMu sync.RWMutex
|
||||
natsConn *nats.Conn
|
||||
)
|
||||
|
||||
func subscribeToNATS(nc *nats.Conn) {
|
||||
topic := envDefault("NATS_TOPIC", "agentmon.events.v1")
|
||||
sub, err := nc.Subscribe(topic, func(msg *nats.Msg) {
|
||||
wsMu.RLock()
|
||||
var stale []*websocket.Conn
|
||||
for conn := range wsClients {
|
||||
err := conn.WriteMessage(websocket.TextMessage, msg.Data)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
stale = append(stale, conn)
|
||||
}
|
||||
}
|
||||
wsMu.RUnlock()
|
||||
|
||||
if len(stale) > 0 {
|
||||
wsMu.Lock()
|
||||
for _, conn := range stale {
|
||||
delete(wsClients, conn)
|
||||
}
|
||||
wsMu.Unlock()
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("failed to subscribe to NATS: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("subscribed to NATS topic: %s", topic)
|
||||
_ = sub
|
||||
}
|
||||
|
||||
func wsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
wsMu.Lock()
|
||||
wsClients[conn] = true
|
||||
wsMu.Unlock()
|
||||
|
||||
log.Printf("WebSocket client connected")
|
||||
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
wsMu.Lock()
|
||||
delete(wsClients, conn)
|
||||
wsMu.Unlock()
|
||||
log.Printf("WebSocket client disconnected")
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := envDefault("AGENTMON_QUERY_ADDR", ":8081")
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
natsURL := envDefault("NATS_URL", "nats://localhost:4222")
|
||||
|
||||
if dsn == "" {
|
||||
log.Fatalf("DATABASE_URL is required")
|
||||
}
|
||||
@@ -28,6 +98,14 @@ func main() {
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
nc, err := nats.Connect(natsURL)
|
||||
if err != nil {
|
||||
log.Printf("warning: failed to connect to NATS: %v", err)
|
||||
} else {
|
||||
natsConn = nc
|
||||
go subscribeToNATS(nc)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
@@ -39,9 +117,16 @@ func main() {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
r.Get("/v1/ws", wsHandler)
|
||||
|
||||
r.Get("/v1/events", func(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
events, err := db.ListRecentEvents(r.Context(), limit)
|
||||
f := postgres.EventsFilter{
|
||||
Limit: limit,
|
||||
EventType: r.URL.Query().Get("event_type"),
|
||||
Framework: r.URL.Query().Get("framework"),
|
||||
}
|
||||
events, err := db.ListRecentEvents(r.Context(), f)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "db_error"})
|
||||
return
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ func main() {
|
||||
// SPA catch-all: serve index.html for all other routes
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Serve index.html for SPA routes
|
||||
if r.URL.Path == "/" || strings.HasPrefix(r.URL.Path, "/sessions") || strings.HasPrefix(r.URL.Path, "/runs") {
|
||||
if r.URL.Path == "/" || strings.HasPrefix(r.URL.Path, "/sessions") || strings.HasPrefix(r.URL.Path, "/runs") || strings.HasPrefix(r.URL.Path, "/openclaw") || strings.HasPrefix(r.URL.Path, "/agents") {
|
||||
f, err := staticFiles.Open("static/index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "index.html not found", http.StatusInternalServerError)
|
||||
|
||||
+744
-105
@@ -1,18 +1,91 @@
|
||||
(function() {
|
||||
const app = document.getElementById('app');
|
||||
|
||||
// Router
|
||||
function route() {
|
||||
const path = window.location.pathname;
|
||||
let ws = null;
|
||||
let wsReconnectTimeout = null;
|
||||
const wsCallbacks = new Set();
|
||||
|
||||
let sessionsState = { sessions: [], cursor: null };
|
||||
let openclawState = { instances: {} };
|
||||
let openclawUnsubscribe = null;
|
||||
let agentsState = createAgentsState();
|
||||
let agentsUnsubscribe = null;
|
||||
|
||||
function getWsURL() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return protocol + '//' + window.location.host + '/api/v1/ws';
|
||||
}
|
||||
|
||||
function connectWS() {
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ws = new WebSocket(getWsURL());
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
wsCallbacks.forEach(cb => cb({ type: 'connected' }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
wsCallbacks.forEach(cb => cb({ type: 'message', data }));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse WS message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
wsCallbacks.forEach(cb => cb({ type: 'disconnected' }));
|
||||
wsReconnectTimeout = setTimeout(connectWS, 5000);
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error('WebSocket error:', err);
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Failed to connect WebSocket:', e);
|
||||
wsReconnectTimeout = setTimeout(connectWS, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeWS(callback) {
|
||||
wsCallbacks.add(callback);
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
connectWS();
|
||||
}
|
||||
return () => wsCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
function cleanupLiveViews() {
|
||||
if (openclawUnsubscribe) {
|
||||
openclawUnsubscribe();
|
||||
openclawUnsubscribe = null;
|
||||
}
|
||||
if (agentsUnsubscribe) {
|
||||
agentsUnsubscribe();
|
||||
agentsUnsubscribe = null;
|
||||
}
|
||||
}
|
||||
|
||||
function route() {
|
||||
cleanupLiveViews();
|
||||
|
||||
const path = window.location.pathname;
|
||||
if (path === '/' || path === '/sessions') {
|
||||
renderSessions();
|
||||
} else if (path.startsWith('/agents')) {
|
||||
renderAgents();
|
||||
} else if (path.startsWith('/openclaw')) {
|
||||
renderOpenClaw();
|
||||
} else if (path.startsWith('/sessions/')) {
|
||||
const sessionID = path.split('/sessions/')[1];
|
||||
renderSession(sessionID);
|
||||
renderSession(path.split('/sessions/')[1]);
|
||||
} else if (path.startsWith('/runs/')) {
|
||||
const runID = path.split('/runs/')[1];
|
||||
renderRun(runID);
|
||||
renderRun(path.split('/runs/')[1]);
|
||||
} else {
|
||||
app.innerHTML = '<p>Page not found</p>';
|
||||
}
|
||||
@@ -25,14 +98,28 @@
|
||||
|
||||
window.addEventListener('popstate', route);
|
||||
|
||||
// API helpers
|
||||
async function api(path) {
|
||||
const resp = await fetch('/api' + path);
|
||||
if (!resp.ok) throw new Error('API error');
|
||||
if (!resp.ok) {
|
||||
throw new Error('API error');
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function relativeTime(ts) {
|
||||
if (!ts) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const then = new Date(ts).getTime();
|
||||
const diff = now - then;
|
||||
@@ -44,23 +131,82 @@
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (!ms) return '-';
|
||||
if (ms === undefined || ms === null || ms === '') return '-';
|
||||
if (ms < 1000) return ms + 'ms';
|
||||
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
|
||||
return (ms / 60000).toFixed(1) + 'm';
|
||||
}
|
||||
|
||||
function statusIcon(status) {
|
||||
if (status === 'success') return '<span class="status-success">✓</span>';
|
||||
if (status === 'error') return '<span class="status-error">✗</span>';
|
||||
return '<span class="status-unknown">●</span>';
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return null;
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let unitIndex = 0;
|
||||
let value = bytes;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return value.toFixed(1) + ' ' + units[unitIndex];
|
||||
}
|
||||
|
||||
// Sessions list
|
||||
let sessionsState = { sessions: [], cursor: null, filters: {} };
|
||||
function statusIcon(status) {
|
||||
if (status === 'success') return '<span class="status-badge status-success"><span class="status-dot"></span>success</span>';
|
||||
if (status === 'error') return '<span class="status-badge status-error"><span class="status-dot"></span>error</span>';
|
||||
return '<span class="status-badge status-unknown"><span class="status-dot"></span>unknown</span>';
|
||||
}
|
||||
|
||||
function extractEnvelope(record) {
|
||||
if (record && typeof record === 'object' && record.payload && record.payload.event && record.payload.schema) {
|
||||
return record.payload;
|
||||
}
|
||||
return record || {};
|
||||
}
|
||||
|
||||
function getEnvelopeEvent(record) {
|
||||
const envelope = extractEnvelope(record);
|
||||
return envelope.event || envelope.Event || {};
|
||||
}
|
||||
|
||||
function getEnvelopeType(record) {
|
||||
return record?.type || getEnvelopeEvent(record).type || '';
|
||||
}
|
||||
|
||||
function getEnvelopeTS(record) {
|
||||
return record?.ts || getEnvelopeEvent(record).ts || '';
|
||||
}
|
||||
|
||||
function getEnvelopeSource(record) {
|
||||
return getEnvelopeEvent(record).source || {};
|
||||
}
|
||||
|
||||
function getEnvelopePayload(record) {
|
||||
const envelope = extractEnvelope(record);
|
||||
return envelope.payload || envelope.Payload || {};
|
||||
}
|
||||
|
||||
function getEnvelopeAttributes(record) {
|
||||
const envelope = extractEnvelope(record);
|
||||
return envelope.attributes || envelope.Attributes || {};
|
||||
}
|
||||
|
||||
function getEnvelopeCorrelation(record) {
|
||||
const envelope = extractEnvelope(record);
|
||||
return envelope.correlation || envelope.Correlation || {};
|
||||
}
|
||||
|
||||
function getRecordID(record) {
|
||||
return record?.event_id || getEnvelopeEvent(record).id || '';
|
||||
}
|
||||
|
||||
function isCurrentPath(prefix) {
|
||||
return window.location.pathname.startsWith(prefix);
|
||||
}
|
||||
|
||||
async function renderSessions() {
|
||||
app.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h2>Sessions</h2>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<label>From <input type="date" id="filter-from"></label>
|
||||
<label>To <input type="date" id="filter-to"></label>
|
||||
@@ -69,26 +215,28 @@
|
||||
<option value="">All</option>
|
||||
<option value="claude-code">claude-code</option>
|
||||
<option value="opencode">opencode</option>
|
||||
<option value="openclaw">openclaw</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Host <input type="text" id="filter-host" placeholder="hostname"></label>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Session</th>
|
||||
<th>Framework</th>
|
||||
<th>Host</th>
|
||||
<th>Runs</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sessions-body"></tbody>
|
||||
</table>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Session</th>
|
||||
<th>Framework</th>
|
||||
<th>Host</th>
|
||||
<th>Runs</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sessions-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button id="load-more" class="load-more" style="display:none">Load more</button>
|
||||
`;
|
||||
|
||||
// Bind filter events
|
||||
['from', 'to', 'framework', 'host'].forEach(f => {
|
||||
document.getElementById('filter-' + f).addEventListener('change', () => {
|
||||
sessionsState.sessions = [];
|
||||
@@ -122,12 +270,12 @@
|
||||
|
||||
const tbody = document.getElementById('sessions-body');
|
||||
tbody.innerHTML = sessionsState.sessions.map(s => `
|
||||
<tr class="clickable" data-session="${s.session_id}">
|
||||
<td>${s.session_id.substring(0, 12)}...</td>
|
||||
<td>${s.framework || '-'}</td>
|
||||
<td>${s.host || '-'}</td>
|
||||
<tr class="clickable" data-session="${escapeHTML(s.session_id)}">
|
||||
<td class="id-cell">${escapeHTML(s.session_id.substring(0, 12))}...</td>
|
||||
<td>${escapeHTML(s.framework || '-')}</td>
|
||||
<td>${escapeHTML(s.host || '-')}</td>
|
||||
<td>${s.run_count}</td>
|
||||
<td title="${s.started_at}">${relativeTime(s.started_at)}</td>
|
||||
<td title="${escapeHTML(s.started_at)}">${escapeHTML(relativeTime(s.started_at))}</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="5" class="empty-state">No sessions found</td></tr>';
|
||||
|
||||
@@ -138,12 +286,10 @@
|
||||
document.getElementById('load-more').style.display = sessionsState.cursor ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Session detail
|
||||
async function renderSession(sessionID) {
|
||||
const data = await api('/v1/sessions/' + sessionID);
|
||||
const s = data.session;
|
||||
const runs = data.runs || [];
|
||||
|
||||
const duration = s.ended_at
|
||||
? formatDuration(new Date(s.ended_at) - new Date(s.started_at))
|
||||
: 'ongoing';
|
||||
@@ -151,42 +297,44 @@
|
||||
app.innerHTML = `
|
||||
<a href="/sessions" class="back-link">← Back to Sessions</a>
|
||||
<div class="page-header">
|
||||
<h2>Session ${sessionID.substring(0, 16)}...</h2>
|
||||
<p class="meta">
|
||||
Started: ${new Date(s.started_at).toLocaleString()} •
|
||||
Framework: ${s.framework || '-'} •
|
||||
Host: ${s.host || '-'} •
|
||||
Duration: ${duration}
|
||||
</p>
|
||||
<h2>Session <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)">${escapeHTML(sessionID.substring(0, 16))}...</span></h2>
|
||||
<div class="meta">
|
||||
<span class="meta-item"><span class="meta-label">Started</span> ${escapeHTML(new Date(s.started_at).toLocaleString())}</span>
|
||||
<span class="meta-item"><span class="meta-label">Framework</span> ${escapeHTML(s.framework || '-')}</span>
|
||||
<span class="meta-item"><span class="meta-label">Host</span> ${escapeHTML(s.host || '-')}</span>
|
||||
<span class="meta-item"><span class="meta-label">Duration</span> ${escapeHTML(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-title">Runs <span class="count">${runs.length}</span></div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run ID</th>
|
||||
<th>Status</th>
|
||||
<th>Spans</th>
|
||||
<th>Duration</th>
|
||||
<th>Started</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${runs.map(r => {
|
||||
const runDuration = r.ended_at
|
||||
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
||||
: '-';
|
||||
return `
|
||||
<tr class="clickable" data-run="${escapeHTML(r.run_id)}">
|
||||
<td class="id-cell">${escapeHTML(r.run_id.substring(0, 12))}...</td>
|
||||
<td>${statusIcon(r.status)}</td>
|
||||
<td>${r.span_count}</td>
|
||||
<td>${escapeHTML(runDuration)}</td>
|
||||
<td>${escapeHTML(new Date(r.started_at).toLocaleTimeString())}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('') || '<tr><td colspan="5" class="empty-state">No runs</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<h3>Runs (${runs.length})</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run ID</th>
|
||||
<th>Status</th>
|
||||
<th>Spans</th>
|
||||
<th>Duration</th>
|
||||
<th>Started</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${runs.map(r => {
|
||||
const dur = r.ended_at
|
||||
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
||||
: '-';
|
||||
return `
|
||||
<tr class="clickable" data-run="${r.run_id}">
|
||||
<td>${r.run_id.substring(0, 12)}...</td>
|
||||
<td>${statusIcon(r.status)} ${r.status}</td>
|
||||
<td>${r.span_count}</td>
|
||||
<td>${dur}</td>
|
||||
<td>${new Date(r.started_at).toLocaleTimeString()}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('') || '<tr><td colspan="5" class="empty-state">No runs</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
document.querySelectorAll('tr.clickable').forEach(row => {
|
||||
@@ -199,51 +347,51 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Run detail
|
||||
async function renderRun(runID) {
|
||||
const data = await api('/v1/runs/' + runID);
|
||||
const r = data.run;
|
||||
const spans = data.spans || [];
|
||||
|
||||
const duration = r.ended_at
|
||||
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
||||
: 'ongoing';
|
||||
|
||||
app.innerHTML = `
|
||||
<a href="/sessions/${r.session_id}" class="back-link">← Back to Session</a>
|
||||
<a href="/sessions/${escapeHTML(r.session_id)}" class="back-link">← Back to Session</a>
|
||||
<div class="page-header">
|
||||
<h2>Run ${runID.substring(0, 16)}... ${statusIcon(r.status)}</h2>
|
||||
<p class="meta">
|
||||
Started: ${new Date(r.started_at).toLocaleString()} •
|
||||
Duration: ${duration}
|
||||
</p>
|
||||
<h2>Run <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)">${escapeHTML(runID.substring(0, 16))}...</span> ${statusIcon(r.status)}</h2>
|
||||
<div class="meta">
|
||||
<span class="meta-item"><span class="meta-label">Started</span> ${escapeHTML(new Date(r.started_at).toLocaleString())}</span>
|
||||
<span class="meta-item"><span class="meta-label">Duration</span> ${escapeHTML(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3>Spans (${spans.length})</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Kind</th>
|
||||
<th>Status</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="spans-body">
|
||||
${spans.map((sp, i) => `
|
||||
<tr class="expandable" data-index="${i}">
|
||||
<td><span class="expand-icon">▶</span>${sp.name}</td>
|
||||
<td>${sp.kind}</td>
|
||||
<td>${statusIcon(sp.status)}</td>
|
||||
<td>${formatDuration(sp.duration_ms)}</td>
|
||||
<div class="section-title">Spans <span class="count">${spans.length}</span></div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Kind</th>
|
||||
<th>Status</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
<tr class="span-detail-row" data-index="${i}" style="display:none">
|
||||
<td colspan="4">
|
||||
<div class="span-details">${JSON.stringify(sp.payload, null, 2)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="4" class="empty-state">No spans</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody id="spans-body">
|
||||
${spans.map((sp, i) => `
|
||||
<tr class="expandable" data-index="${i}">
|
||||
<td><span class="expand-icon">▶</span>${escapeHTML(sp.name)}</td>
|
||||
<td>${escapeHTML(sp.kind)}</td>
|
||||
<td>${statusIcon(sp.status)}</td>
|
||||
<td>${escapeHTML(formatDuration(sp.duration_ms))}</td>
|
||||
</tr>
|
||||
<tr class="span-detail-row" data-index="${i}" style="display:none">
|
||||
<td colspan="4">
|
||||
<div class="span-details">${escapeHTML(JSON.stringify(sp.payload, null, 2))}</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="4" class="empty-state">No spans</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.querySelectorAll('tr.expandable').forEach(row => {
|
||||
@@ -267,6 +415,497 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Start
|
||||
async function renderOpenClaw() {
|
||||
app.innerHTML = '<div class="page-header"><h2>OpenClaw</h2></div><p class="empty-state">Loading...</p>';
|
||||
|
||||
openclawUnsubscribe = subscribeWS(handleOpenClawWS);
|
||||
|
||||
try {
|
||||
const data = await api('/v1/events?event_type=openclaw.snapshot&limit=100');
|
||||
mergeOpenClawEvents(data.events || []);
|
||||
if (isCurrentPath('/openclaw')) {
|
||||
renderOpenClawGrid();
|
||||
}
|
||||
} catch (e) {
|
||||
if (isCurrentPath('/openclaw')) {
|
||||
app.innerHTML = `<div class="page-header"><h2>OpenClaw</h2></div><p class="empty-state">Error loading: ${escapeHTML(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenClawWS(msg) {
|
||||
if (msg.type !== 'message') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getEnvelopeType(msg.data) !== 'openclaw.snapshot') {
|
||||
return;
|
||||
}
|
||||
|
||||
mergeOpenClawEvents([msg.data]);
|
||||
|
||||
if (isCurrentPath('/openclaw')) {
|
||||
renderOpenClawGrid();
|
||||
}
|
||||
if (isCurrentPath('/agents')) {
|
||||
renderAgentVMStrip();
|
||||
}
|
||||
}
|
||||
|
||||
function mergeOpenClawEvents(events) {
|
||||
for (const evt of events) {
|
||||
const payload = getEnvelopePayload(evt);
|
||||
const instance = payload.instance || {};
|
||||
if (!instance.name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = openclawState.instances[instance.name];
|
||||
const nextTS = new Date(getEnvelopeTS(evt) || 0).getTime();
|
||||
const currentTS = existing ? new Date(getEnvelopeTS(existing) || 0).getTime() : 0;
|
||||
if (!existing || nextTS >= currentTS) {
|
||||
openclawState.instances[instance.name] = evt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderOpenClawGrid() {
|
||||
const names = Object.keys(openclawState.instances).sort();
|
||||
|
||||
if (names.length === 0) {
|
||||
app.innerHTML = `
|
||||
<div class="page-header"><h2>OpenClaw</h2></div>
|
||||
<p class="empty-state">No OpenClaw instances found</p>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h2>OpenClaw <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
|
||||
</div>
|
||||
<div class="vm-grid">
|
||||
${names.map(name => {
|
||||
const evt = openclawState.instances[name];
|
||||
const payload = getEnvelopePayload(evt);
|
||||
const inst = payload.instance || {};
|
||||
const host = payload.host || {};
|
||||
const guest = payload.guest;
|
||||
const issues = payload.issues;
|
||||
|
||||
return `
|
||||
<div class="vm-card">
|
||||
<div class="vm-card-header">
|
||||
<h3>${escapeHTML(inst.name || name)}</h3>
|
||||
<div class="vm-status ${host.state === 'running' ? 'running' : 'stopped'}">
|
||||
${host.state === 'running' ? 'Running' : 'Stopped'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="vm-updated">Updated ${escapeHTML(relativeTime(getEnvelopeTS(evt)))}</div>
|
||||
<table class="vm-stats">
|
||||
<tr><td>Host</td><td>${escapeHTML(inst.host || '-')}</td></tr>
|
||||
<tr><td>Domain</td><td>${escapeHTML(inst.domain || '-')}</td></tr>
|
||||
<tr><td>vCPUs</td><td>${host.vcpus || '-'}</td></tr>
|
||||
<tr><td>Memory</td><td>${escapeHTML(formatBytes(host.memory_kib ? host.memory_kib * 1024 : 0) || '-')}</td></tr>
|
||||
<tr><td>Disk</td><td>${escapeHTML(formatBytes(host.disk_actual_bytes) || '-')}</td></tr>
|
||||
<tr><td>Autostart</td><td>${host.autostart ? 'Yes' : 'No'}</td></tr>
|
||||
${guest ? `
|
||||
<tr><td>Gateway</td><td class="${guest.service_active ? 'status-success' : 'status-error'}">${guest.service_active ? 'Active' : 'Inactive'}</td></tr>
|
||||
<tr><td>HTTP</td><td class="${guest.http_status === 200 ? 'status-success' : 'status-error'}">${guest.http_status || 'N/A'}</td></tr>
|
||||
<tr><td>Version</td><td>${escapeHTML(guest.version || '-')}</td></tr>
|
||||
<tr><td>Guest Memory</td><td>${guest.memory_percent !== undefined ? guest.memory_percent.toFixed(1) : '-'}%</td></tr>
|
||||
<tr><td>Guest Disk</td><td>${guest.disk_percent !== undefined ? guest.disk_percent.toFixed(1) : '-'}%</td></tr>
|
||||
<tr><td>Load</td><td>${guest.load_average !== undefined ? guest.load_average.toFixed(2) : '-'}</td></tr>
|
||||
<tr><td>Uptime</td><td>${escapeHTML(guest.service_uptime || '-')}</td></tr>
|
||||
` : ''}
|
||||
</table>
|
||||
${issues ? `
|
||||
<div class="vm-issues">
|
||||
${Object.entries(issues).filter(([, value]) => value).map(([key]) => `
|
||||
<span class="issue ${escapeHTML(key)}">${escapeHTML(key.replace(/_/g, ' '))}</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function createAgentsState() {
|
||||
return {
|
||||
events: [],
|
||||
eventIDs: new Set(),
|
||||
stats: {
|
||||
messages: 0,
|
||||
tools: 0,
|
||||
errors: 0,
|
||||
toolCounts: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getVMStatus() {
|
||||
const names = ['zap', 'orb', 'sun'];
|
||||
return names.map(name => {
|
||||
const snapshot = openclawState.instances[name];
|
||||
const payload = snapshot ? getEnvelopePayload(snapshot) : {};
|
||||
const host = payload.host || {};
|
||||
return {
|
||||
name,
|
||||
active: host.state === 'running',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function renderAgents() {
|
||||
agentsState = createAgentsState();
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h2>Agents <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
|
||||
</div>
|
||||
<div class="vm-strip" id="agents-vm-strip"></div>
|
||||
<div class="agents-layout">
|
||||
<div class="timeline" id="agents-timeline">
|
||||
<p class="empty-state">Loading agent activity...</p>
|
||||
</div>
|
||||
<div class="stats-panel">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-title">Messages</div>
|
||||
<div class="stat-card-value" id="stat-messages">0</div>
|
||||
<div class="stat-card-sub">received and sent</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-title">Tool Calls</div>
|
||||
<div class="stat-card-value" id="stat-tools">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-title">Errors</div>
|
||||
<div class="stat-card-value" id="stat-errors">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-title">Top Tools</div>
|
||||
<ul class="stat-list" id="stat-top-tools">
|
||||
<li style="color:var(--text-dim);font-size:0.8rem">No data yet</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
renderAgentVMStrip();
|
||||
|
||||
try {
|
||||
const [snapshots, events] = await Promise.all([
|
||||
api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })),
|
||||
api('/v1/events?framework=openclaw&limit=200'),
|
||||
]);
|
||||
|
||||
if (!isCurrentPath('/agents')) {
|
||||
return;
|
||||
}
|
||||
|
||||
mergeOpenClawEvents(snapshots.events || []);
|
||||
renderAgentVMStrip();
|
||||
addAgentEvents((events.events || []).slice().reverse());
|
||||
renderAgentTimeline();
|
||||
renderAgentStats();
|
||||
} catch (e) {
|
||||
const timeline = document.getElementById('agents-timeline');
|
||||
if (timeline) {
|
||||
timeline.innerHTML = `<p class="empty-state">Error loading agent activity: ${escapeHTML(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
agentsUnsubscribe = subscribeWS(handleAgentsWS);
|
||||
}
|
||||
|
||||
function renderAgentVMStrip() {
|
||||
const strip = document.getElementById('agents-vm-strip');
|
||||
if (!strip) {
|
||||
return;
|
||||
}
|
||||
|
||||
const vms = getVMStatus();
|
||||
strip.innerHTML = vms.map(vm => `
|
||||
<div class="vm-pill ${vm.active ? 'active' : 'inactive'}">
|
||||
<span class="vm-pill-dot"></span>
|
||||
<span class="vm-pill-name">${escapeHTML(vm.name)}</span>
|
||||
<span class="vm-pill-label">${vm.active ? 'online' : 'offline'}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function handleAgentsWS(msg) {
|
||||
if (msg.type !== 'message') {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventType = getEnvelopeType(msg.data);
|
||||
if (eventType === 'openclaw.snapshot') {
|
||||
mergeOpenClawEvents([msg.data]);
|
||||
renderAgentVMStrip();
|
||||
return;
|
||||
}
|
||||
|
||||
const framework = getEnvelopeSource(msg.data).framework || msg.data.source_framework;
|
||||
if (framework !== 'openclaw') {
|
||||
return;
|
||||
}
|
||||
|
||||
addAgentEvents([msg.data]);
|
||||
renderAgentTimeline();
|
||||
renderAgentStats();
|
||||
}
|
||||
|
||||
function addAgentEvents(events) {
|
||||
let changed = false;
|
||||
|
||||
for (const evt of events) {
|
||||
const id = getRecordID(evt);
|
||||
if (!id || agentsState.eventIDs.has(id)) {
|
||||
continue;
|
||||
}
|
||||
agentsState.eventIDs.add(id);
|
||||
agentsState.events.push(evt);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
agentsState.events.sort((a, b) => new Date(getEnvelopeTS(a)).getTime() - new Date(getEnvelopeTS(b)).getTime());
|
||||
|
||||
while (agentsState.events.length > 500) {
|
||||
const removed = agentsState.events.shift();
|
||||
agentsState.eventIDs.delete(getRecordID(removed));
|
||||
}
|
||||
|
||||
recomputeAgentStats();
|
||||
}
|
||||
|
||||
function recomputeAgentStats() {
|
||||
const stats = {
|
||||
messages: 0,
|
||||
tools: 0,
|
||||
errors: 0,
|
||||
toolCounts: {},
|
||||
};
|
||||
|
||||
for (const evt of agentsState.events) {
|
||||
const eventType = getEnvelopeType(evt);
|
||||
const attrs = getEnvelopeAttributes(evt);
|
||||
|
||||
if (eventType === 'run.start' || eventType === 'run.end') {
|
||||
stats.messages++;
|
||||
}
|
||||
|
||||
if (eventType === 'span.end' && attrs.span_kind === 'tool') {
|
||||
stats.tools++;
|
||||
const toolName = attrs.name || 'unknown';
|
||||
stats.toolCounts[toolName] = (stats.toolCounts[toolName] || 0) + 1;
|
||||
}
|
||||
|
||||
if (eventType === 'error') {
|
||||
stats.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
agentsState.stats = stats;
|
||||
}
|
||||
|
||||
function getEventIcon(eventType) {
|
||||
switch (eventType) {
|
||||
case 'run.start':
|
||||
return '<div class="event-icon message-in">↓</div>';
|
||||
case 'run.end':
|
||||
return '<div class="event-icon message-out">↑</div>';
|
||||
case 'span.start':
|
||||
case 'span.end':
|
||||
return '<div class="event-icon tool">⚙</div>';
|
||||
case 'error':
|
||||
return '<div class="event-icon error">!</div>';
|
||||
case 'session.start':
|
||||
case 'session.end':
|
||||
return '<div class="event-icon session">○</div>';
|
||||
default:
|
||||
return '<div class="event-icon internal">·</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function getEventLabel(eventType) {
|
||||
const labels = {
|
||||
'session.start': 'Session Started',
|
||||
'session.end': 'Session Ended',
|
||||
'run.start': 'Message Received',
|
||||
'run.end': 'Response Sent',
|
||||
'span.start': 'Span Started',
|
||||
'span.end': 'Span Completed',
|
||||
'error': 'Error',
|
||||
'metric.snapshot': 'Metric',
|
||||
};
|
||||
return labels[eventType] || eventType;
|
||||
}
|
||||
|
||||
function getVMName(evt) {
|
||||
return getEnvelopeSource(evt).client_id || evt.client_id || 'unknown';
|
||||
}
|
||||
|
||||
function getVMClassName(vmName) {
|
||||
const normalized = String(vmName || 'unknown').toLowerCase();
|
||||
return ['zap', 'orb', 'sun'].includes(normalized) ? normalized : 'unknown';
|
||||
}
|
||||
|
||||
function getEventBody(evt) {
|
||||
const eventType = getEnvelopeType(evt);
|
||||
const payload = getEnvelopePayload(evt);
|
||||
const attrs = getEnvelopeAttributes(evt);
|
||||
const correlation = getEnvelopeCorrelation(evt);
|
||||
|
||||
if (eventType === 'span.start' || eventType === 'span.end') {
|
||||
const name = attrs.name || attrs.span_kind || 'unknown span';
|
||||
const duration = payload.duration_ms !== undefined && payload.duration_ms !== null
|
||||
? ` <span class="timeline-duration">${escapeHTML(formatDuration(payload.duration_ms))}</span>`
|
||||
: '';
|
||||
return `<div class="timeline-event-body tool-name">${escapeHTML(name)}${duration}</div>`;
|
||||
}
|
||||
|
||||
if (eventType === 'run.start') {
|
||||
const preview = payload.message_preview || payload.message || '';
|
||||
if (!preview) {
|
||||
return '';
|
||||
}
|
||||
const trimmed = preview.length > 140 ? preview.slice(0, 140) + '...' : preview;
|
||||
return `<div class="timeline-event-body message-preview">"${escapeHTML(trimmed)}"</div>`;
|
||||
}
|
||||
|
||||
if (eventType === 'run.end') {
|
||||
return `<div class="timeline-event-body">${statusIcon(payload.status || 'unknown')}</div>`;
|
||||
}
|
||||
|
||||
if (eventType === 'error') {
|
||||
const errPayload = payload.error || {};
|
||||
const errType = errPayload.type || 'error';
|
||||
const message = errPayload.message || payload.message || 'unknown';
|
||||
return `<div class="timeline-event-body error-message">${escapeHTML(errType + ': ' + message)}</div>`;
|
||||
}
|
||||
|
||||
if (eventType === 'session.start' || eventType === 'session.end') {
|
||||
return correlation.session_id
|
||||
? `<div class="timeline-event-body">session ${escapeHTML(correlation.session_id)}</div>`
|
||||
: '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getEventDetails(evt) {
|
||||
const details = {};
|
||||
const correlation = getEnvelopeCorrelation(evt);
|
||||
const attributes = getEnvelopeAttributes(evt);
|
||||
const payload = getEnvelopePayload(evt);
|
||||
|
||||
if (Object.keys(correlation).length > 0) {
|
||||
details.correlation = correlation;
|
||||
}
|
||||
if (Object.keys(attributes).length > 0) {
|
||||
details.attributes = attributes;
|
||||
}
|
||||
if (Object.keys(payload).length > 0) {
|
||||
details.payload = payload;
|
||||
}
|
||||
|
||||
if (Object.keys(details).length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return JSON.stringify(details, null, 2);
|
||||
}
|
||||
|
||||
function renderAgentTimeline() {
|
||||
const timeline = document.getElementById('agents-timeline');
|
||||
if (!timeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recent = agentsState.events.slice(-100).reverse();
|
||||
if (recent.length === 0) {
|
||||
timeline.innerHTML = '<p class="empty-state">Waiting for agent activity...</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
timeline.innerHTML = recent.map((evt, index) => {
|
||||
const eventType = getEnvelopeType(evt);
|
||||
const vmName = getVMName(evt);
|
||||
const vmClass = getVMClassName(vmName);
|
||||
const details = getEventDetails(evt);
|
||||
const detailHTML = details ? `<div class="timeline-detail">${escapeHTML(details)}</div>` : '';
|
||||
const expandHTML = details ? '<button class="timeline-expand-hint" type="button">details</button>' : '';
|
||||
|
||||
return `
|
||||
<div class="timeline-event" data-index="${index}">
|
||||
<div class="timeline-event-header">
|
||||
${getEventIcon(eventType)}
|
||||
<span class="timeline-vm-tag ${vmClass}">${escapeHTML(vmName)}</span>
|
||||
<span class="timeline-event-type">${escapeHTML(getEventLabel(eventType))}</span>
|
||||
<span class="timeline-event-time">${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}</span>
|
||||
</div>
|
||||
${getEventBody(evt)}
|
||||
${expandHTML}
|
||||
${detailHTML}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
timeline.querySelectorAll('.timeline-expand-hint').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
button.parentElement.classList.toggle('expanded');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderAgentStats() {
|
||||
const stats = agentsState.stats;
|
||||
|
||||
const messagesEl = document.getElementById('stat-messages');
|
||||
if (messagesEl) {
|
||||
messagesEl.textContent = String(stats.messages);
|
||||
}
|
||||
|
||||
const toolsEl = document.getElementById('stat-tools');
|
||||
if (toolsEl) {
|
||||
toolsEl.textContent = String(stats.tools);
|
||||
}
|
||||
|
||||
const errorsEl = document.getElementById('stat-errors');
|
||||
if (errorsEl) {
|
||||
errorsEl.textContent = String(stats.errors);
|
||||
}
|
||||
|
||||
const list = document.getElementById('stat-top-tools');
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
const topTools = Object.entries(stats.toolCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 8);
|
||||
|
||||
if (topTools.length === 0) {
|
||||
list.innerHTML = '<li style="color:var(--text-dim);font-size:0.8rem">No data yet</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = topTools.map(([name, count]) => `
|
||||
<li>
|
||||
<span class="stat-list-name">${escapeHTML(name)}</span>
|
||||
<span class="stat-list-count">${count}</span>
|
||||
</li>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
route();
|
||||
})();
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>agentmon</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=Outfit:wght@300;400;500;600&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="/sessions">agentmon</a></h1>
|
||||
<div class="header-logo">
|
||||
<h1><a href="/sessions">agentmon<span class="logo-dot"></span></a></h1>
|
||||
</div>
|
||||
<nav><a href="/agents">Agents</a><a href="/openclaw">OpenClaw</a></nav>
|
||||
</header>
|
||||
<main id="app">
|
||||
<p>Loading...</p>
|
||||
|
||||
+870
-61
@@ -1,70 +1,280 @@
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
/* ============================================================
|
||||
agentmon — Refined Dark UI
|
||||
============================================================ */
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
line-height: 1.5;
|
||||
:root {
|
||||
--bg: #07090f;
|
||||
--surface: #0d1117;
|
||||
--surface-2: #121922;
|
||||
--card: rgba(13, 20, 32, 0.85);
|
||||
--border: #1c2637;
|
||||
--border-soft: rgba(28, 38, 55, 0.6);
|
||||
|
||||
--text: #c8d3e0;
|
||||
--text-dim: #4e6070;
|
||||
--text-bright: #e8eef4;
|
||||
|
||||
--accent: #22d3ee;
|
||||
--accent-dim: rgba(34, 211, 238, 0.08);
|
||||
--accent-glow: rgba(34, 211, 238, 0.2);
|
||||
|
||||
--success: #34d399;
|
||||
--error: #f87171;
|
||||
--warning: #fbbf24;
|
||||
--purple: #a78bfa;
|
||||
|
||||
--font-display: 'Syne', sans-serif;
|
||||
--font-body: 'Outfit', sans-serif;
|
||||
--font-mono: 'Fira Code', monospace;
|
||||
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
}
|
||||
|
||||
/* ── Reset ─────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
/* ── Base ──────────────────────────────────────────────────── */
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
background-color: var(--bg);
|
||||
background-image:
|
||||
radial-gradient(ellipse 80% 40% at 50% -20%, rgba(34, 211, 238, 0.04) 0%, transparent 70%),
|
||||
radial-gradient(circle at 1px 1px, rgba(34, 211, 238, 0.045) 1px, transparent 0);
|
||||
background-size: 100% 100%, 28px 28px;
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── Header ────────────────────────────────────────────────── */
|
||||
header {
|
||||
background: #161b22;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid #30363d;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 2rem;
|
||||
height: 54px;
|
||||
background: rgba(7, 9, 15, 0.82);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent 0%, var(--accent) 40%, var(--accent) 60%, transparent 100%);
|
||||
opacity: 0.15;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
header h1 a {
|
||||
color: #58a6ff;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-bright);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
.logo-dot {
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
color: #58a6ff;
|
||||
text-decoration: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
margin-left: 2px;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
header nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
header nav a {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: var(--radius);
|
||||
letter-spacing: 0.04em;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
header nav a:hover {
|
||||
color: var(--text-bright);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
/* ── Main ──────────────────────────────────────────────────── */
|
||||
main {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
|
||||
/* Page entry animation */
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
main > * {
|
||||
animation: fadeUp 0.28s ease both;
|
||||
}
|
||||
|
||||
/* ── Back link ─────────────────────────────────────────────── */
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1.75rem;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.03em;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.back-link:hover { color: var(--accent); }
|
||||
|
||||
/* ── Page header ───────────────────────────────────────────── */
|
||||
.page-header {
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.55rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
margin-bottom: 0.6rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.meta { color: #8b949e; font-size: 0.9rem; }
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 1.25rem;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* ── Section title ─────────────────────────────────────────── */
|
||||
.section-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: 0.01em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.section-title .count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── Filters ───────────────────────────────────────────────── */
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.filters label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: #8b949e;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.filters input, .filters select {
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
color: #c9d1d9;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
.filters input,
|
||||
.filters select {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.85rem;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
outline: none;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.filters input:focus,
|
||||
.filters select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
|
||||
.filters select option {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
/* ── Table container ───────────────────────────────────────── */
|
||||
.table-container {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
table {
|
||||
@@ -74,55 +284,654 @@ table {
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #21262d;
|
||||
padding: 0.7rem 1.25rem;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #161b22;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
background: var(--surface-2);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: #8b949e;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-dim);
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tr:hover { background: #161b22; }
|
||||
td {
|
||||
font-size: 0.875rem;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
tr.clickable { cursor: pointer; }
|
||||
|
||||
.status-success { color: #3fb950; }
|
||||
.status-error { color: #f85149; }
|
||||
.status-unknown { color: #d29922; }
|
||||
tr.clickable:hover td {
|
||||
background: var(--surface-2);
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
tr.clickable:hover td:first-child {
|
||||
border-left: 2px solid var(--accent);
|
||||
padding-left: calc(1.25rem - 2px);
|
||||
}
|
||||
|
||||
/* ── Status badges ─────────────────────────────────────────── */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
color: var(--success);
|
||||
background: rgba(52, 211, 153, 0.1);
|
||||
border: 1px solid rgba(52, 211, 153, 0.2);
|
||||
}
|
||||
.status-success .status-dot { background: var(--success); }
|
||||
|
||||
.status-error {
|
||||
color: var(--error);
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
border: 1px solid rgba(248, 113, 113, 0.2);
|
||||
}
|
||||
.status-error .status-dot { background: var(--error); }
|
||||
|
||||
.status-unknown {
|
||||
color: var(--warning);
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
border: 1px solid rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
.status-unknown .status-dot { background: var(--warning); }
|
||||
|
||||
/* ── Monospace cells ───────────────────────────────────────── */
|
||||
.id-cell {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ── Load more ─────────────────────────────────────────────── */
|
||||
.load-more {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
color: #c9d1d9;
|
||||
margin-top: 0.875rem;
|
||||
padding: 0.7rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.load-more:hover { background: #30363d; }
|
||||
.load-more:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
/* ── Span expand ───────────────────────────────────────────── */
|
||||
.expandable { cursor: pointer; }
|
||||
.expand-icon { margin-right: 0.5rem; }
|
||||
|
||||
.expand-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 0.5rem;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.6rem;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.span-details {
|
||||
background: #161b22;
|
||||
padding: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
background: #020508;
|
||||
padding: 1.25rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: #7a9ab5;
|
||||
line-height: 1.75;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Empty state ───────────────────────────────────────────── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #8b949e;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ── Live indicator ────────────────────────────────────────── */
|
||||
.live-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 6px rgba(52, 211, 153, 0.5);
|
||||
animation: livePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes livePulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 6px rgba(52, 211, 153, 0.5); }
|
||||
50% { opacity: 0.6; box-shadow: 0 0 2px rgba(52, 211, 153, 0.2); }
|
||||
}
|
||||
|
||||
/* ── VM Grid ───────────────────────────────────────────────── */
|
||||
.vm-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* ── VM Card ───────────────────────────────────────────────── */
|
||||
.vm-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 1.25rem;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
transition: border-color 0.2s, transform 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vm-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-glow), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.vm-card:hover {
|
||||
border-color: rgba(34, 211, 238, 0.18);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.vm-card:hover::before { opacity: 1; }
|
||||
|
||||
.vm-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
.vm-card h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.vm-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.vm-status.running {
|
||||
background: rgba(52, 211, 153, 0.1);
|
||||
color: var(--success);
|
||||
border: 1px solid rgba(52, 211, 153, 0.2);
|
||||
}
|
||||
|
||||
.vm-status.stopped {
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
color: var(--error);
|
||||
border: 1px solid rgba(248, 113, 113, 0.2);
|
||||
}
|
||||
|
||||
.vm-updated {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.75rem;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.vm-divider {
|
||||
height: 1px;
|
||||
background: var(--border-soft);
|
||||
margin: 0.875rem 0;
|
||||
}
|
||||
|
||||
.vm-stats { width: 100%; }
|
||||
|
||||
.vm-stats td {
|
||||
padding: 0.28rem 0;
|
||||
border-bottom: none;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.vm-stats td:first-child {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
width: 42%;
|
||||
}
|
||||
|
||||
.vm-stats td:last-child {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.vm-issues {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
|
||||
.issue {
|
||||
font-size: 0.68rem;
|
||||
padding: 0.22rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.issue.gateway_down {
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
color: var(--error);
|
||||
border: 1px solid rgba(248, 113, 113, 0.2);
|
||||
}
|
||||
|
||||
.issue.http_unhealthy {
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: var(--warning);
|
||||
border: 1px solid rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
|
||||
.issue.backup_stale {
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
color: var(--warning);
|
||||
border: 1px solid rgba(251, 191, 36, 0.15);
|
||||
}
|
||||
|
||||
.issue.version_mismatch {
|
||||
background: rgba(167, 139, 250, 0.1);
|
||||
color: var(--purple);
|
||||
border: 1px solid rgba(167, 139, 250, 0.2);
|
||||
}
|
||||
|
||||
/* ── Agents Page ───────────────────────────────────────────── */
|
||||
.agents-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.agents-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.vm-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.vm-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
transition: border-color 0.2s, opacity 0.2s;
|
||||
}
|
||||
|
||||
.vm-pill.active {
|
||||
border-color: rgba(52, 211, 153, 0.3);
|
||||
}
|
||||
|
||||
.vm-pill.inactive {
|
||||
border-color: rgba(248, 113, 113, 0.2);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.vm-pill-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vm-pill.active .vm-pill-dot {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 6px rgba(52, 211, 153, 0.5);
|
||||
animation: livePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.vm-pill.inactive .vm-pill-dot {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.vm-pill-name {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.vm-pill-label {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-event {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.875rem 1.125rem;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
animation: fadeUp 0.25s ease both;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.timeline-event:hover {
|
||||
border-color: rgba(34, 211, 238, 0.15);
|
||||
}
|
||||
|
||||
.timeline-event-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.timeline-vm-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.timeline-vm-tag.zap {
|
||||
background: rgba(34, 211, 238, 0.12);
|
||||
color: var(--accent);
|
||||
border: 1px solid rgba(34, 211, 238, 0.2);
|
||||
}
|
||||
|
||||
.timeline-vm-tag.orb {
|
||||
background: rgba(167, 139, 250, 0.12);
|
||||
color: var(--purple);
|
||||
border: 1px solid rgba(167, 139, 250, 0.2);
|
||||
}
|
||||
|
||||
.timeline-vm-tag.sun {
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
color: var(--warning);
|
||||
border: 1px solid rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
|
||||
.timeline-vm-tag.unknown {
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.timeline-event-type {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.timeline-event-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.timeline-event-body {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
padding-left: 0.15rem;
|
||||
}
|
||||
|
||||
.timeline-event-body.tool-name {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.timeline-event-body.message-preview {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.timeline-event-body.error-message {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.timeline-duration {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.timeline-detail {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: #020508;
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: #7a9ab5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
line-height: 1.65;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-event.expanded .timeline-detail {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.timeline-expand-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-top: 0.3rem;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.timeline-expand-hint:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stats-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stat-card-title {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.stat-card-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.stat-card-sub {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.stat-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.stat-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.stat-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stat-list-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stat-list-count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
background: var(--surface-2);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.event-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.6rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-icon.message-in {
|
||||
background: rgba(52, 211, 153, 0.12);
|
||||
color: var(--success);
|
||||
border: 1px solid rgba(52, 211, 153, 0.25);
|
||||
}
|
||||
|
||||
.event-icon.message-out {
|
||||
background: rgba(34, 211, 238, 0.12);
|
||||
color: var(--accent);
|
||||
border: 1px solid rgba(34, 211, 238, 0.25);
|
||||
}
|
||||
|
||||
.event-icon.tool {
|
||||
background: rgba(167, 139, 250, 0.12);
|
||||
color: var(--purple);
|
||||
border: 1px solid rgba(167, 139, 250, 0.25);
|
||||
}
|
||||
|
||||
.event-icon.error {
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
color: var(--error);
|
||||
border: 1px solid rgba(248, 113, 113, 0.25);
|
||||
}
|
||||
|
||||
.event-icon.session {
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
color: var(--warning);
|
||||
border: 1px solid rgba(251, 191, 36, 0.25);
|
||||
}
|
||||
|
||||
.event-icon.internal {
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user