Skip to content

Commit c61d13e

Browse files
feat(health): implement unified health status across all interfaces
Complete implementation of the unified health status feature (012): ## Backend (already committed) - HealthStatus struct with level, admin_state, summary, detail, action - CalculateHealth() function in internal/health/calculator.go - Health field added to contracts.Server ## CLI - Updated upstream list to use health.level for status emoji - Added action hints column showing CLI commands for fixes - Distinct indicators for disabled/quarantined admin states ## Tray App - Updated getServerStatusDisplay() to use unified health status - Health-based status indicators (green/orange/red/paused/locked) - Added restart action menu items based on health.action ## Web UI - ServerCard.vue uses health.level for badge color - Added action buttons (Login, Restart, Enable, Approve, View Logs) - HealthStatus TypeScript interface in contracts.ts ## Dashboard - X servers need attention banner for degraded/unhealthy servers - Quick-fix action buttons in banner - Filters out disabled/quarantined from attention list ## Documentation - Updated CLAUDE.md with health status documentation - Added HealthStatus schema to oas/swagger.yaml - Created followup doc for verify-oas-coverage.sh issues All 44 tasks complete. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 86bb84a commit c61d13e

11 files changed

Lines changed: 485 additions & 148 deletions

File tree

CLAUDE.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,41 @@ open "http://127.0.0.1:8080/ui/?apikey=your-api-key"
522522
- **REST API** requires authentication - API key is always enforced (auto-generated if not provided)
523523
- **Secure by default**: Empty or missing API keys trigger automatic generation and persistence to config
524524

525+
### Unified Health Status
526+
527+
All server responses include a `health` field that provides consistent status information across all interfaces (CLI, web UI, tray, MCP tools):
528+
529+
```json
530+
{
531+
"health": {
532+
"level": "healthy|degraded|unhealthy",
533+
"admin_state": "enabled|disabled|quarantined",
534+
"summary": "Human-readable status summary",
535+
"detail": "Additional context about the status",
536+
"action": "login|restart|enable|approve|view_logs|"
537+
}
538+
}
539+
```
540+
541+
**Health Levels**:
542+
- `healthy`: Server is connected and functioning normally
543+
- `degraded`: Server has warnings (e.g., OAuth token expiring soon)
544+
- `unhealthy`: Server has errors or is not functioning
545+
546+
**Admin States**:
547+
- `enabled`: Normal operation
548+
- `disabled`: User disabled the server
549+
- `quarantined`: Server pending security approval
550+
551+
**Actions**: Suggested remediation action for the current state. Empty when no action is needed.
552+
553+
**Configuration**: Token expiry warning threshold can be configured:
554+
```json
555+
{
556+
"oauth_expiry_warning_hours": 24
557+
}
558+
```
559+
525560
## JavaScript Code Execution
526561

527562
The `code_execution` tool enables orchestrating multiple upstream MCP tools in a single request using sandboxed JavaScript (ES5.1+).

cmd/mcpproxy/upstream_cmd.go

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -700,24 +700,3 @@ func runUpstreamBulkAction(action string, force bool) error {
700700

701701
return nil
702702
}
703-
704-
// formatDurationShort formats a duration into a short human-readable string for table display
705-
func formatDurationShort(d time.Duration) string {
706-
if d < 0 {
707-
return "expired"
708-
}
709-
710-
days := int(d.Hours() / 24)
711-
hours := int(d.Hours()) % 24
712-
713-
if days > 30 {
714-
return fmt.Sprintf("%dd", days)
715-
} else if days > 0 {
716-
return fmt.Sprintf("%dd %dh", days, hours)
717-
} else if hours > 0 {
718-
return fmt.Sprintf("%dh", hours)
719-
} else {
720-
minutes := int(d.Minutes())
721-
return fmt.Sprintf("%dm", minutes)
722-
}
723-
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Follow-up: verify-oas-coverage.sh Script Issues
2+
3+
**Date**: 2025-12-11
4+
**Related Feature**: 012-unified-health-status
5+
**Priority**: Low (script works but output is misleading)
6+
7+
## Issue
8+
9+
The `scripts/verify-oas-coverage.sh` script has issues that cause misleading output:
10+
11+
1. **Endpoint prefix mismatch**: The script extracts routes without the `/api/v1` prefix, but the OAS file documents them with the full path. This causes false "missing" reports.
12+
13+
2. **Uppercase artifact**: The sed command `\U\1` uppercases the HTTP method but also adds a "U" prefix to the path (e.g., `UGet /config` instead of `GET /config`).
14+
15+
3. **Coverage calculation is incorrect**: Shows "Coverage: 100%" even when listing 37 "missing" endpoints because the comparison logic doesn't properly match routes.
16+
17+
## Example Output (Problematic)
18+
19+
```
20+
❌ Missing OAS documentation for:
21+
UDelete /{name}
22+
UGet /config
23+
...
24+
25+
📊 Coverage Statistics:
26+
Total endpoints: 37
27+
Documented: 37
28+
Missing: 37
29+
Coverage: 100.0% <-- This is wrong
30+
```
31+
32+
## Root Cause
33+
34+
The route extraction from Go files captures relative paths like `/config`, but the OAS file documents them as `/api/v1/config`. The comparison fails to match these.
35+
36+
## Fix Applied (Partial)
37+
38+
Fixed the bash syntax error where inline comments appeared after backslash line continuations (lines 45-47). This was causing "command not found" errors.
39+
40+
## Remaining Work
41+
42+
1. Update the sed pattern to not add "U" prefix to paths
43+
2. Either:
44+
- Add `/api/v1` prefix to extracted routes, OR
45+
- Strip `/api/v1` prefix from OAS paths for comparison
46+
3. Fix the coverage calculation logic
47+
48+
## Verification Steps
49+
50+
```bash
51+
# Run the script and verify:
52+
# 1. No "command not found" errors
53+
# 2. Routes show as "GET /api/v1/config" not "UGet /config"
54+
# 3. Coverage percentage matches actual missing count
55+
./scripts/verify-oas-coverage.sh
56+
```
57+
58+
## Files Affected
59+
60+
- `scripts/verify-oas-coverage.sh`

frontend/src/components/ServerCard.vue

Lines changed: 98 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,14 @@
1010
</p>
1111
</div>
1212

13-
<!-- Status indicator -->
13+
<!-- Status indicator using unified health status -->
1414
<div
1515
:class="[
1616
'badge badge-sm flex-shrink-0',
17-
server.connected ? 'badge-success' :
18-
server.connecting ? 'badge-warning' :
19-
'badge-error'
17+
statusBadgeClass
2018
]"
2119
>
22-
{{ server.connected ? 'Connected' : server.connecting ? 'Connecting' : 'Disconnected' }}
20+
{{ statusText }}
2321
</div>
2422
</div>
2523

@@ -65,39 +63,58 @@
6563
<span class="text-xs">Server is quarantined</span>
6664
</div>
6765

68-
<!-- Actions -->
66+
<!-- Actions - uses unified health.action when available -->
6967
<div class="card-actions justify-end space-x-2">
68+
<!-- Primary action button based on health.action -->
7069
<button
71-
v-if="server.quarantined"
70+
v-if="healthAction === 'approve'"
7271
@click="unquarantine"
7372
:disabled="loading"
7473
class="btn btn-sm btn-warning"
7574
>
7675
<span v-if="loading" class="loading loading-spinner loading-xs"></span>
77-
Unquarantine
76+
Approve
7877
</button>
7978

80-
<!-- Restart button only for stdio servers (HTTP servers use Login/Reconnect) -->
8179
<button
82-
v-if="!server.connected && server.enabled && !isHttpProtocol"
83-
@click="restart"
80+
v-if="healthAction === 'enable'"
81+
@click="enableServer"
8482
:disabled="loading"
85-
class="btn btn-sm btn-outline"
83+
class="btn btn-sm btn-primary"
8684
>
8785
<span v-if="loading" class="loading loading-spinner loading-xs"></span>
88-
Restart
86+
Enable
8987
</button>
9088

9189
<button
92-
v-if="needsOAuth"
90+
v-if="healthAction === 'login'"
9391
@click="triggerOAuth"
9492
:disabled="loading"
95-
class="btn btn-sm btn-outline"
93+
class="btn btn-sm btn-primary"
9694
>
9795
<span v-if="loading" class="loading loading-spinner loading-xs"></span>
9896
Login
9997
</button>
10098

99+
<button
100+
v-if="healthAction === 'restart'"
101+
@click="restart"
102+
:disabled="loading"
103+
class="btn btn-sm btn-primary"
104+
>
105+
<span v-if="loading" class="loading loading-spinner loading-xs"></span>
106+
Restart
107+
</button>
108+
109+
<router-link
110+
v-if="healthAction === 'view_logs'"
111+
:to="`/servers/${server.name}?tab=logs`"
112+
class="btn btn-sm btn-primary"
113+
>
114+
View Logs
115+
</router-link>
116+
117+
<!-- Logout button (only when connected with OAuth) -->
101118
<button
102119
v-if="canLogout"
103120
@click="triggerLogout"
@@ -178,6 +195,52 @@ const isHttpProtocol = computed(() => {
178195
return props.server.protocol === 'http' || props.server.protocol === 'streamable-http'
179196
})
180197
198+
// Unified health status computed properties
199+
const statusBadgeClass = computed(() => {
200+
const health = props.server.health
201+
if (health) {
202+
// Use admin_state for disabled/quarantined, otherwise use health level
203+
switch (health.admin_state) {
204+
case 'disabled':
205+
return 'badge-neutral' // gray
206+
case 'quarantined':
207+
return 'badge-secondary' // purple-ish
208+
default:
209+
// Use health level
210+
switch (health.level) {
211+
case 'healthy':
212+
return 'badge-success'
213+
case 'degraded':
214+
return 'badge-warning'
215+
case 'unhealthy':
216+
return 'badge-error'
217+
default:
218+
return 'badge-ghost'
219+
}
220+
}
221+
}
222+
// Fallback to legacy logic
223+
if (props.server.connected) return 'badge-success'
224+
if (props.server.connecting) return 'badge-warning'
225+
return 'badge-error'
226+
})
227+
228+
const statusText = computed(() => {
229+
const health = props.server.health
230+
if (health) {
231+
return health.summary || health.level
232+
}
233+
// Fallback to legacy logic
234+
if (props.server.connected) return 'Connected'
235+
if (props.server.connecting) return 'Connecting'
236+
return 'Disconnected'
237+
})
238+
239+
// Suggested action from health status
240+
const healthAction = computed(() => {
241+
return props.server.health?.action || ''
242+
})
243+
181244
const needsOAuth = computed(() => {
182245
// Don't show Login button if server is disabled
183246
if (!props.server.enabled) return false
@@ -305,6 +368,26 @@ async function toggleEnabled() {
305368
}
306369
}
307370
371+
async function enableServer() {
372+
loading.value = true
373+
try {
374+
await serversStore.enableServer(props.server.name)
375+
systemStore.addToast({
376+
type: 'success',
377+
title: 'Server Enabled',
378+
message: `${props.server.name} has been enabled`,
379+
})
380+
} catch (error) {
381+
systemStore.addToast({
382+
type: 'error',
383+
title: 'Enable Failed',
384+
message: error instanceof Error ? error.message : 'Unknown error',
385+
})
386+
} finally {
387+
loading.value = false
388+
}
389+
}
390+
308391
async function restart() {
309392
loading.value = true
310393
try {

frontend/src/types/contracts.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ export interface APIResponse<T = any> {
88
error?: string;
99
}
1010

11+
export interface HealthStatus {
12+
level: 'healthy' | 'degraded' | 'unhealthy';
13+
admin_state: 'enabled' | 'disabled' | 'quarantined';
14+
summary: string;
15+
detail?: string;
16+
action?: 'login' | 'restart' | 'enable' | 'approve' | 'view_logs' | '';
17+
}
18+
1119
export interface Server {
1220
id: string;
1321
name: string;
@@ -37,6 +45,7 @@ export interface Server {
3745
oauth_status?: 'authenticated' | 'expired' | 'error' | 'none'; // OAuth authentication status
3846
token_expires_at?: string; // ISO date string when OAuth token expires
3947
user_logged_out?: boolean; // True if user explicitly logged out (prevents auto-reconnection)
48+
health?: HealthStatus; // Unified health status calculated by the backend
4049
}
4150

4251
export interface OAuthConfig {

0 commit comments

Comments
 (0)