mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-16 02:51:45 +00:00
Fix autoupdate, add healtcheck status to the dashboard
This commit is contained in:
@@ -132,3 +132,8 @@ func (s *Store) QueryRowContext(ctx context.Context, query string, args ...inter
|
|||||||
}
|
}
|
||||||
return stmt.QueryRowContext(ctx, args...)
|
return stmt.QueryRowContext(ctx, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ping checks if the database connection is alive.
|
||||||
|
func (s *Store) Ping() error {
|
||||||
|
return s.db.Ping()
|
||||||
|
}
|
||||||
|
|||||||
@@ -573,21 +573,30 @@ func (u *Updater) Restart() error {
|
|||||||
|
|
||||||
log.Info().Str("path", workerPath).Msg("Restarting worker with new binary")
|
log.Info().Str("path", workerPath).Msg("Restarting worker with new binary")
|
||||||
|
|
||||||
// Start the new process
|
// Use nohup to start a detached process that survives parent exit
|
||||||
cmd := exec.Command(workerPath) // #nosec G204 -- workerPath is from internal installDir
|
// The new worker will retry binding to the port after the old process exits
|
||||||
cmd.Stdout = os.Stdout
|
cmd := exec.Command("nohup", workerPath) // #nosec G204 -- workerPath is from internal installDir
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stdout = nil // Detach stdout
|
||||||
cmd.Env = os.Environ()
|
cmd.Stderr = nil // Detach stderr
|
||||||
|
cmd.Stdin = nil // Detach stdin
|
||||||
|
cmd.Env = append(os.Environ(), "CLAUDE_MNEMONIC_RESTART=1")
|
||||||
|
|
||||||
|
// Start in background - don't wait
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return fmt.Errorf("failed to start new worker: %w", err)
|
return fmt.Errorf("failed to start new worker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give the new process time to start
|
// Release the child process so it's not a zombie
|
||||||
time.Sleep(RestartDelay)
|
go func() {
|
||||||
|
_ = cmd.Wait()
|
||||||
|
}()
|
||||||
|
|
||||||
// Exit current process - the new one is now running
|
|
||||||
log.Info().Int("new_pid", cmd.Process.Pid).Msg("New worker started, exiting old process")
|
log.Info().Int("new_pid", cmd.Process.Pid).Msg("New worker started, exiting old process")
|
||||||
|
|
||||||
|
// Give a moment for the log to flush
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Exit current process - the new one will bind to the port
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
|
|
||||||
return nil // Never reached
|
return nil // Never reached
|
||||||
|
|||||||
@@ -449,6 +449,13 @@ func (c *Client) nextID() int {
|
|||||||
return c.requestID
|
return c.requestID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsConnected returns whether the client is currently connected to ChromaDB.
|
||||||
|
func (c *Client) IsConnected() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.connected
|
||||||
|
}
|
||||||
|
|
||||||
// Close closes the connection to ChromaDB.
|
// Close closes the connection to ChromaDB.
|
||||||
func (c *Client) Close() error {
|
func (c *Client) Close() error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
|||||||
@@ -751,6 +751,110 @@ func (s *Service) handleUpdateStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, status)
|
writeJSON(w, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ComponentHealth represents the health status of a single component.
|
||||||
|
type ComponentHealth struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"` // "healthy", "degraded", "unhealthy"
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelfCheckResponse contains the health status of all components.
|
||||||
|
type SelfCheckResponse struct {
|
||||||
|
Overall string `json:"overall"` // "healthy", "degraded", "unhealthy"
|
||||||
|
Version string `json:"version"`
|
||||||
|
Uptime string `json:"uptime"`
|
||||||
|
Components []ComponentHealth `json:"components"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSelfCheck returns the health status of all components.
|
||||||
|
func (s *Service) handleSelfCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
|
components := []ComponentHealth{}
|
||||||
|
overall := "healthy"
|
||||||
|
|
||||||
|
// Check Worker Service
|
||||||
|
workerStatus := ComponentHealth{Name: "Worker Service", Status: "healthy"}
|
||||||
|
if !s.ready.Load() {
|
||||||
|
if err := s.GetInitError(); err != nil {
|
||||||
|
workerStatus.Status = "unhealthy"
|
||||||
|
workerStatus.Message = err.Error()
|
||||||
|
overall = "unhealthy"
|
||||||
|
} else {
|
||||||
|
workerStatus.Status = "degraded"
|
||||||
|
workerStatus.Message = "Initializing"
|
||||||
|
if overall == "healthy" {
|
||||||
|
overall = "degraded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
components = append(components, workerStatus)
|
||||||
|
|
||||||
|
// Check SQLite Database
|
||||||
|
dbStatus := ComponentHealth{Name: "SQLite Database", Status: "healthy"}
|
||||||
|
if s.store == nil {
|
||||||
|
dbStatus.Status = "unhealthy"
|
||||||
|
dbStatus.Message = "Not initialized"
|
||||||
|
overall = "unhealthy"
|
||||||
|
} else if err := s.store.Ping(); err != nil {
|
||||||
|
dbStatus.Status = "unhealthy"
|
||||||
|
dbStatus.Message = err.Error()
|
||||||
|
overall = "unhealthy"
|
||||||
|
}
|
||||||
|
components = append(components, dbStatus)
|
||||||
|
|
||||||
|
// Check ChromaDB
|
||||||
|
chromaStatus := ComponentHealth{Name: "ChromaDB", Status: "healthy"}
|
||||||
|
if s.chromaClient == nil {
|
||||||
|
chromaStatus.Status = "degraded"
|
||||||
|
chromaStatus.Message = "Not configured"
|
||||||
|
if overall == "healthy" {
|
||||||
|
overall = "degraded"
|
||||||
|
}
|
||||||
|
} else if !s.chromaClient.IsConnected() {
|
||||||
|
chromaStatus.Status = "degraded"
|
||||||
|
chromaStatus.Message = "Not connected"
|
||||||
|
if overall == "healthy" {
|
||||||
|
overall = "degraded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
components = append(components, chromaStatus)
|
||||||
|
|
||||||
|
// Check SDK Processor
|
||||||
|
sdkStatus := ComponentHealth{Name: "SDK Processor", Status: "healthy"}
|
||||||
|
if s.processor == nil {
|
||||||
|
sdkStatus.Status = "degraded"
|
||||||
|
sdkStatus.Message = "Not initialized"
|
||||||
|
if overall == "healthy" {
|
||||||
|
overall = "degraded"
|
||||||
|
}
|
||||||
|
} else if !s.processor.IsAvailable() {
|
||||||
|
sdkStatus.Status = "degraded"
|
||||||
|
sdkStatus.Message = "Claude CLI not available"
|
||||||
|
if overall == "healthy" {
|
||||||
|
overall = "degraded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
components = append(components, sdkStatus)
|
||||||
|
|
||||||
|
// Check SSE Broadcaster
|
||||||
|
sseStatus := ComponentHealth{Name: "SSE Broadcaster", Status: "healthy"}
|
||||||
|
if s.sseBroadcaster == nil {
|
||||||
|
sseStatus.Status = "unhealthy"
|
||||||
|
sseStatus.Message = "Not initialized"
|
||||||
|
overall = "unhealthy"
|
||||||
|
}
|
||||||
|
components = append(components, sseStatus)
|
||||||
|
|
||||||
|
// Calculate uptime
|
||||||
|
uptime := time.Since(s.startTime).Round(time.Second).String()
|
||||||
|
|
||||||
|
writeJSON(w, SelfCheckResponse{
|
||||||
|
Overall: overall,
|
||||||
|
Version: s.version,
|
||||||
|
Uptime: uptime,
|
||||||
|
Components: components,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// handleUpdateRestart restarts the worker with the new binary.
|
// handleUpdateRestart restarts the worker with the new binary.
|
||||||
func (s *Service) handleUpdateRestart(w http.ResponseWriter, r *http.Request) {
|
func (s *Service) handleUpdateRestart(w http.ResponseWriter, r *http.Request) {
|
||||||
status := s.updater.GetStatus()
|
status := s.updater.GetStatus()
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ func NewProcessor(observationStore *sqlite.ObservationStore, summaryStore *sqlit
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsAvailable checks if the Claude CLI is available for processing.
|
||||||
|
func (p *Processor) IsAvailable() bool {
|
||||||
|
_, err := os.Stat(p.claudePath)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
// ProcessObservation processes a single tool observation and extracts insights.
|
// ProcessObservation processes a single tool observation and extracts insights.
|
||||||
func (p *Processor) ProcessObservation(ctx context.Context, sdkSessionID, project string, toolName string, toolInput, toolResponse interface{}, promptNumber int, cwd string) error {
|
func (p *Processor) ProcessObservation(ctx context.Context, sdkSessionID, project string, toolName string, toolInput, toolResponse interface{}, promptNumber int, cwd string) error {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
|
|||||||
@@ -602,6 +602,9 @@ func (s *Service) setupRoutes() {
|
|||||||
s.router.Get("/api/update/status", s.handleUpdateStatus)
|
s.router.Get("/api/update/status", s.handleUpdateStatus)
|
||||||
s.router.Post("/api/update/restart", s.handleUpdateRestart)
|
s.router.Post("/api/update/restart", s.handleUpdateRestart)
|
||||||
|
|
||||||
|
// Selfcheck endpoint (works before DB is ready - checks all components)
|
||||||
|
s.router.Get("/api/selfcheck", s.handleSelfCheck)
|
||||||
|
|
||||||
// SSE endpoint (works before DB is ready)
|
// SSE endpoint (works before DB is ready)
|
||||||
s.router.Get("/api/events", s.sseBroadcaster.HandleSSE)
|
s.router.Get("/api/events", s.sseBroadcaster.HandleSSE)
|
||||||
|
|
||||||
@@ -668,11 +671,34 @@ func (s *Service) Start() error {
|
|||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if we're in restart mode (after update)
|
||||||
|
isRestart := os.Getenv("CLAUDE_MNEMONIC_RESTART") == "1"
|
||||||
|
|
||||||
s.wg.Add(1)
|
s.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer s.wg.Done()
|
defer s.wg.Done()
|
||||||
if err := s.server.ListenAndServe(); err != http.ErrServerClosed {
|
|
||||||
log.Error().Err(err).Msg("HTTP server error")
|
var lastErr error
|
||||||
|
maxRetries := 1
|
||||||
|
if isRestart {
|
||||||
|
maxRetries = 10 // Retry up to 10 times during restart
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < maxRetries; i++ {
|
||||||
|
lastErr = s.server.ListenAndServe()
|
||||||
|
if lastErr == http.ErrServerClosed {
|
||||||
|
return // Normal shutdown
|
||||||
|
}
|
||||||
|
|
||||||
|
if i < maxRetries-1 && isRestart {
|
||||||
|
log.Warn().Err(lastErr).Int("retry", i+1).Msg("Port not ready, retrying...")
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
log.Error().Err(lastErr).Msg("HTTP server error")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -681,6 +707,7 @@ func (s *Service) Start() error {
|
|||||||
log.Info().
|
log.Info().
|
||||||
Int("port", port).
|
Int("port", port).
|
||||||
Int("pid", getPID()).
|
Int("pid", getPID()).
|
||||||
|
Bool("restart_mode", isRestart).
|
||||||
Msg("Worker HTTP server started (initialization in progress)")
|
Msg("Worker HTTP server started (initialization in progress)")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<!-- Placeholder for go:embed - replaced by UI build -->
|
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watch } from 'vue'
|
import { watch } from 'vue'
|
||||||
import { useSSE, useStats, useTimeline, useUpdate } from '@/composables'
|
import { useSSE, useStats, useTimeline, useUpdate, useHealth } from '@/composables'
|
||||||
import Header from '@/components/Header.vue'
|
import Header from '@/components/Header.vue'
|
||||||
import StatsCards from '@/components/StatsCards.vue'
|
import StatsCards from '@/components/StatsCards.vue'
|
||||||
import FilterTabs from '@/components/FilterTabs.vue'
|
import FilterTabs from '@/components/FilterTabs.vue'
|
||||||
@@ -11,6 +11,7 @@ import Sidebar from '@/components/Sidebar.vue'
|
|||||||
const { isConnected, isProcessing, queueDepth, lastEvent } = useSSE()
|
const { isConnected, isProcessing, queueDepth, lastEvent } = useSSE()
|
||||||
const { stats } = useStats()
|
const { stats } = useStats()
|
||||||
const { updateInfo, updateStatus, isUpdating, applyUpdate } = useUpdate()
|
const { updateInfo, updateStatus, isUpdating, applyUpdate } = useUpdate()
|
||||||
|
const { health } = useHealth()
|
||||||
const {
|
const {
|
||||||
filteredItems,
|
filteredItems,
|
||||||
loading,
|
loading,
|
||||||
@@ -66,6 +67,7 @@ watch(lastEvent, (event) => {
|
|||||||
:prompt-count="promptCount"
|
:prompt-count="promptCount"
|
||||||
:summary-count="summaryCount"
|
:summary-count="summaryCount"
|
||||||
:current-project="currentProject"
|
:current-project="currentProject"
|
||||||
|
:health="health"
|
||||||
@update:project="setProject"
|
@update:project="setProject"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -22,15 +22,35 @@ const restartWorker = async () => {
|
|||||||
isRestarting.value = true
|
isRestarting.value = true
|
||||||
try {
|
try {
|
||||||
await fetch('/api/update/restart', { method: 'POST' })
|
await fetch('/api/update/restart', { method: 'POST' })
|
||||||
// Wait a moment for the new process to start, then reload
|
// Poll for new worker to be ready before reloading
|
||||||
setTimeout(() => {
|
await waitForWorker()
|
||||||
globalThis.location.reload()
|
globalThis.location.reload()
|
||||||
}, 2000)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to restart:', error)
|
console.error('Failed to restart:', error)
|
||||||
isRestarting.value = false
|
isRestarting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Poll health endpoint until worker is ready
|
||||||
|
const waitForWorker = async (maxAttempts = 30, delayMs = 500): Promise<void> => {
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delayMs))
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/health', {
|
||||||
|
signal: AbortSignal.timeout(2000)
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.status === 'ready') {
|
||||||
|
return // Worker is ready
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Worker not ready yet, continue polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Timeout - reload anyway
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
+232
-116
@@ -1,148 +1,264 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Stats } from '@/types'
|
import { ref, computed } from 'vue'
|
||||||
|
import type { Stats, SelfCheckResponse } from '@/types'
|
||||||
import ProjectFilter from './ProjectFilter.vue'
|
import ProjectFilter from './ProjectFilter.vue'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
stats: Stats | null
|
stats: Stats | null
|
||||||
observationCount: number
|
observationCount: number
|
||||||
promptCount: number
|
promptCount: number
|
||||||
summaryCount: number
|
summaryCount: number
|
||||||
currentProject: string | null
|
currentProject: string | null
|
||||||
|
health: SelfCheckResponse | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
'update:project': [project: string | null]
|
'update:project': [project: string | null]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Collapse state - persisted in localStorage
|
||||||
|
const isCollapsed = ref(localStorage.getItem('sidebar-collapsed') === 'true')
|
||||||
|
|
||||||
|
function toggleCollapse() {
|
||||||
|
isCollapsed.value = !isCollapsed.value
|
||||||
|
localStorage.setItem('sidebar-collapsed', String(isCollapsed.value))
|
||||||
|
}
|
||||||
|
|
||||||
function formatNumber(n: number): string {
|
function formatNumber(n: number): string {
|
||||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
||||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
|
||||||
return n.toString()
|
return n.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Health status helpers
|
||||||
|
const overallHealthIcon = computed(() => {
|
||||||
|
if (!props.health) return 'fa-circle-question'
|
||||||
|
switch (props.health.overall) {
|
||||||
|
case 'healthy': return 'fa-circle-check'
|
||||||
|
case 'degraded': return 'fa-triangle-exclamation'
|
||||||
|
case 'unhealthy': return 'fa-circle-xmark'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const overallHealthColor = computed(() => {
|
||||||
|
if (!props.health) return 'text-slate-400'
|
||||||
|
switch (props.health.overall) {
|
||||||
|
case 'healthy': return 'text-green-400'
|
||||||
|
case 'degraded': return 'text-amber-400'
|
||||||
|
case 'unhealthy': return 'text-red-400'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'healthy': return 'text-green-400'
|
||||||
|
case 'degraded': return 'text-amber-400'
|
||||||
|
case 'unhealthy': return 'text-red-400'
|
||||||
|
default: return 'text-slate-400'
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="w-72 flex-shrink-0 space-y-4">
|
<aside
|
||||||
<!-- Project Filter -->
|
:class="[
|
||||||
<div class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
|
'flex-shrink-0 transition-all duration-300 ease-in-out',
|
||||||
<div class="flex items-center gap-2 mb-3">
|
isCollapsed ? 'w-12' : 'w-72'
|
||||||
<i class="fas fa-filter text-claude-400" />
|
]"
|
||||||
<h3 class="text-sm font-semibold text-white">Filter by Project</h3>
|
>
|
||||||
</div>
|
<!-- Collapse Toggle Button -->
|
||||||
<ProjectFilter
|
<button
|
||||||
:current-project="currentProject"
|
@click="toggleCollapse"
|
||||||
@update:project="$emit('update:project', $event)"
|
class="w-full flex items-center justify-center py-2 mb-4 bg-slate-800/50 rounded-lg border border-slate-700/50 hover:bg-slate-700/50 transition-colors"
|
||||||
|
:title="isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
:class="[
|
||||||
|
'fas transition-transform duration-300',
|
||||||
|
isCollapsed ? 'fa-chevron-right' : 'fa-chevron-left'
|
||||||
|
]"
|
||||||
|
class="text-slate-400"
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-show="!isCollapsed" class="space-y-4">
|
||||||
|
<!-- Project Filter -->
|
||||||
|
<div class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<i class="fas fa-filter text-claude-400" />
|
||||||
|
<h3 class="text-sm font-semibold text-white">Filter by Project</h3>
|
||||||
|
</div>
|
||||||
|
<ProjectFilter
|
||||||
|
:current-project="currentProject"
|
||||||
|
@update:project="$emit('update:project', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Component Health -->
|
||||||
|
<div class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<i :class="['fas', overallHealthIcon, overallHealthColor]" />
|
||||||
|
<h3 class="text-sm font-semibold text-white">System Health</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="health" class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="component in health.components"
|
||||||
|
:key="component.name"
|
||||||
|
class="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span class="text-slate-400 text-sm truncate" :title="component.message">
|
||||||
|
{{ component.name }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
:class="['text-xs font-medium capitalize', getStatusColor(component.status)]"
|
||||||
|
>
|
||||||
|
{{ component.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-slate-500 text-sm">
|
||||||
|
Loading health status...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Memory Stats -->
|
||||||
|
<div class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<i class="fas fa-brain text-purple-400" />
|
||||||
|
<h3 class="text-sm font-semibold text-white">Memory Contents</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Observations -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="fas fa-lightbulb text-amber-400 w-4" />
|
||||||
|
<span class="text-slate-400 text-sm">Observations</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-white font-medium">{{ formatNumber(observationCount) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prompts -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="fas fa-comment text-blue-400 w-4" />
|
||||||
|
<span class="text-slate-400 text-sm">Prompts</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-white font-medium">{{ formatNumber(promptCount) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summaries -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="fas fa-clipboard-list text-green-400 w-4" />
|
||||||
|
<span class="text-slate-400 text-sm">Summaries</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-white font-medium">{{ formatNumber(summaryCount) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Retrieval Stats -->
|
||||||
|
<div v-if="stats?.retrieval" class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<i class="fas fa-search text-cyan-400" />
|
||||||
|
<h3 class="text-sm font-semibold text-white">Retrieval Stats</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Total Requests -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-slate-400 text-sm">Total Requests</span>
|
||||||
|
<span class="text-white font-medium">{{ formatNumber(stats.retrieval.TotalRequests) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Observations Served -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-slate-400 text-sm">Obs Served</span>
|
||||||
|
<span class="text-white font-medium">{{ formatNumber(stats.retrieval.ObservationsServed) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Requests -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-slate-400 text-sm">Searches</span>
|
||||||
|
<span class="text-white font-medium">{{ formatNumber(stats.retrieval.SearchRequests) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Context Injections -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-slate-400 text-sm">Injections</span>
|
||||||
|
<span class="text-white font-medium">{{ formatNumber(stats.retrieval.ContextInjections) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Verified Stale -->
|
||||||
|
<div v-if="stats.retrieval.VerifiedStale > 0" class="flex items-center justify-between">
|
||||||
|
<span class="text-slate-400 text-sm">Verified Stale</span>
|
||||||
|
<span class="text-amber-400 font-medium">{{ formatNumber(stats.retrieval.VerifiedStale) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deleted Invalid -->
|
||||||
|
<div v-if="stats.retrieval.DeletedInvalid > 0" class="flex items-center justify-between">
|
||||||
|
<span class="text-slate-400 text-sm">Deleted Invalid</span>
|
||||||
|
<span class="text-red-400 font-medium">{{ formatNumber(stats.retrieval.DeletedInvalid) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Session Info -->
|
||||||
|
<div v-if="stats" class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<i class="fas fa-clock text-slate-400" />
|
||||||
|
<h3 class="text-sm font-semibold text-white">Worker Info</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Uptime -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-slate-400 text-sm">Uptime</span>
|
||||||
|
<span class="text-white font-medium text-xs">{{ stats.uptime }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sessions Today -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-slate-400 text-sm">Sessions Today</span>
|
||||||
|
<span class="text-white font-medium">{{ stats.sessionsToday }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connected Clients -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-slate-400 text-sm">Connected Clients</span>
|
||||||
|
<span class="text-white font-medium">{{ stats.connectedClients }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Memory Stats -->
|
<!-- Collapsed State - Show icons only -->
|
||||||
<div class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
|
<div v-show="isCollapsed" class="space-y-2">
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<!-- Health indicator -->
|
||||||
|
<div
|
||||||
|
class="bg-slate-800/50 rounded-lg p-3 border border-slate-700/50 flex justify-center"
|
||||||
|
:title="`System: ${health?.overall || 'Unknown'}`"
|
||||||
|
>
|
||||||
|
<i :class="['fas', overallHealthIcon, overallHealthColor]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Memory indicator -->
|
||||||
|
<div
|
||||||
|
class="bg-slate-800/50 rounded-lg p-3 border border-slate-700/50 flex justify-center"
|
||||||
|
:title="`${observationCount} observations, ${promptCount} prompts`"
|
||||||
|
>
|
||||||
<i class="fas fa-brain text-purple-400" />
|
<i class="fas fa-brain text-purple-400" />
|
||||||
<h3 class="text-sm font-semibold text-white">Memory Contents</h3>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<!-- Stats indicator -->
|
||||||
<!-- Observations -->
|
<div
|
||||||
<div class="flex items-center justify-between">
|
v-if="stats?.retrieval"
|
||||||
<div class="flex items-center gap-2">
|
class="bg-slate-800/50 rounded-lg p-3 border border-slate-700/50 flex justify-center"
|
||||||
<i class="fas fa-lightbulb text-amber-400 w-4" />
|
:title="`${stats.retrieval.TotalRequests} total requests`"
|
||||||
<span class="text-slate-400 text-sm">Observations</span>
|
>
|
||||||
</div>
|
|
||||||
<span class="text-white font-medium">{{ formatNumber(observationCount) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Prompts -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<i class="fas fa-comment text-blue-400 w-4" />
|
|
||||||
<span class="text-slate-400 text-sm">Prompts</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-white font-medium">{{ formatNumber(promptCount) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Summaries -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<i class="fas fa-clipboard-list text-green-400 w-4" />
|
|
||||||
<span class="text-slate-400 text-sm">Summaries</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-white font-medium">{{ formatNumber(summaryCount) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Retrieval Stats -->
|
|
||||||
<div v-if="stats?.retrieval" class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
|
|
||||||
<div class="flex items-center gap-2 mb-3">
|
|
||||||
<i class="fas fa-search text-cyan-400" />
|
<i class="fas fa-search text-cyan-400" />
|
||||||
<h3 class="text-sm font-semibold text-white">Retrieval Stats</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<!-- Total Requests -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-slate-400 text-sm">Total Requests</span>
|
|
||||||
<span class="text-white font-medium">{{ formatNumber(stats.retrieval.TotalRequests) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Observations Served -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-slate-400 text-sm">Obs Served</span>
|
|
||||||
<span class="text-white font-medium">{{ formatNumber(stats.retrieval.ObservationsServed) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search Requests -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-slate-400 text-sm">Searches</span>
|
|
||||||
<span class="text-white font-medium">{{ formatNumber(stats.retrieval.SearchRequests) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Context Injections -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-slate-400 text-sm">Injections</span>
|
|
||||||
<span class="text-white font-medium">{{ formatNumber(stats.retrieval.ContextInjections) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Verified Stale -->
|
|
||||||
<div v-if="stats.retrieval.VerifiedStale > 0" class="flex items-center justify-between">
|
|
||||||
<span class="text-slate-400 text-sm">Verified Stale</span>
|
|
||||||
<span class="text-amber-400 font-medium">{{ formatNumber(stats.retrieval.VerifiedStale) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Deleted Invalid -->
|
|
||||||
<div v-if="stats.retrieval.DeletedInvalid > 0" class="flex items-center justify-between">
|
|
||||||
<span class="text-slate-400 text-sm">Deleted Invalid</span>
|
|
||||||
<span class="text-red-400 font-medium">{{ formatNumber(stats.retrieval.DeletedInvalid) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Session Info -->
|
|
||||||
<div v-if="stats" class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
|
|
||||||
<div class="flex items-center gap-2 mb-3">
|
|
||||||
<i class="fas fa-clock text-slate-400" />
|
|
||||||
<h3 class="text-sm font-semibold text-white">Worker Info</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<!-- Uptime -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-slate-400 text-sm">Uptime</span>
|
|
||||||
<span class="text-white font-medium text-xs">{{ stats.uptime }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sessions Today -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-slate-400 text-sm">Sessions Today</span>
|
|
||||||
<span class="text-white font-medium">{{ stats.sessionsToday }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Connected Clients -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-slate-400 text-sm">Connected Clients</span>
|
|
||||||
<span class="text-white font-medium">{{ stats.connectedClients }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export { useSSE } from './useSSE'
|
|||||||
export { useStats } from './useStats'
|
export { useStats } from './useStats'
|
||||||
export { useTimeline } from './useTimeline'
|
export { useTimeline } from './useTimeline'
|
||||||
export { useUpdate } from './useUpdate'
|
export { useUpdate } from './useUpdate'
|
||||||
|
export { useHealth } from './useHealth'
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import type { SelfCheckResponse } from '@/types'
|
||||||
|
|
||||||
|
const CHECK_INTERVAL = 30 * 1000 // 30 seconds
|
||||||
|
|
||||||
|
export function useHealth() {
|
||||||
|
const health = ref<SelfCheckResponse | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
async function fetchHealth() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/selfcheck')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
health.value = await response.json()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Unknown error'
|
||||||
|
health.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
fetchHealth()
|
||||||
|
intervalId = setInterval(fetchHealth, CHECK_INTERVAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
intervalId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
startPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
health,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh: fetchHealth
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,3 +50,16 @@ export interface SSEEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type FilterType = 'all' | 'observations' | 'summaries' | 'prompts'
|
export type FilterType = 'all' | 'observations' | 'summaries' | 'prompts'
|
||||||
|
|
||||||
|
export interface ComponentHealth {
|
||||||
|
name: string
|
||||||
|
status: 'healthy' | 'degraded' | 'unhealthy'
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfCheckResponse {
|
||||||
|
overall: 'healthy' | 'degraded' | 'unhealthy'
|
||||||
|
version: string
|
||||||
|
uptime: string
|
||||||
|
components: ComponentHealth[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/components/index.ts","./src/composables/index.ts","./src/composables/usesse.ts","./src/composables/usestats.ts","./src/composables/usetimeline.ts","./src/composables/useupdate.ts","./src/types/api.ts","./src/types/index.ts","./src/types/observation.ts","./src/types/prompt.ts","./src/types/summary.ts","./src/utils/api.ts","./src/utils/formatters.ts","./src/app.vue","./src/components/badge.vue","./src/components/card.vue","./src/components/filtertabs.vue","./src/components/header.vue","./src/components/iconbox.vue","./src/components/observationcard.vue","./src/components/projectfilter.vue","./src/components/promptcard.vue","./src/components/sidebar.vue","./src/components/statscards.vue","./src/components/summarycard.vue","./src/components/timeline.vue"],"version":"5.7.3"}
|
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/components/index.ts","./src/composables/index.ts","./src/composables/usehealth.ts","./src/composables/usesse.ts","./src/composables/usestats.ts","./src/composables/usetimeline.ts","./src/composables/useupdate.ts","./src/types/api.ts","./src/types/index.ts","./src/types/observation.ts","./src/types/prompt.ts","./src/types/summary.ts","./src/utils/api.ts","./src/utils/formatters.ts","./src/app.vue","./src/components/badge.vue","./src/components/card.vue","./src/components/filtertabs.vue","./src/components/header.vue","./src/components/iconbox.vue","./src/components/observationcard.vue","./src/components/projectfilter.vue","./src/components/promptcard.vue","./src/components/sidebar.vue","./src/components/statscards.vue","./src/components/summarycard.vue","./src/components/timeline.vue"],"version":"5.7.3"}
|
||||||
Reference in New Issue
Block a user