diff --git a/cmd/generate-types/main.go b/cmd/generate-types/main.go index 29d258f64..d7ba2f1de 100644 --- a/cmd/generate-types/main.go +++ b/cmd/generate-types/main.go @@ -147,6 +147,56 @@ export interface HealthStatus { actions: HealthAction[]; } +// Needs-attention list (Spec 109 FR-001-007) - generated from +// internal/contracts/attention.go. One list, one count, every surface (Web +// UI, macOS tray/Home, CLI) reads from GET /api/v1/attention. +export const AttentionKindSignInRequired = 'sign_in_required' as const; +export const AttentionKindMissingSecret = 'missing_secret' as const; +export const AttentionKindConfigError = 'config_error' as const; +export const AttentionKindServerError = 'server_error' as const; +export const AttentionKindServerReview = 'server_review' as const; +export const AttentionKindToolReview = 'tool_review' as const; +export const AttentionKindClientNeverSeen = 'client_never_seen' as const; + +export type AttentionKind = + | typeof AttentionKindSignInRequired + | typeof AttentionKindMissingSecret + | typeof AttentionKindConfigError + | typeof AttentionKindServerError + | typeof AttentionKindServerReview + | typeof AttentionKindToolReview + | typeof AttentionKindClientNeverSeen; + +export interface AttentionSubject { + type: 'server' | 'tool' | 'client'; + id: string; + name: string; +} + +export interface AttentionFix { + verb: string; + label: string; + target: string; +} + +export interface AttentionItem { + /** Stable: kind:type:subject[:state]. */ + id: string; + kind: AttentionKind; + rank: number; + subject: AttentionSubject; + summary: string; + detail?: string; + fix: AttentionFix; + since: string; // ISO date string +} + +export interface AttentionResponse { + count: number; + generated_at: string; // ISO date string + items: AttentionItem[]; +} + `) // Activity status vocabulary - generated from internal/storage/activity_models.go diff --git a/cmd/mcpproxy/attention_cmd.go b/cmd/mcpproxy/attention_cmd.go new file mode 100644 index 000000000..8e1c50fd3 --- /dev/null +++ b/cmd/mcpproxy/attention_cmd.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + + clioutput "github.com/smart-mcp-proxy/mcpproxy-go/internal/cli/output" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient" +) + +// GetAttentionCommand returns the `mcpproxy attention` cobra command +// (Spec 109 FR-001/FR-003): the one needs-attention list, read verbatim from +// GET /api/v1/attention — the same list the Web UI Home page, the macOS tray +// and Home section, and the first line of `status`/first section of `doctor` +// all render. +func GetAttentionCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "attention", + Short: "Show the needs-attention list", + Long: `Show the one needs-attention list every MCPProxy surface reads from: +sign-in prompts, quarantine/tool reviews, connection errors, missing secrets, +configuration errors, and clients that connected but were never seen. + +This is a report, not a health check: it always exits 0, whether the list is +empty or not. Use 'mcpproxy doctor' for a full diagnostic pass. + +Examples: + mcpproxy attention + mcpproxy attention -o json`, + RunE: runAttention, + } + return cmd +} + +func runAttention(_ *cobra.Command, _ []string) error { + cfg, err := loadCLIConfig(configFile) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client, ok := newDaemonClient(cfg, nil) + if !ok { + return fmt.Errorf("attention requires running daemon. Start with: mcpproxy serve") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resp, err := client.GetAttention(ctx) + if err != nil { + return fmt.Errorf("failed to get attention list from daemon: %w", err) + } + + return printAttentionOutput(resp) +} + +func printAttentionOutput(resp *cliclient.AttentionResponse) error { + format := ResolveOutputFormat() + + // Review finding F5: the formatter must be constructed (and its format + // validated) unconditionally, before either output path below — not only + // on the non-empty branch. Building it here first means an invalid/typo'd + // -o value is rejected the same way whether the daemon has 0 or 100 + // attention items, instead of a typo silently succeeding with "All clear" + // whenever the instance happens to be healthy. + formatter, err := GetOutputFormatter() + if err != nil { + return clioutput.NewStructuredError(clioutput.ErrCodeInvalidOutputFormat, err.Error()). + WithGuidance("Use -o table, -o json, or -o yaml") + } + + if format == "json" || format == "yaml" { + out, err := formatter.Format(resp) + if err != nil { + return fmt.Errorf("failed to format output: %w", err) + } + fmt.Println(out) + return nil + } + + if resp.Count == 0 { + fmt.Println("All clear") + return nil + } + + headers := []string{"#", "KIND", "SUBJECT", "SUMMARY", "FIX"} + rows := make([][]string, len(resp.Items)) + for i, item := range resp.Items { + rows[i] = []string{ + fmt.Sprintf("%d", i+1), + item.Kind, + item.Subject.Name, + item.Summary, + item.Fix.Label, + } + } + + out, err := formatter.FormatTable(headers, rows) + if err != nil { + return fmt.Errorf("failed to format table: %w", err) + } + fmt.Print(out) + return nil +} diff --git a/cmd/mcpproxy/attention_cmd_test.go b/cmd/mcpproxy/attention_cmd_test.go new file mode 100644 index 000000000..d1914f609 --- /dev/null +++ b/cmd/mcpproxy/attention_cmd_test.go @@ -0,0 +1,172 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" +) + +func sampleAttentionResponse() *cliclient.AttentionResponse { + return &cliclient.AttentionResponse{ + Count: 2, + GeneratedAt: "2026-09-25T06:12:03Z", + Items: []contracts.AttentionItem{ + { + ID: "sign_in_required:server:github", + Kind: "sign_in_required", + Rank: 10, + Subject: contracts.AttentionSubject{Type: "server", ID: "github", Name: "github"}, + Summary: "github: sign in required", + Fix: contracts.AttentionFix{Verb: "login", Label: "Sign in", Target: "/servers/github"}, + }, + { + ID: "server_review:server:github", + Kind: "server_review", + Rank: 50, + Subject: contracts.AttentionSubject{Type: "server", ID: "github", Name: "github"}, + Summary: "github: waiting for review", + Fix: contracts.AttentionFix{Verb: "review", Label: "Review", Target: "/review/github"}, + }, + }, + } +} + +// TestAttentionCmd_TableOutput pins T056: `mcpproxy attention` table columns +// are exactly `# KIND SUBJECT SUMMARY FIX` (contracts/cli.md). +func TestAttentionCmd_TableOutput(t *testing.T) { + setOutputGlobals(t, "table", false) + output := captureOutput(func() { + if err := printAttentionOutput(sampleAttentionResponse()); err != nil { + t.Fatalf("printAttentionOutput: %v", err) + } + }) + + for _, want := range []string{"KIND", "SUBJECT", "SUMMARY", "FIX", "sign_in_required", "github", "Sign in", "server_review", "Review"} { + if !strings.Contains(output, want) { + t.Errorf("table output missing %q, got:\n%s", want, output) + } + } +} + +// TestAttentionCmd_JSONOutput pins T056: `-o json` round-trips the exact +// contracts/rest-api.md#attention shape. +func TestAttentionCmd_JSONOutput(t *testing.T) { + setOutputGlobals(t, "json", false) + output := captureOutput(func() { + if err := printAttentionOutput(sampleAttentionResponse()); err != nil { + t.Fatalf("printAttentionOutput: %v", err) + } + }) + + var decoded cliclient.AttentionResponse + if err := json.Unmarshal([]byte(output), &decoded); err != nil { + t.Fatalf("json output did not parse: %v\noutput: %s", err, output) + } + if decoded.Count != 2 || len(decoded.Items) != 2 { + t.Errorf("unexpected decoded response: %+v", decoded) + } + if decoded.Items[0].Fix.Verb != "login" { + t.Errorf("expected first item's fix verb 'login', got %q", decoded.Items[0].Fix.Verb) + } +} + +// TestAttentionCmd_AllClear pins T056: an empty list prints "All clear" and +// the command still succeeds (it is a report, not a check — FR-001). +func TestAttentionCmd_AllClear(t *testing.T) { + setOutputGlobals(t, "table", false) + output := captureOutput(func() { + if err := printAttentionOutput(&cliclient.AttentionResponse{Count: 0, Items: []contracts.AttentionItem{}}); err != nil { + t.Fatalf("printAttentionOutput: %v", err) + } + }) + if strings.TrimSpace(output) != "All clear" { + t.Errorf("expected exactly %q, got %q", "All clear", strings.TrimSpace(output)) + } +} + +// TestAttentionCmd_InvalidFormatRejectedEvenWhenAllClear pins review finding +// F5: printAttentionOutput's "All clear" early-return (0 items) used to run +// before any formatter was constructed, so a typo'd -o value silently +// succeeded with exit 0 and "All clear" — while the identical typo against a +// non-empty list correctly failed. Format validation must not depend on +// whether there happen to be any items. +func TestAttentionCmd_InvalidFormatRejectedEvenWhenAllClear(t *testing.T) { + setOutputGlobals(t, "jso", false) + err := printAttentionOutput(&cliclient.AttentionResponse{Count: 0, Items: []contracts.AttentionItem{}}) + if err == nil { + t.Fatal("expected an error for an invalid output format, got nil") + } + if !strings.Contains(err.Error(), "unknown output format") { + t.Errorf("expected 'unknown output format' error, got: %v", err) + } +} + +// TestStatusTable_NeedsAttentionFirstLine pins T056/contracts/cli.md: the +// first line after the "MCPProxy Status" header is the FR-001 attention +// count, in both the non-zero and "none" shapes. +func TestStatusTable_NeedsAttentionFirstLine(t *testing.T) { + nonZero := 3 + output := captureOutput(func() { + printStatusTable(&StatusInfo{State: "Running", Edition: "personal", NeedsAttention: &nonZero}) + }) + lines := strings.Split(strings.TrimRight(output, "\n"), "\n") + if len(lines) < 2 || lines[0] != "MCPProxy Status" { + t.Fatalf("expected header first, got: %q", lines) + } + if lines[1] != "Needs attention: 3 (run 'mcpproxy attention')" { + t.Errorf("unexpected second line: %q", lines[1]) + } + + zero := 0 + output = captureOutput(func() { + printStatusTable(&StatusInfo{State: "Running", Edition: "personal", NeedsAttention: &zero}) + }) + lines = strings.Split(strings.TrimRight(output, "\n"), "\n") + if len(lines) < 2 || lines[1] != "Needs attention: none" { + t.Errorf("unexpected second line for zero count: %q", lines) + } + + // An older daemon / unreachable endpoint (nil) omits the line entirely + // rather than lying with a count of 0. + output = captureOutput(func() { + printStatusTable(&StatusInfo{State: "Running", Edition: "personal"}) + }) + if strings.Contains(output, "Needs attention") { + t.Errorf("expected no attention line when NeedsAttention is nil, got: %q", output) + } +} + +// TestDoctorAttentionSection_FirstAndDiagnosticsWording pins T056: doctor's +// first section is "Needs attention (N)" from GET /attention, and the old +// "Found N issues that need attention" line is now "Diagnostics: N findings" +// (FR-004 — "need attention" refers only to the FR-001 count). +func TestDoctorAttentionSection_FirstAndDiagnosticsWording(t *testing.T) { + doctorOutput = "pretty" + diag := map[string]interface{}{ + "total_issues": 1, + "oauth_required": []interface{}{"github"}, + } + output := captureOutput(func() { + if err := outputDiagnostics(diag, nil, nil, "", sampleAttentionResponse()); err != nil { + t.Fatalf("outputDiagnostics: %v", err) + } + }) + + attentionIdx := strings.Index(output, "Needs attention (2)") + diagnosticsIdx := strings.Index(output, "Diagnostics: 1 findings") + if attentionIdx == -1 { + t.Fatalf("missing 'Needs attention (2)' section, got:\n%s", output) + } + if diagnosticsIdx == -1 { + t.Fatalf("missing 'Diagnostics: 1 findings' line, got:\n%s", output) + } + if attentionIdx > diagnosticsIdx { + t.Errorf("attention section must come first, got attention@%d diagnostics@%d", attentionIdx, diagnosticsIdx) + } + if strings.Contains(output, "Found 1") { + t.Errorf("stale 'Found N issues' wording must not appear, got:\n%s", output) + } +} diff --git a/cmd/mcpproxy/doctor_cmd.go b/cmd/mcpproxy/doctor_cmd.go index b6be15864..30a3afdc1 100644 --- a/cmd/mcpproxy/doctor_cmd.go +++ b/cmd/mcpproxy/doctor_cmd.go @@ -13,6 +13,7 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient" "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" "github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck" ) @@ -128,6 +129,15 @@ func runDoctorClientMode(ctx context.Context, client *cliclient.Client, logger * return fmt.Errorf("failed to get diagnostics from daemon: %w", err) } + // Spec 109 FR-004: doctor's first section is the one needs-attention list + // every surface reads (FR-001). Non-fatal: an older or unreachable + // endpoint just omits the section rather than failing the whole command. + attention, attErr := client.GetAttention(ctx) + if attErr != nil { + logger.Debug("Failed to get attention list from daemon", zap.Error(attErr)) + attention = nil + } + // Collect quarantine stats from servers quarantineStats := collectQuarantineStats(ctx, client, logger) @@ -156,9 +166,26 @@ func runDoctorClientMode(ctx context.Context, client *cliclient.Client, logger * if doctorServerFilter != "" { diag = filterDiagnosticsByServer(diag, doctorServerFilter) quarantineStats = filterQuarantineStatsByServer(quarantineStats, doctorServerFilter) + attention = filterAttentionByServer(attention, doctorServerFilter) } - return outputDiagnostics(diag, info, quarantineStats, envHint) + return outputDiagnostics(diag, info, quarantineStats, envHint, attention) +} + +// filterAttentionByServer narrows an attention response to the items whose +// subject is the requested server. A single-server doctor run is not +// interested in another server's attention items, or in client items at all. +func filterAttentionByServer(resp *cliclient.AttentionResponse, serverName string) *cliclient.AttentionResponse { + if resp == nil { + return nil + } + items := make([]contracts.AttentionItem, 0, len(resp.Items)) + for _, it := range resp.Items { + if it.Subject.Type == "server" && it.Subject.ID == serverName { + items = append(items, it) + } + } + return &cliclient.AttentionResponse{Count: len(items), GeneratedAt: resp.GeneratedAt, Items: items} } // filterDiagnosticsByServer returns a shallow copy of the diagnostics @@ -271,7 +298,27 @@ func collectQuarantineStats(ctx context.Context, client *cliclient.Client, logge return stats } -func outputDiagnostics(diag map[string]interface{}, info map[string]interface{}, quarantineStats []quarantineServerStats, envHint string) error { +// printDoctorAttentionSection prints doctor's first section (FR-004): the +// same needs-attention list FR-001 computes, rendered identically to +// `mcpproxy attention`'s table rows. nil (endpoint unreachable, e.g. an +// older daemon) prints nothing — the diagnostics sections below still run. +func printDoctorAttentionSection(attention *cliclient.AttentionResponse) { + if attention == nil { + return + } + fmt.Printf("📋 Needs attention (%d)\n", attention.Count) + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + if attention.Count == 0 { + fmt.Println("All clear") + } else { + for _, item := range attention.Items { + fmt.Printf(" [%s] %s (%s)\n", item.Kind, item.Summary, item.Fix.Label) + } + } + fmt.Println() +} + +func outputDiagnostics(diag map[string]interface{}, info map[string]interface{}, quarantineStats []quarantineServerStats, envHint string, attention *cliclient.AttentionResponse) error { switch doctorOutput { case "json": // Combine diagnostics with info for JSON output @@ -287,6 +334,9 @@ func outputDiagnostics(diag map[string]interface{}, info map[string]interface{}, if envHint != "" { combined["environment_warnings"] = []string{envHint} } + if attention != nil { + combined["attention"] = attention + } output, err := json.MarshalIndent(combined, "", " ") if err != nil { return fmt.Errorf("failed to format output: %w", err) @@ -336,7 +386,17 @@ func outputDiagnostics(diag map[string]interface{}, info map[string]interface{}, } fmt.Println() - if totalIssues == 0 { + // Spec 109 FR-004: doctor's first section, from the same GET + // /attention every other surface reads (FR-001/FR-003). + printDoctorAttentionSection(attention) + + // Fix review finding: the "all clear" verdict must also require the + // FR-001 attention list (a separate counter from `total_issues`) to + // be empty, or a quarantined server / tool awaiting review with no + // other diagnostics findings prints two contradicting verdicts back + // to back — exactly what FR-004 exists to prevent. + attentionClear := attention == nil || attention.Count == 0 + if totalIssues == 0 && attentionClear { fmt.Println("✅ All systems operational! No issues detected.") fmt.Println() @@ -359,12 +419,10 @@ func outputDiagnostics(diag map[string]interface{}, info map[string]interface{}, return nil } - // Show issue summary - issueWord := "issue" - if totalIssues > 1 { - issueWord = "issues" - } - fmt.Printf("⚠️ Found %d %s that need attention\n", totalIssues, issueWord) + // Show issue summary. FR-004: "need attention" refers only to the + // FR-001 attention count above; the diagnostics count below is a + // different, separately named number. + fmt.Printf("Diagnostics: %d findings\n", totalIssues) fmt.Println() // 1. Upstream Connection Errors diff --git a/cmd/mcpproxy/doctor_cmd_test.go b/cmd/mcpproxy/doctor_cmd_test.go index c9be6c25d..a23d81322 100644 --- a/cmd/mcpproxy/doctor_cmd_test.go +++ b/cmd/mcpproxy/doctor_cmd_test.go @@ -8,7 +8,9 @@ import ( "strings" "testing" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient" "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" "github.com/smart-mcp-proxy/mcpproxy-go/internal/socket" ) @@ -31,7 +33,7 @@ func TestOutputDiagnostics_JSONFormat(t *testing.T) { defer func() { os.Stdout = oldStdout }() doctorOutput = "json" - err := outputDiagnostics(diag, nil, nil, "") + err := outputDiagnostics(diag, nil, nil, "", nil) w.Close() var buf bytes.Buffer @@ -70,7 +72,7 @@ func TestOutputDiagnostics_PrettyFormat_NoIssues(t *testing.T) { defer func() { os.Stdout = oldStdout }() doctorOutput = "pretty" - err := outputDiagnostics(diag, nil, nil, "") + err := outputDiagnostics(diag, nil, nil, "", nil) w.Close() var buf bytes.Buffer @@ -90,6 +92,61 @@ func TestOutputDiagnostics_PrettyFormat_NoIssues(t *testing.T) { } } +// TestOutputDiagnostics_PrettyFormat_ZeroDiagnosticsButAttentionPending +// covers a review finding on Spec 109 FR-004: doctor must not print the +// "All systems operational" verdict when the FR-001 attention list (a +// separate counter from the diagnostics `total_issues`) still has pending +// items, e.g. a quarantined server awaiting review with no other +// diagnostics findings. It must also still print the "Diagnostics: N +// findings" section rather than skipping it via the zero-issue early +// return. +func TestOutputDiagnostics_PrettyFormat_ZeroDiagnosticsButAttentionPending(t *testing.T) { + diag := map[string]interface{}{ + "total_issues": 0, + } + attention := &cliclient.AttentionResponse{ + Count: 1, + Items: []contracts.AttentionItem{ + { + Kind: "server_review", + Summary: "github: waiting for review", + Fix: contracts.AttentionFix{Label: "Review"}, + }, + }, + } + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + defer func() { os.Stdout = oldStdout }() + + doctorOutput = "pretty" + err := outputDiagnostics(diag, nil, nil, "", attention) + + w.Close() + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + if err != nil { + t.Errorf("outputDiagnostics() returned error: %v", err) + } + + if strings.Contains(output, "All systems operational") { + t.Error("doctor must not claim all-clear while the attention list has pending items") + } + if !strings.Contains(output, "📋 Needs attention (1)") { + t.Error("Expected the FR-004 attention section header") + } + if !strings.Contains(output, "server_review") { + t.Error("Expected the pending attention item to be printed") + } + if !strings.Contains(output, "Diagnostics: 0 findings") { + t.Error("Expected the Diagnostics section to still print even with zero diagnostics findings") + } +} + func TestOutputDiagnostics_PrettyFormat_WithUpstreamErrors(t *testing.T) { diag := map[string]interface{}{ "total_issues": 2, @@ -112,7 +169,7 @@ func TestOutputDiagnostics_PrettyFormat_WithUpstreamErrors(t *testing.T) { defer func() { os.Stdout = oldStdout }() doctorOutput = "pretty" - err := outputDiagnostics(diag, nil, nil, "") + err := outputDiagnostics(diag, nil, nil, "", nil) w.Close() var buf bytes.Buffer @@ -168,7 +225,7 @@ func TestOutputDiagnostics_PrettyFormat_WithOAuthRequired(t *testing.T) { defer func() { os.Stdout = oldStdout }() doctorOutput = "pretty" - err := outputDiagnostics(diag, nil, nil, "") + err := outputDiagnostics(diag, nil, nil, "", nil) w.Close() var buf bytes.Buffer @@ -214,7 +271,7 @@ func TestOutputDiagnostics_PrettyFormat_WithMissingSecrets(t *testing.T) { defer func() { os.Stdout = oldStdout }() doctorOutput = "pretty" - err := outputDiagnostics(diag, nil, nil, "") + err := outputDiagnostics(diag, nil, nil, "", nil) w.Close() var buf bytes.Buffer @@ -256,7 +313,7 @@ func TestOutputDiagnostics_PrettyFormat_WithRuntimeWarnings(t *testing.T) { defer func() { os.Stdout = oldStdout }() doctorOutput = "pretty" - err := outputDiagnostics(diag, nil, nil, "") + err := outputDiagnostics(diag, nil, nil, "", nil) w.Close() var buf bytes.Buffer @@ -310,7 +367,7 @@ func TestOutputDiagnostics_PrettyFormat_MultipleIssueTypes(t *testing.T) { defer func() { os.Stdout = oldStdout }() doctorOutput = "pretty" - err := outputDiagnostics(diag, nil, nil, "") + err := outputDiagnostics(diag, nil, nil, "", nil) w.Close() var buf bytes.Buffer @@ -335,12 +392,14 @@ func TestOutputDiagnostics_PrettyFormat_MultipleIssueTypes(t *testing.T) { t.Error("Missing warnings section") } - // Verify issue count + // Verify issue count. Spec 109 FR-004: "need attention" refers only to + // the FR-001 attention count; doctor's own diagnostics count is now + // worded "Diagnostics: N findings" (no singular/plural distinction). if !strings.Contains(output, "5") { t.Error("Missing total issue count") } - if !strings.Contains(output, "issues") { - t.Error("Should use plural 'issues' for count > 1") + if !strings.Contains(output, "Diagnostics: 5 findings") { + t.Error("Should print 'Diagnostics: N findings'") } } @@ -357,7 +416,7 @@ func TestOutputDiagnostics_PrettyFormat_SingleIssue(t *testing.T) { defer func() { os.Stdout = oldStdout }() doctorOutput = "pretty" - err := outputDiagnostics(diag, nil, nil, "") + err := outputDiagnostics(diag, nil, nil, "", nil) w.Close() var buf bytes.Buffer @@ -368,9 +427,9 @@ func TestOutputDiagnostics_PrettyFormat_SingleIssue(t *testing.T) { t.Errorf("outputDiagnostics() returned error: %v", err) } - // Should use singular "issue" not "issues" - if !strings.Contains(output, "1 issue") { - t.Error("Should use singular 'issue' for count = 1") + // Spec 109 FR-004: "Diagnostics: N findings", not "issue(s)". + if !strings.Contains(output, "Diagnostics: 1 findings") { + t.Error("Should print 'Diagnostics: 1 findings'") } } @@ -387,7 +446,7 @@ func TestOutputDiagnostics_EmptyFormat(t *testing.T) { // Empty string should default to pretty format doctorOutput = "" - err := outputDiagnostics(diag, nil, nil, "") + err := outputDiagnostics(diag, nil, nil, "", nil) w.Close() var buf bytes.Buffer @@ -552,7 +611,7 @@ func TestOutputDiagnostics_WarningWithoutTitle(t *testing.T) { defer func() { os.Stdout = oldStdout }() doctorOutput = "pretty" - err := outputDiagnostics(diag, nil, nil, "") + err := outputDiagnostics(diag, nil, nil, "", nil) w.Close() var buf bytes.Buffer @@ -587,7 +646,7 @@ func TestOutputDiagnostics_HighSeverityWarning(t *testing.T) { defer func() { os.Stdout = oldStdout }() doctorOutput = "pretty" - err := outputDiagnostics(diag, nil, nil, "") + err := outputDiagnostics(diag, nil, nil, "", nil) w.Close() var buf bytes.Buffer @@ -625,7 +684,7 @@ func TestOutputDiagnostics_SecretWithoutOptionalFields(t *testing.T) { defer func() { os.Stdout = oldStdout }() doctorOutput = "pretty" - err := outputDiagnostics(diag, nil, nil, "") + err := outputDiagnostics(diag, nil, nil, "", nil) w.Close() var buf bytes.Buffer @@ -666,7 +725,7 @@ func TestOutputDiagnostics_MissingSecretsRealJSON(t *testing.T) { defer func() { os.Stdout = oldStdout }() doctorOutput = "pretty" - err := outputDiagnostics(diag, nil, nil, "") + err := outputDiagnostics(diag, nil, nil, "", nil) w.Close() var buf bytes.Buffer diff --git a/cmd/mcpproxy/main.go b/cmd/mcpproxy/main.go index c7742ac4b..30f671986 100644 --- a/cmd/mcpproxy/main.go +++ b/cmd/mcpproxy/main.go @@ -185,6 +185,9 @@ func main() { // Add status command statusCmd := GetStatusCommand() + // Add attention command (Spec 109 FR-001/FR-003) + attentionCmd := GetAttentionCommand() + // Add token command (Spec 028: Agent tokens) tokenCmd := GetTokenCommand() @@ -218,6 +221,7 @@ func main() { rootCmd.AddCommand(activityCmd) rootCmd.AddCommand(tuiCmd) rootCmd.AddCommand(statusCmd) + rootCmd.AddCommand(attentionCmd) rootCmd.AddCommand(tokenCmd) rootCmd.AddCommand(telemetryCmd) rootCmd.AddCommand(dbCmd) diff --git a/cmd/mcpproxy/status_cmd.go b/cmd/mcpproxy/status_cmd.go index 574efad8f..cf6c032d3 100644 --- a/cmd/mcpproxy/status_cmd.go +++ b/cmd/mcpproxy/status_cmd.go @@ -38,6 +38,11 @@ type StatusInfo struct { LaunchedBy string `json:"launched_by,omitempty"` // Spec 092 FR-001a; empty = user-launched/unknown or older daemon Update *StatusUpdateInfo `json:"update,omitempty"` ServerEditionInfo *ServerEditionStatusInfo `json:"server_edition,omitempty"` + // NeedsAttention is the FR-001 attention count (Spec 109), the same + // number `mcpproxy attention` and every other surface reads. nil when the + // daemon could not be reached or is too old to serve GET /attention — + // contracts/cli.md's first line is then omitted rather than printed as 0. + NeedsAttention *int `json:"needs_attention,omitempty"` // TokenSavings is nil when the daemon could not be reached, the caller is // not an administrator (agent token), or the tokenizer is unavailable — // the "Token savings:" summary line (Spec 109-k, contracts/cli.md) is then @@ -264,6 +269,14 @@ func collectStatusFromDaemon(cfg *config.Config, client *cliclient.Client, socke } } + // Spec 109 FR-001/FR-003: the same needs-attention count every surface + // reads. Non-fatal — an older daemon (or a transient failure) just omits + // the first line rather than failing the whole status call. + if attResp, attErr := client.GetAttention(ctx); attErr == nil { + count := attResp.Count + info.NeedsAttention = &count + } + // Get info data (version, web_ui_url) infoData, err := client.GetInfo(ctx) if err == nil { @@ -579,6 +592,16 @@ func printStatusJSON(info *StatusInfo) error { func printStatusTable(info *StatusInfo) { fmt.Println("MCPProxy Status") + // Spec 109 FR-001/FR-003 (contracts/cli.md): fixed first line after the + // header, so parallel PRs' own lines land at a stable position. + if info.NeedsAttention != nil { + if *info.NeedsAttention > 0 { + fmt.Printf("Needs attention: %d (run 'mcpproxy attention')\n", *info.NeedsAttention) + } else { + fmt.Println("Needs attention: none") + } + } + fmt.Printf(" %-12s %s\n", "State:", info.State) fmt.Printf(" %-12s %s\n", "Edition:", info.Edition) diff --git a/docs/features/needs-attention.md b/docs/features/needs-attention.md new file mode 100644 index 000000000..98864c007 --- /dev/null +++ b/docs/features/needs-attention.md @@ -0,0 +1,97 @@ +--- +id: needs-attention +title: Needs Attention +sidebar_label: Needs Attention +sidebar_position: 15 +description: The one needs-attention list every MCPProxy surface reads from — sign-in prompts, reviews, errors, missing secrets, configuration problems and unseen clients. +keywords: [attention, needs attention, health, quarantine, review, sign in, dashboard, home, tray, cli, doctor] +--- + +# Needs Attention + +MCPProxy computes **one** needs-attention list from in-memory state — server +health, tool approval counts, connect status, session history — and every +surface renders it verbatim: the Web UI's Home page, the macOS tray's "Needs +Attention" group and Home section, and the CLI's `attention` command, the +first line of `status`, and the first section of `doctor`. + +No surface derives its own version of this list. Before this list existed, +the Web UI Dashboard, the macOS tray and the CLI each had their own predicate +over server health, and the three disagreed about what counted as "needs +attention" — a quarantined server with a transport fault, for example, could +show as an error on one surface and a review item on another. This page is +the parity table for how the list is computed, its kinds, ranks and fixes. + +## Where it lives + +| Surface | Location | +|---|---| +| REST API | `GET /api/v1/attention` | +| Web UI | Home page (`/`) attention list, header pill (hidden at 0), sidebar Home badge | +| macOS tray | "Needs Attention (N)" menu group, Home section (first, above the topology) | +| CLI | `mcpproxy attention`; first line of `mcpproxy status`; first section of `mcpproxy doctor` | +| SSE | `attention.changed` event (`{count, ids}`, narrowed per subscriber) | + +## Kinds, ranks and fixes + +The list is sorted by rank ascending, then by subject name. Each item's `id` +is stable (`kind:type:subject[:state]`), so a surface can diff successive +lists instead of re-rendering the whole thing on every change. + +| Kind | Rank | Condition | Fix | +|---|---|---|---| +| `sign_in_required` | 10 | `health.status == sign_in_required` (includes a quarantined OAuth server, or a failed token refresh) | `login` → opens the server, which offers Sign in | +| `missing_secret` | 20 | `health.status == needs_secret` | `set_secret` → opens the server's secret form | +| `config_error` | 30 | `health.status == needs_config` | `configure`/`edit_url` → opens the server's Configuration tab, field focused | +| `server_error` | 40 | Not quarantined, and `health.status` has been `error` or `connecting` for ≥ 60 seconds | `restart` → the server menu action; logs are one click away | +| `server_review` | 50 | `admin_state == quarantined` (and enabled) | `review` → opens the review location — never a one-click approve | +| `tool_review` | 60 (changed) / 61 (pending) | A trusted server with ≥ 1 tool in that approval state; one item per state, so a server with both is two items | `review` → opens the review location, filtered to that state | +| `client_never_seen` | 70 | A client MCPProxy recorded as connected ≥ 5 minutes ago, with no MCP session observed since | `reload_hint` → shows how to restart the client | + +Never an item: a disabled server, a server `connecting` for under 60 seconds, +a `ready` server whatever its proactive `actions` (a login nudge for a token +expiring soon is not a blocking state), and update availability (that has its +own nudge). A quarantined server is *only* a `server_review` item, never also +`server_error` — restarting a server before it is reviewed fixes nothing, and +the review screen already shows the transport error. + +Conditions key on `health.status` and `admin_state`, never on the presence of +an entry in `actions` — `actions` also carries proactive nudges on a +perfectly usable server (the same `status`/`usable`/`actions` vocabulary every +surface renders `health` through) and cannot by itself distinguish a blocking +state from a hint. + +## Fixes are never a one-click approve + +A `review` fix always **opens a screen**; it never calls an approve or +unquarantine endpoint directly from the list. Reviewing a quarantined server +or a changed/pending tool is a decision a person makes on that server's own +review screen, with the diff or the transport error in front of them — not a +button on a summary row. This is the same rule the macOS tray already +enforced for its per-server actions, extended to every surface. + +## Live updates + +The backend recomputes the list (debounced) whenever the underlying state +changes, and separately arms a timer for the earliest pending time-based +threshold — a server crossing from `connecting` to `server_error` at 60 +seconds, or a client crossing into `client_never_seen` at 5 minutes — so the +list is correct even on a quiet instance where nothing else happens to +trigger a recompute. Every surface holding a live connection (the Web UI and +the macOS tray, both over Server-Sent Events) receives an `attention.changed` +notification and refreshes; the CLI and `GET /api/v1/attention` always read +the current computed list. + +Testing the `client_never_seen` threshold live means waiting out the real 5 +minutes unless it is shrunk first: set the test-only environment variable +`MCPPROXY_ATTENTION_NEVER_SEEN_AFTER` (e.g. `5s`) before starting the daemon +to override the threshold for that run. An unset or unparseable value keeps +the 5-minute default; there is no equivalent hook for the 60-second +`server_error` threshold. + +## Scoped callers + +An administrator (the API key, the local socket, or a server-edition admin +user) sees every item. A scoped caller — an agent token, or a non-admin +server-edition user session — sees only items whose subject is a server it +may enumerate, and never sees a client item at all. diff --git a/frontend/src/components/AttentionList.vue b/frontend/src/components/AttentionList.vue new file mode 100644 index 000000000..593c7f334 --- /dev/null +++ b/frontend/src/components/AttentionList.vue @@ -0,0 +1,55 @@ + + + diff --git a/frontend/src/components/SidebarNav.vue b/frontend/src/components/SidebarNav.vue index d7627f925..9722ca1ca 100644 --- a/frontend/src/components/SidebarNav.vue +++ b/frontend/src/components/SidebarNav.vue @@ -180,18 +180,32 @@ - + @@ -481,6 +495,7 @@ import { useSystemStore } from '@/stores/system' import { formatDateTime } from '@/utils/datetime' import { useAuthStore } from '@/stores/auth' import { useOnboardingStore } from '@/stores/onboarding' +import { useAttentionStore } from '@/stores/attention' import api from '@/services/api' const route = useRoute() @@ -488,6 +503,7 @@ const router = useRouter() const systemStore = useSystemStore() const authStore = useAuthStore() const onboardingStore = useOnboardingStore() +const attentionStore = useAttentionStore() // Spec 046 v2: badge count drives the sidebar Setup entry's pulse + count. // Refetched on mount; the wizard itself drives subsequent updates while open. @@ -542,12 +558,19 @@ function loadBadgeCounts() { onMounted(() => { // Pull initial state so the badge is correct on first render. loadBadgeCounts() + // Spec 109 FR-001/FR-003: the sidebar is global (outside the Home view), + // so it fetches its own copy rather than depending on Home having mounted + // first — the badge must be correct on every page, not only "/". + attentionStore.fetchAttention() }) // #1065: the sidebar sits outside , so App.vue's authEpoch key // cannot remount it. Without this, badge counts that failed while auth was // broken keep their stale values until a full page reload. -watch(() => systemStore.authEpoch, loadBadgeCounts) +watch(() => systemStore.authEpoch, () => { + loadBadgeCounts() + attentionStore.fetchAttention() +}) const collapsed = computed(() => systemStore.sidebarCollapsed) @@ -717,13 +740,14 @@ const userInitials = computed(() => { return name.substring(0, 2).toUpperCase() }) -// Dashboard panels are deep-linkable routes that all render the Dashboard, so -// the "Dashboard" entry stays highlighted on each of them. -const DASHBOARD_PATHS = ['/', '/usage', '/overview'] +// /overview redirects to / (Spec 109 FR-051), so both paths keep the "Home" +// entry highlighted — the redirect target is what the route ends up on, but +// this stays correct even mid-navigation. +const HOME_PATHS = ['/', '/overview'] function isActiveRoute(path: string): boolean { if (path === '/') { - return DASHBOARD_PATHS.includes(route.path) + return HOME_PATHS.includes(route.path) } return route.path.startsWith(path) } diff --git a/frontend/src/components/TopHeader.vue b/frontend/src/components/TopHeader.vue index 183e260e9..594c40ba9 100644 --- a/frontend/src/components/TopHeader.vue +++ b/frontend/src/components/TopHeader.vue @@ -86,6 +86,52 @@ looked like agent scoping while only setting a UI default. --> + +
+ +
+
+ + {{ item.summary }} + +
+ + See all + +
+ +
+
+
{ + // Spec 109 FR-001/FR-003: the header is global, so it fetches its own copy + // rather than depending on Home having mounted first. + attentionStore.fetchAttention() // App only mounts the shell after canLoadCore. The personal branch keeps // isolated component consumers and tests working without inventing a server // session; it is never reached during the browser's pending startup path. diff --git a/frontend/src/components/UsageSummaryStrip.vue b/frontend/src/components/UsageSummaryStrip.vue new file mode 100644 index 000000000..3dbf472a3 --- /dev/null +++ b/frontend/src/components/UsageSummaryStrip.vue @@ -0,0 +1,85 @@ + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 5c33a9f69..c2389b639 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,5 +1,5 @@ import { createRouter, createWebHistory, type NavigationGuard } from 'vue-router' -import Dashboard from '@/views/Dashboard.vue' +import Home from '@/views/Home.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -13,39 +13,31 @@ const router = createRouter({ }, // Existing routes (admin/personal) // - // The landing page (`/`) opens the Dashboard on its Overview (hub) panel. - // Spec 109 FR-051 / research D3 reverses #1044's Usage-first landing: new - // users kept landing on empty charts (audit N8). This is the 109-a interim - // step only — Home (needs-attention + topology + a usage strip) replaces - // this route in 109-d. `/usage` and `/overview` render the same Dashboard - // component so each panel is deep-linkable and survives a reload; - // `meta.dashboardView` is what the component reads to pick the active panel. + // Spec 109 FR-051: `/` renders Home — the needs-attention list, the + // topology (formerly Dashboard.vue's "Overview" panel) and a usage + // summary strip. `/usage` is the full Usage page in its own right + // (Usage.vue, no longer wrapped by Dashboard's panel switcher). + // `/overview` redirects to `/` — Home replaces the standalone Overview + // panel. `Dashboard.vue` is retired. { path: '/', - name: 'dashboard', - component: Dashboard, + name: 'home', + component: Home, meta: { - title: 'Dashboard', - dashboardView: 'overview', + title: 'Home', }, }, { path: '/usage', name: 'usage', - component: Dashboard, + component: () => import('@/views/Usage.vue'), meta: { title: 'Usage Analytics', - dashboardView: 'usage', }, }, { path: '/overview', - name: 'dashboard-overview', - component: Dashboard, - meta: { - title: 'Overview', - dashboardView: 'overview', - }, + redirect: { name: 'home' }, }, { path: '/servers', @@ -263,7 +255,7 @@ export const authGuard: NavigationGuard = async (to) => { if (!authStore.isTeamsEdition) { // Don't show server routes in personal edition if (to.path === '/login' || to.path.startsWith('/my/') || to.path.startsWith('/admin/')) { - return { name: 'dashboard' } + return { name: 'home' } } // Update title for personal edition const title = to.meta.title as string @@ -276,7 +268,7 @@ export const authGuard: NavigationGuard = async (to) => { // Public routes (login) - redirect to dashboard if already authenticated if (to.meta.public) { if (authStore.isAuthenticated) { - return { name: 'dashboard' } + return { name: 'home' } } return } @@ -288,7 +280,7 @@ export const authGuard: NavigationGuard = async (to) => { // Admin-only routes if (to.meta.requiresAdmin && !authStore.isAdmin) { - return { name: 'dashboard' } + return { name: 'home' } } // Update title diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0182a2e74..47d727d8d 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,4 +1,4 @@ -import type { APIResponse, Server, Tool, ToolApproval, SearchResult, StatusUpdate, StatusResponse, SecretRef, MigrationAnalysis, ConfigSecretsResponse, GetToolCallsResponse, GetToolCallDetailResponse, GetServerToolCallsResponse, GetConfigResponse, ValidateConfigResponse, ConfigApplyResult, ServerTokenMetrics, GetRegistriesResponse, SearchRegistryServersResponse, RegistrySummary, CatalogSearchResponse, GetSessionsResponse, GetSessionDetailResponse, InfoResponse, ActivityListResponse, ActivityDetailResponse, ActivityRecord, ActivitySummaryResponse, ImportResponse, AgentTokenInfo, CreateAgentTokenRequest, CreateAgentTokenResponse, RoutingInfo, ConnectStatusResponse, ClientStatus, ConnectResult, ConnectPreview, OnboardingStateResponse, OnboardingMarkRequest, DiagnosticFixResponse, GlobalToolsResponse, UsageAggregateResponse, UsageWindow, UsageSort, UsageStatus, ListProfilesResponse, ActiveProfileResponse } from '@/types' +import type { APIResponse, Server, Tool, ToolApproval, SearchResult, StatusUpdate, StatusResponse, SecretRef, MigrationAnalysis, ConfigSecretsResponse, GetToolCallsResponse, GetToolCallDetailResponse, GetServerToolCallsResponse, GetConfigResponse, ValidateConfigResponse, ConfigApplyResult, ServerTokenMetrics, GetRegistriesResponse, SearchRegistryServersResponse, RegistrySummary, CatalogSearchResponse, GetSessionsResponse, GetSessionDetailResponse, InfoResponse, ActivityListResponse, ActivityDetailResponse, ActivityRecord, ActivitySummaryResponse, ImportResponse, AgentTokenInfo, CreateAgentTokenRequest, CreateAgentTokenResponse, RoutingInfo, ConnectStatusResponse, ClientStatus, ConnectResult, ConnectPreview, OnboardingStateResponse, OnboardingMarkRequest, DiagnosticFixResponse, GlobalToolsResponse, UsageAggregateResponse, UsageWindow, UsageSort, UsageStatus, ListProfilesResponse, ActiveProfileResponse, AttentionResponse } from '@/types' import { joinHoldEvidence, type HoldEvidenceSource } from '@/utils/holdEvidence' @@ -281,6 +281,11 @@ class APIService { }) } + // Needs-attention list (Spec 109 FR-001): the one list every surface reads. + async getAttention(): Promise> { + return this.request('/api/v1/attention') + } + // Server endpoints async getServers(): Promise> { return this.request<{ servers: Server[] }>('/api/v1/servers') diff --git a/frontend/src/stores/attention.ts b/frontend/src/stores/attention.ts new file mode 100644 index 000000000..bc21dc360 --- /dev/null +++ b/frontend/src/stores/attention.ts @@ -0,0 +1,96 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { AttentionItem, LoadingState } from '@/types' +import api from '@/services/api' + +// Spec 109 FR-001/FR-003: the one needs-attention list, read verbatim from +// GET /api/v1/attention and kept live over SSE attention.changed. Every +// surface (Home list, header pill, sidebar Home badge) reads this store — +// none of them re-derives its own predicate (FR-003 removes +// Dashboard.vue's local `serversNeedingAttention`). +export const useAttentionStore = defineStore('attention', () => { + const items = ref([]) + const loading = ref({ loading: false, error: null }) + + // True once the list has been fetched successfully at least once, so a + // consumer can tell "no items" from "we don't know yet" (mirrors + // servers.ts `loaded`). + const loaded = ref(false) + + const count = computed(() => items.value.length) + + // Three independent, uncancelled callers issue a fetch: SidebarNav.vue and + // TopHeader.vue each fetch onMounted, AttentionList.vue fetches onMounted + // too, and `attention.changed` over SSE triggers a silent refetch. None of + // them cancel the others, so a response already in flight when a newer one + // lands must not be able to overwrite it — the same ticket guard + // servers.ts uses for its own multi-writer list (see servers.ts + // issueSeq/appliedSeq). + let issueSeq = 0 + let appliedSeq = 0 + + // A request settles when it produces an outcome — a list OR a failure, both + // advance the mark, otherwise an older request's failure could still land + // after a newer success and get accepted as if it were news. + function claimTicket(ticket: number): boolean { + if (ticket < appliedSeq) return false + appliedSeq = ticket + return true + } + + async function fetchAttention(silent = false) { + if (!silent) { + loading.value = { loading: true, error: null } + } + const ticket = ++issueSeq + try { + const response = await api.getAttention() + if (response.success && response.data) { + if (!claimTicket(ticket)) return + items.value = response.data.items ?? [] + loaded.value = true + loading.value = { loading: false, error: null } + } else { + throw new Error(response.error || 'Failed to load attention list') + } + } catch (error) { + console.error('Failed to fetch attention list:', error) + if (!claimTicket(ticket)) return + if (!silent) { + loading.value = { loading: false, error: error instanceof Error ? error.message : 'Unknown error' } + } + } + } + + function handleAttentionChanged(_event: Event) { + // The SSE payload is already narrowed to {count, ids} per caller + // (FR-006) — refetch the full item shape (summaries, fixes) rather than + // reconstructing it from ids. Silent so a background change never flashes + // a loading state over the list a user is reading. + fetchAttention(true) + } + + function setupEventListeners() { + window.addEventListener('mcpproxy:attention-changed', handleAttentionChanged) + } + + function cleanupEventListeners() { + window.removeEventListener('mcpproxy:attention-changed', handleAttentionChanged) + } + + setupEventListeners() + + return { + // State + items, + loading, + loaded, + + // Computed + count, + + // Actions + fetchAttention, + cleanupEventListeners, + } +}) diff --git a/frontend/src/stores/system.ts b/frontend/src/stores/system.ts index 5f2c0b0fe..7de2ff8b8 100644 --- a/frontend/src/stores/system.ts +++ b/frontend/src/stores/system.ts @@ -198,6 +198,19 @@ export const useSystemStore = defineStore('system', () => { if (!isCurrentSource()) return connected.value = true console.log('EventSource connected successfully') + + // Review finding: FR-002's threshold-crossing attention events + // (server_error, client_never_seen, …) fire only once, at the moment + // the threshold is crossed. A drop that spans that moment loses the + // frame forever, and the header pill / sidebar badge / Home list stay + // wrong until an unrelated change happens to fire a fresh event. Every + // (re)connect — the initial one and every retry after `onerror` — is + // exactly the point a missed event could have been lost, so resync by + // re-dispatching the same window event the live `attention.changed` + // handler below dispatches; the attention store's own handler ignores + // the detail and just refetches (silent), so an extra one on first + // connect is harmless. + window.dispatchEvent(new CustomEvent('mcpproxy:attention-changed')) } es.onmessage = (event) => { @@ -254,6 +267,19 @@ export const useSystemStore = defineStore('system', () => { } }) + // Listen for attention.changed events (Spec 109 FR-001/FR-006). The + // rendered payload is already narrowed to {count, ids} per caller — the + // attention store refetches GET /attention for the full item shape + // (summaries, fixes) rather than reconstructing it from ids here. + es.addEventListener('attention.changed', (event) => { + try { + const data = JSON.parse(event.data) + window.dispatchEvent(new CustomEvent('mcpproxy:attention-changed', { detail: data })) + } catch (error) { + console.error('Failed to parse SSE attention.changed event:', error) + } + }) + // Listen for config.reloaded events es.addEventListener('config.reloaded', (event) => { try { diff --git a/frontend/src/types/contracts.ts b/frontend/src/types/contracts.ts index 301aa066d..4b2e82f3e 100644 --- a/frontend/src/types/contracts.ts +++ b/frontend/src/types/contracts.ts @@ -102,6 +102,56 @@ export interface HealthStatus { actions: HealthAction[]; } +// Needs-attention list (Spec 109 FR-001-007) - generated from +// internal/contracts/attention.go. One list, one count, every surface (Web +// UI, macOS tray/Home, CLI) reads from GET /api/v1/attention. +export const AttentionKindSignInRequired = 'sign_in_required' as const; +export const AttentionKindMissingSecret = 'missing_secret' as const; +export const AttentionKindConfigError = 'config_error' as const; +export const AttentionKindServerError = 'server_error' as const; +export const AttentionKindServerReview = 'server_review' as const; +export const AttentionKindToolReview = 'tool_review' as const; +export const AttentionKindClientNeverSeen = 'client_never_seen' as const; + +export type AttentionKind = + | typeof AttentionKindSignInRequired + | typeof AttentionKindMissingSecret + | typeof AttentionKindConfigError + | typeof AttentionKindServerError + | typeof AttentionKindServerReview + | typeof AttentionKindToolReview + | typeof AttentionKindClientNeverSeen; + +export interface AttentionSubject { + type: 'server' | 'tool' | 'client'; + id: string; + name: string; +} + +export interface AttentionFix { + verb: string; + label: string; + target: string; +} + +export interface AttentionItem { + /** Stable: kind:type:subject[:state]. */ + id: string; + kind: AttentionKind; + rank: number; + subject: AttentionSubject; + summary: string; + detail?: string; + fix: AttentionFix; + since: string; // ISO date string +} + +export interface AttentionResponse { + count: number; + generated_at: string; // ISO date string + items: AttentionItem[]; +} + // Activity status vocabulary - generated from internal/storage/activity_models.go export const ActivityStatusSuccess = 'success' as const; export const ActivityStatusError = 'error' as const; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a5b7043fa..c7a9fdaf0 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -29,6 +29,20 @@ export type { APIError, APISuccess, APIResult, + AttentionKind, + AttentionSubject, + AttentionFix, + AttentionItem, + AttentionResponse, +} from './contracts' +export { + AttentionKindSignInRequired, + AttentionKindMissingSecret, + AttentionKindConfigError, + AttentionKindServerError, + AttentionKindServerReview, + AttentionKindToolReview, + AttentionKindClientNeverSeen, } from './contracts' export { isAPIError, isAPISuccess } from './contracts' diff --git a/frontend/src/utils/health.ts b/frontend/src/utils/health.ts index 44ab82ea3..a5a0ca636 100644 --- a/frontend/src/utils/health.ts +++ b/frontend/src/utils/health.ts @@ -129,7 +129,8 @@ export function healthStatusTextOrEmpty(health: { summary?: string | null; statu * (round-4 review finding — AdminServers.vue/UserServers.vue already had a * fallback for a payload carrying neither `status` nor `summary`; * ServerCard.vue/ServerDetail.vue did not, which was an asymmetric guard - * against the same gap). + * against the same gap). Never falls back to `health.level` as text + * (FR-011). * * @param health - the server's health object (caller handles the no-health case) * @param connected - the server's legacy `connected` field, for the last-resort fallback diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Home.vue similarity index 65% rename from frontend/src/views/Dashboard.vue rename to frontend/src/views/Home.vue index afa24ca03..cf6ecbbbd 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Home.vue @@ -6,209 +6,20 @@ - - - -
- -
-

{{ serversNeedingAttention.length }} server{{ serversNeedingAttention.length !== 1 ? 's' : '' }} need{{ serversNeedingAttention.length === 1 ? 's' : '' }} attention

-
-
- - {{ server.name }} - - {{ server.health?.summary }} - - - - - Set Secret - - - Configure - - - - Edit URL - -
-
- ... and {{ serversNeedingAttention.length - 3 }} more -
-
-
- - - View All Servers - -
- - -
- - - -
-

{{ totalPendingTools }} tool{{ totalPendingTools !== 1 ? 's' : '' }} pending approval across {{ serversWithPendingTools.length }} server{{ serversWithPendingTools.length !== 1 ? 's' : '' }}

-
-
- ● - {{ entry.serverName }} - {{ entry.count }} tool{{ entry.count !== 1 ? 's' : '' }} pending -
-
- ... and {{ serversWithPendingTools.length - 5 }} more server{{ serversWithPendingTools.length - 5 !== 1 ? 's' : '' }} -
-
-
- - Review Tools - -
- - -
- Usage - Overview -
- - -
- -
-
- - - -

Add your first server to see usage

-

- No upstream MCP servers are configured yet, so there is nothing to chart. - Add one and this page will show call volume, token sinks, error rates and a timeline. -

-
- - - Browse Registry - -
-
-
- -
- -
- - - - -
- - -
+ + + + + + + +
@@ -465,7 +276,7 @@ - + Security Scan Run first scan {{ securityTotalFindings }} issue{{ securityTotalFindings === 1 ? '' : 's' }} @@ -543,7 +354,9 @@
- + + + @@ -554,11 +367,12 @@