mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
feat(profiles): add AI-assisted profile suggestions
Add ability for users to describe what kind of agent profile they need in natural language, and have AI generate a suggestion with name, description, config values, and rationale. - Add ProfileSuggestionHandler with schema-aware prompting - Add SuggestProfileModal component with example prompts - Update AgentProfilesPanel with suggest button and description field - Streamline ValidConfigKeys to only agent-supported settings - Update profile validation tests for simplified schema
This commit is contained in:
+17
-13
@@ -1,20 +1,24 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.schema.json",
|
||||
"mcp": {
|
||||
"pulse": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:0",
|
||||
"description": "Pulse infrastructure tools - URL is set dynamically at runtime"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"tools": ["pulse_*"]
|
||||
"tools": [
|
||||
"pulse_*"
|
||||
]
|
||||
},
|
||||
"instructions": [
|
||||
"You are Pulse's AI assistant for infrastructure monitoring and management.",
|
||||
"Use pulse_* tools to interact with the monitored infrastructure.",
|
||||
"Be concise and direct in responses.",
|
||||
"Focus on actionable insights and solutions."
|
||||
"You have access to pulse_* MCP tools. ALWAYS use them for infrastructure questions:",
|
||||
"- pulse_get_infrastructure_state: Get all VMs, containers, hosts",
|
||||
"- pulse_get_active_alerts: Get current alerts and warnings",
|
||||
"- pulse_get_metrics_history: Get CPU/memory/disk history for resources",
|
||||
"- pulse_get_resource_details: Get details for a specific VM/container",
|
||||
"- pulse_get_baselines: Get learned normal behavior",
|
||||
"- pulse_get_patterns: Get detected patterns and predictions",
|
||||
"- pulse_get_disk_health: Get SMART data and disk status",
|
||||
"- pulse_get_storage: Get storage pool information",
|
||||
"- pulse_run_command: Execute commands on managed hosts",
|
||||
"When asked about infrastructure, VMs, containers, alerts, metrics, or system status, ALWAYS use pulse_* tools.",
|
||||
"Do NOT use webfetch for infrastructure questions - use the MCP tools.",
|
||||
"Be concise and direct. Focus on actionable insights."
|
||||
]
|
||||
}
|
||||
}
|
||||
+44
-1
@@ -901,18 +901,32 @@ func initKubernetesWithRetry(ctx context.Context, cfg kubernetesagent.Config, lo
|
||||
|
||||
// applyRemoteSettings merges remote settings into the local configuration.
|
||||
// Supported keys:
|
||||
// - enable_host (bool)
|
||||
// - enable_docker (bool)
|
||||
// - enable_kubernetes (bool)
|
||||
// - enable_proxmox (bool)
|
||||
// - proxmox_type (string)
|
||||
// - docker_runtime (string)
|
||||
// - disable_auto_update (bool)
|
||||
// - disable_docker_update_checks (bool)
|
||||
// - kube_include_all_pods (bool)
|
||||
// - kube_include_all_deployments (bool)
|
||||
// - log_level (string)
|
||||
// - interval (string/duration)
|
||||
// - report_ip (string)
|
||||
// - disable_ceph (bool)
|
||||
func applyRemoteSettings(cfg *Config, settings map[string]interface{}, logger *zerolog.Logger) {
|
||||
for k, v := range settings {
|
||||
switch k {
|
||||
case "enable_host":
|
||||
if b, ok := v.(bool); ok {
|
||||
cfg.EnableHost = b
|
||||
logger.Info().Bool("val", b).Msg("Remote config: enable_host")
|
||||
}
|
||||
case "enable_docker":
|
||||
if b, ok := v.(bool); ok {
|
||||
cfg.EnableDocker = b
|
||||
cfg.DockerConfigured = true
|
||||
logger.Info().Bool("val", b).Msg("Remote config: enable_docker")
|
||||
}
|
||||
case "enable_kubernetes":
|
||||
@@ -927,9 +941,18 @@ func applyRemoteSettings(cfg *Config, settings map[string]interface{}, logger *z
|
||||
}
|
||||
case "proxmox_type":
|
||||
if s, ok := v.(string); ok {
|
||||
cfg.ProxmoxType = s
|
||||
normalized := strings.TrimSpace(strings.ToLower(s))
|
||||
if normalized == "auto" {
|
||||
normalized = ""
|
||||
}
|
||||
cfg.ProxmoxType = normalized
|
||||
logger.Info().Str("val", s).Msg("Remote config: proxmox_type")
|
||||
}
|
||||
case "docker_runtime":
|
||||
if s, ok := v.(string); ok {
|
||||
cfg.DockerRuntime = strings.TrimSpace(strings.ToLower(s))
|
||||
logger.Info().Str("val", s).Msg("Remote config: docker_runtime")
|
||||
}
|
||||
case "log_level":
|
||||
if s, ok := v.(string); ok {
|
||||
if l, err := zerolog.ParseLevel(s); err == nil {
|
||||
@@ -952,6 +975,26 @@ func applyRemoteSettings(cfg *Config, settings map[string]interface{}, logger *z
|
||||
cfg.Interval = time.Duration(f) * time.Second
|
||||
logger.Info().Float64("val", f).Msg("Remote config: interval (s)")
|
||||
}
|
||||
case "disable_auto_update":
|
||||
if b, ok := v.(bool); ok {
|
||||
cfg.DisableAutoUpdate = b
|
||||
logger.Info().Bool("val", b).Msg("Remote config: disable_auto_update")
|
||||
}
|
||||
case "disable_docker_update_checks":
|
||||
if b, ok := v.(bool); ok {
|
||||
cfg.DisableDockerUpdateChecks = b
|
||||
logger.Info().Bool("val", b).Msg("Remote config: disable_docker_update_checks")
|
||||
}
|
||||
case "kube_include_all_pods":
|
||||
if b, ok := v.(bool); ok {
|
||||
cfg.KubeIncludeAllPods = b
|
||||
logger.Info().Bool("val", b).Msg("Remote config: kube_include_all_pods")
|
||||
}
|
||||
case "kube_include_all_deployments":
|
||||
if b, ok := v.(bool); ok {
|
||||
cfg.KubeIncludeAllDeployments = b
|
||||
logger.Info().Bool("val", b).Msg("Remote config: kube_include_all_deployments")
|
||||
}
|
||||
case "report_ip":
|
||||
if s, ok := v.(string); ok {
|
||||
cfg.ReportIP = s
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
AGENTS AI SCOPE PROFILE PLAN
|
||||
|
||||
Context
|
||||
- Agent profiles exist today and are managed via AgentProfilesAPI.
|
||||
- Current UI copy implied AI could auto-create profiles, but that flow does not exist.
|
||||
- Goal: add AI-assisted suggestions that are always reviewed and explicitly created by the user.
|
||||
|
||||
Goals
|
||||
- Provide an AI "Suggest profile" flow that drafts a scope profile and explains the rationale.
|
||||
- Keep user in control: no auto-creation, no auto-assignment, no silent changes.
|
||||
- Integrate with existing profile CRUD (AgentProfilesAPI.createProfile / assignProfile).
|
||||
|
||||
Non-Goals
|
||||
- No background or automatic profile creation.
|
||||
- No automatic assignment to agents.
|
||||
- No backend changes to agent config schema in this phase.
|
||||
|
||||
Proposed UX
|
||||
Entry points
|
||||
- Agent Profiles page: "Suggest profile" button next to "New Profile".
|
||||
- Optional: Unified Agents table row action "Suggest profile from this agent" to seed context.
|
||||
|
||||
Flow
|
||||
1) User clicks "Suggest profile".
|
||||
2) Modal opens with:
|
||||
- Prompt text area (optional) + default prompt template.
|
||||
- Scope of inputs toggles (agent telemetry, current profile examples, etc.).
|
||||
3) AI returns a draft:
|
||||
- name
|
||||
- description (why this profile exists)
|
||||
- config JSON
|
||||
- rationale bullets (what signals led to the configuration)
|
||||
4) User reviews and can edit name/description/config.
|
||||
5) User clicks "Create profile".
|
||||
6) Optional follow-up: "Assign to agents" (separate explicit action).
|
||||
|
||||
UX requirements
|
||||
- Clear labels: "Suggest" or "Draft", never "Auto-create".
|
||||
- Show JSON in a code editor-style area with validation errors.
|
||||
- "Create profile" disabled until JSON validates.
|
||||
- Provide copy/export for the config JSON.
|
||||
|
||||
Data inputs (minimal viable)
|
||||
- User prompt text.
|
||||
- Selected agent IDs (if starting from an agent).
|
||||
- Basic agent metadata: hostname, platform, versions, tags, types (host/docker/k8s).
|
||||
|
||||
Data inputs (nice to have)
|
||||
- Recent health signals: last seen, status, error flags.
|
||||
- Existing profile list to avoid duplicates and suggest edits.
|
||||
- Links to the agent config schema (so suggestions are valid).
|
||||
|
||||
AI output contract (server side)
|
||||
- A stable JSON envelope with:
|
||||
- name: string
|
||||
- description: string
|
||||
- config: object
|
||||
- rationale: string[]
|
||||
- If model returns invalid JSON, backend should retry once and then return a friendly error.
|
||||
|
||||
API proposal
|
||||
- POST /api/admin/profiles/suggestions
|
||||
- body: { prompt, agentIds?: string[], includeTelemetry?: boolean }
|
||||
- response: { name, description, config, rationale }
|
||||
- This endpoint can be a thin wrapper around the internal LLM service.
|
||||
- If not licensed, return 402 and the UI should show the Pro gating.
|
||||
|
||||
Frontend plan
|
||||
1) Add "Suggest profile" button to AgentProfilesPanel.
|
||||
2) Create SuggestProfileModal component:
|
||||
- prompt input
|
||||
- loading state
|
||||
- response preview (name, description, rationale)
|
||||
- editable config text area with validation
|
||||
3) On "Create profile", call AgentProfilesAPI.createProfile.
|
||||
4) On success, refresh profile list and show toast.
|
||||
5) Optional: allow "Assign to selected agents" step.
|
||||
|
||||
Backend plan (minimal)
|
||||
1) Add suggestion endpoint that:
|
||||
- Gathers agent context (if agentIds provided).
|
||||
- Calls LLM with template.
|
||||
- Returns validated JSON.
|
||||
2) Ensure prompt redaction of secrets/tokens.
|
||||
3) Log prompt usage for auditing (excluding secrets).
|
||||
|
||||
Safety and product constraints
|
||||
- Never apply changes without a user click.
|
||||
- Show a "This is a draft" warning.
|
||||
- Document that AI outputs are suggestions and may need adjustments.
|
||||
- Respect licensing (Pro feature) and return 402 if unlicensed.
|
||||
|
||||
Testing
|
||||
- Unit: modal renders, validation errors, createProfile call.
|
||||
- Unit: suggestion endpoint response mapping.
|
||||
- Integration: suggestion -> create profile -> list refresh.
|
||||
- Regression: no auto-assignment when suggestion is created.
|
||||
|
||||
Open questions
|
||||
- Which telemetry fields are safe/useful to include by default?
|
||||
- Should suggestions be limited to host agents only?
|
||||
- Do we need a schema-aware editor to reduce invalid configs?
|
||||
|
||||
---
|
||||
|
||||
## Implementation Summary (Completed)
|
||||
|
||||
### Backend Changes
|
||||
1. **New file**: `internal/api/profile_suggestions.go`
|
||||
- `ProfileSuggestionHandler` - handles AI-assisted profile suggestions
|
||||
- `SuggestionRequest` / `ProfileSuggestion` types for API contract
|
||||
- Prompt template includes available config keys and their types
|
||||
- Parses LLM JSON response and validates config
|
||||
|
||||
2. **Modified file**: `internal/api/config_profiles.go`
|
||||
- Added `suggestionHandler` field to `ConfigProfileHandler`
|
||||
- Added `SetAIHandler()` method to inject AI capability
|
||||
- Added routing for `POST /suggestions` in `ServeHTTP()`
|
||||
|
||||
3. **Modified file**: `internal/api/router.go`
|
||||
- Wired AI handler to profile handler: `r.configProfileHandler.SetAIHandler(r.aiHandler)`
|
||||
|
||||
### Frontend Changes
|
||||
1. **Modified file**: `frontend-modern/src/api/agentProfiles.ts`
|
||||
- Added `ProfileSuggestionRequest` and `ProfileSuggestion` interfaces
|
||||
- Added `suggestProfile()` method to `AgentProfilesAPI`
|
||||
|
||||
2. **New file**: `frontend-modern/src/components/Settings/SuggestProfileModal.tsx`
|
||||
- Modal with prompt input and example prompts
|
||||
- Loading state during AI call
|
||||
- Preview of suggested profile with name, description, config JSON, and rationale
|
||||
- "Draft" warning banner
|
||||
- "Use This Profile" button to accept suggestion
|
||||
|
||||
3. **Modified file**: `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`
|
||||
- Added "Suggest Profile" button (purple, with Sparkles icon) next to "New Profile"
|
||||
- Added `showSuggestModal` state
|
||||
- Added `handleSuggest()` and `handleSuggestionAccepted()` handlers
|
||||
- When suggestion is accepted, pre-fills the create profile form
|
||||
|
||||
### UX Flow
|
||||
1. User clicks "Suggest Profile" button
|
||||
2. Modal opens with prompt input and example prompts
|
||||
3. User describes what they need
|
||||
4. AI generates a profile suggestion
|
||||
5. User reviews name, description, config JSON, and rationale
|
||||
6. User clicks "Use This Profile"
|
||||
7. Modal closes, create profile form opens pre-filled with suggestion
|
||||
8. User can edit and then click "Create Profile"
|
||||
+12
-12
@@ -48,7 +48,7 @@ import { TokenRevealDialog } from './components/TokenRevealDialog';
|
||||
import { useAlertsActivation } from './stores/alertsActivation';
|
||||
import { UpdateProgressModal } from './components/UpdateProgressModal';
|
||||
import type { UpdateStatus } from './api/updates';
|
||||
import { AIChat } from './components/AI/AIChat';
|
||||
import { AIChat } from './components/AI/Chat';
|
||||
import { AIStatusIndicator } from './components/AI/AIStatusIndicator';
|
||||
import { aiChatStore } from './stores/aiChat';
|
||||
import { useResourcesAsLegacy } from './hooks/useResources';
|
||||
@@ -1194,17 +1194,17 @@ function AppLayout(props: {
|
||||
breakdown: { warning: number; critical: number } | undefined;
|
||||
icon: JSX.Element;
|
||||
}> = [
|
||||
{
|
||||
id: 'alerts',
|
||||
label: 'Alerts',
|
||||
route: '/alerts',
|
||||
tooltip: 'Review active alerts and automation rules',
|
||||
badge: null,
|
||||
count: activeAlertCount,
|
||||
breakdown,
|
||||
icon: <BellIcon class="w-4 h-4 shrink-0" />,
|
||||
},
|
||||
];
|
||||
{
|
||||
id: 'alerts',
|
||||
label: 'Alerts',
|
||||
route: '/alerts',
|
||||
tooltip: 'Review active alerts and automation rules',
|
||||
badge: null,
|
||||
count: activeAlertCount,
|
||||
breakdown,
|
||||
icon: <BellIcon class="w-4 h-4 shrink-0" />,
|
||||
},
|
||||
];
|
||||
|
||||
// Only show settings tab if user has access
|
||||
if (hasSettingsAccess) {
|
||||
|
||||
@@ -6,7 +6,9 @@ import { apiFetch, apiFetchJSON } from '@/utils/apiClient';
|
||||
export interface AgentProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
config: Record<string, unknown>;
|
||||
version?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -20,6 +22,23 @@ export interface AgentProfileAssignment {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request for AI-assisted profile suggestion.
|
||||
*/
|
||||
export interface ProfileSuggestionRequest {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI-generated profile suggestion.
|
||||
*/
|
||||
export interface ProfileSuggestion {
|
||||
name: string;
|
||||
description: string;
|
||||
config: Record<string, unknown>;
|
||||
rationale: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* API client for agent profiles (Pro feature).
|
||||
* Endpoints are gated behind license - returns 402 if not licensed.
|
||||
@@ -60,11 +79,11 @@ export class AgentProfilesAPI {
|
||||
/**
|
||||
* Create a new profile.
|
||||
*/
|
||||
static async createProfile(name: string, config: Record<string, unknown>): Promise<AgentProfile> {
|
||||
static async createProfile(name: string, config: Record<string, unknown>, description?: string): Promise<AgentProfile> {
|
||||
const response = await apiFetch(this.baseUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, config }),
|
||||
body: JSON.stringify({ name, description, config }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -78,11 +97,11 @@ export class AgentProfilesAPI {
|
||||
/**
|
||||
* Update an existing profile.
|
||||
*/
|
||||
static async updateProfile(id: string, name: string, config: Record<string, unknown>): Promise<AgentProfile> {
|
||||
static async updateProfile(id: string, name: string, config: Record<string, unknown>, description?: string): Promise<AgentProfile> {
|
||||
const response = await apiFetch(`${this.baseUrl}/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, name, config }),
|
||||
body: JSON.stringify({ id, name, description, config }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -151,4 +170,26 @@ export class AgentProfilesAPI {
|
||||
throw new Error(text || `Failed to unassign profile: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AI-assisted profile suggestion.
|
||||
* Requires AI to be enabled and running.
|
||||
*/
|
||||
static async suggestProfile(request: ProfileSuggestionRequest): Promise<ProfileSuggestion> {
|
||||
const response = await apiFetch(`${this.baseUrl}/suggestions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
if (response.status === 503) {
|
||||
throw new Error('AI service is not available. Please check AI settings.');
|
||||
}
|
||||
throw new Error(text || `Failed to get suggestion: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,23 +14,41 @@ interface ChatMessagesProps {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatMessages - Renders the scrollable message list.
|
||||
*
|
||||
* Features:
|
||||
* - Auto-scroll to bottom on new messages
|
||||
* - Empty state with suggestions
|
||||
* - Smooth scrolling behavior
|
||||
*/
|
||||
export const ChatMessages: Component<ChatMessagesProps> = (props) => {
|
||||
let messagesEndRef: HTMLDivElement | undefined;
|
||||
let containerRef: HTMLDivElement | undefined;
|
||||
|
||||
// Auto-scroll to bottom
|
||||
// Auto-scroll to bottom on new messages
|
||||
createEffect(() => {
|
||||
if (props.messages.length > 0 && messagesEndRef) {
|
||||
messagesEndRef.scrollIntoView({ behavior: 'smooth' });
|
||||
if (props.messages.length > 0 && messagesEndRef && containerRef) {
|
||||
// Only auto-scroll if user is near the bottom
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef;
|
||||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 150;
|
||||
|
||||
if (isNearBottom) {
|
||||
messagesEndRef.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="flex-1 overflow-y-auto px-4 py-3 bg-white dark:bg-slate-900"
|
||||
>
|
||||
{/* Empty state */}
|
||||
<Show when={props.messages.length === 0 && props.emptyState}>
|
||||
<div class="flex flex-col items-center justify-center h-full text-center py-12">
|
||||
{/* AI Icon */}
|
||||
<div class="w-16 h-16 mb-4 rounded-2xl bg-gradient-to-br from-purple-100 to-violet-100 dark:from-purple-900/30 dark:to-violet-900/30 flex items-center justify-center">
|
||||
<div class="w-16 h-16 mb-4 rounded-2xl bg-gradient-to-br from-purple-100 to-violet-100 dark:from-purple-900/30 dark:to-violet-900/30 flex items-center justify-center shadow-lg shadow-purple-500/10">
|
||||
<svg
|
||||
class="w-8 h-8 text-purple-500 dark:text-purple-400"
|
||||
fill="none"
|
||||
@@ -46,10 +64,10 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
<h3 class="text-base font-semibold text-slate-900 dark:text-slate-100 mb-1">
|
||||
{props.emptyState!.title}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 max-w-xs mb-6">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 max-w-xs mb-6">
|
||||
{props.emptyState!.subtitle}
|
||||
</p>
|
||||
|
||||
@@ -61,9 +79,10 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.emptyState!.onSuggestionClick?.(suggestion)}
|
||||
class="w-full text-left px-4 py-2.5 rounded-xl bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 text-sm hover:bg-purple-100 dark:hover:bg-purple-900/40 transition-colors border border-purple-100 dark:border-purple-800"
|
||||
class="w-full text-left px-4 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-sm hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors border border-slate-200 dark:border-slate-700 hover:border-purple-300 dark:hover:border-purple-700"
|
||||
>
|
||||
"{suggestion}"
|
||||
<span class="text-purple-500 dark:text-purple-400 mr-2">→</span>
|
||||
{suggestion}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
@@ -84,7 +103,7 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
|
||||
</For>
|
||||
|
||||
{/* Scroll anchor */}
|
||||
<div ref={messagesEndRef} />
|
||||
<div ref={messagesEndRef} class="h-1" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Component, Show, For, Switch, Match } from 'solid-js';
|
||||
import { Component, Show, For, Switch, Match, createMemo } from 'solid-js';
|
||||
import { renderMarkdown } from '../aiChatUtils';
|
||||
import { ThinkingBlock } from './ThinkingBlock';
|
||||
import { ToolExecutionBlock, PendingToolBlock } from './ToolExecutionBlock';
|
||||
import { ApprovalCard } from './ApprovalCard';
|
||||
import type { ChatMessage, PendingApproval } from './types';
|
||||
import type { ChatMessage, PendingApproval, StreamDisplayEvent } from './types';
|
||||
|
||||
interface MessageItemProps {
|
||||
message: ChatMessage;
|
||||
@@ -11,72 +11,152 @@ interface MessageItemProps {
|
||||
onSkip: (toolId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* MessageItem - Renders a single message in the chat.
|
||||
*
|
||||
* User messages: Compact, right-aligned bubble
|
||||
* Assistant messages: Full-width, terminal-like with clear sections
|
||||
*/
|
||||
export const MessageItem: Component<MessageItemProps> = (props) => {
|
||||
const isUser = () => props.message.role === 'user';
|
||||
|
||||
const hasStreamEvents = () =>
|
||||
props.message.streamEvents && props.message.streamEvents.length > 0;
|
||||
|
||||
// Group stream events for cleaner rendering
|
||||
// Combine consecutive content events, separate thinking and tools
|
||||
const groupedEvents = createMemo(() => {
|
||||
const events = props.message.streamEvents || [];
|
||||
const grouped: StreamDisplayEvent[] = [];
|
||||
|
||||
for (const evt of events) {
|
||||
// Thinking events are kept separate
|
||||
if (evt.type === 'thinking') {
|
||||
grouped.push(evt);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tool events are kept separate
|
||||
if (evt.type === 'tool') {
|
||||
grouped.push(evt);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Content events can be merged with previous content
|
||||
if (evt.type === 'content' && evt.content) {
|
||||
const lastIdx = grouped.length - 1;
|
||||
if (lastIdx >= 0 && grouped[lastIdx].type === 'content') {
|
||||
grouped[lastIdx] = {
|
||||
...grouped[lastIdx],
|
||||
content: (grouped[lastIdx].content || '') + evt.content,
|
||||
};
|
||||
} else {
|
||||
grouped.push(evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
});
|
||||
|
||||
// Check if currently streaming content (no tools pending, still streaming)
|
||||
const isStreamingText = () =>
|
||||
props.message.isStreaming &&
|
||||
(!props.message.pendingTools || props.message.pendingTools.length === 0);
|
||||
|
||||
return (
|
||||
<div class={`flex ${isUser() ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
class={`max-w-[90%] rounded-2xl overflow-hidden transition-all ${
|
||||
isUser()
|
||||
? 'bg-gradient-to-br from-purple-600 to-violet-600 text-white shadow-lg'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-md border border-gray-100 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div class="px-4 py-3">
|
||||
{/* User messages */}
|
||||
<Show when={isUser()}>
|
||||
<p class="text-sm whitespace-pre-wrap">{props.message.content}</p>
|
||||
</Show>
|
||||
<div class={`${isUser() ? 'flex justify-end' : ''} mb-4`}>
|
||||
{/* User message - compact bubble */}
|
||||
<Show when={isUser()}>
|
||||
<div class="max-w-[85%] px-4 py-2.5 rounded-2xl rounded-br-md bg-gradient-to-br from-purple-600 to-violet-600 text-white shadow-md">
|
||||
<p class="text-sm whitespace-pre-wrap">{props.message.content}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Assistant messages with stream events */}
|
||||
<Show when={!isUser()}>
|
||||
{/* Stream events (chronological order) */}
|
||||
<Show when={hasStreamEvents()}>
|
||||
<div class="space-y-3">
|
||||
<For each={props.message.streamEvents}>
|
||||
{(evt) => (
|
||||
<Switch>
|
||||
<Match when={evt.type === 'thinking' && evt.thinking}>
|
||||
<ThinkingBlock content={evt.thinking!} />
|
||||
</Match>
|
||||
<Match when={evt.type === 'tool' && evt.tool}>
|
||||
<ToolExecutionBlock tool={evt.tool!} />
|
||||
</Match>
|
||||
<Match when={evt.type === 'content' && evt.content}>
|
||||
<div
|
||||
class="text-sm prose prose-sm dark:prose-invert max-w-none prose-pre:bg-gray-800 prose-pre:text-gray-100 prose-code:text-purple-600 dark:prose-code:text-purple-400 prose-code:before:content-none prose-code:after:content-none"
|
||||
innerHTML={renderMarkdown(evt.content!)}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
{/* Assistant message - full width, terminal-like */}
|
||||
<Show when={!isUser()}>
|
||||
<div class="w-full">
|
||||
{/* Assistant indicator */}
|
||||
<div class="flex items-center gap-2 mb-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
<div class="w-5 h-5 rounded-md bg-gradient-to-br from-purple-500 to-violet-500 flex items-center justify-center">
|
||||
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-medium">Assistant</span>
|
||||
<Show when={props.message.model && !props.message.isStreaming}>
|
||||
<span class="text-[10px] text-slate-400 dark:text-slate-500">
|
||||
· {props.message.model}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Pending tools (running) */}
|
||||
<Show when={props.message.pendingTools && props.message.pendingTools.length > 0}>
|
||||
<div class="mt-3 space-y-2">
|
||||
<For each={props.message.pendingTools}>
|
||||
{(tool) => <PendingToolBlock tool={tool} />}
|
||||
</For>
|
||||
</div>
|
||||
{/* Main content area */}
|
||||
<div class="pl-7">
|
||||
{/* Stream events - chronological display */}
|
||||
<Show when={hasStreamEvents()}>
|
||||
<For each={groupedEvents()}>
|
||||
{(evt) => (
|
||||
<Switch>
|
||||
{/* Thinking block - collapsed by default */}
|
||||
<Match when={evt.type === 'thinking' && evt.thinking}>
|
||||
<ThinkingBlock
|
||||
content={evt.thinking!}
|
||||
isStreaming={props.message.isStreaming}
|
||||
/>
|
||||
</Match>
|
||||
|
||||
{/* Pending tool (currently running) - shown in chronological position */}
|
||||
<Match when={evt.type === 'pending_tool' && evt.pendingTool}>
|
||||
<PendingToolBlock tool={evt.pendingTool!} />
|
||||
</Match>
|
||||
|
||||
{/* Completed tool execution block */}
|
||||
<Match when={evt.type === 'tool' && evt.tool}>
|
||||
<ToolExecutionBlock tool={evt.tool!} />
|
||||
</Match>
|
||||
|
||||
{/* Content/text block */}
|
||||
<Match when={evt.type === 'content' && evt.content}>
|
||||
<div
|
||||
class="text-sm prose prose-slate prose-sm dark:prose-invert max-w-none
|
||||
prose-p:leading-relaxed prose-p:my-2
|
||||
prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:rounded-lg prose-pre:text-xs
|
||||
prose-code:text-purple-600 dark:prose-code:text-purple-400
|
||||
prose-code:bg-purple-50 dark:prose-code:bg-purple-900/30
|
||||
prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded
|
||||
prose-code:before:content-none prose-code:after:content-none
|
||||
prose-headings:text-slate-900 dark:prose-headings:text-slate-100
|
||||
prose-strong:text-slate-900 dark:prose-strong:text-slate-100
|
||||
prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5"
|
||||
innerHTML={renderMarkdown(evt.content!)}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
{/* Fallback: show content if no stream events */}
|
||||
<Show when={props.message.content && !hasStreamEvents()}>
|
||||
<div
|
||||
class="text-sm prose prose-sm dark:prose-invert max-w-none prose-pre:bg-gray-800 prose-pre:text-gray-100 prose-code:text-purple-600 dark:prose-code:text-purple-400 prose-code:before:content-none prose-code:after:content-none"
|
||||
class="text-sm prose prose-slate prose-sm dark:prose-invert max-w-none
|
||||
prose-p:leading-relaxed prose-p:my-2
|
||||
prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:rounded-lg prose-pre:text-xs
|
||||
prose-code:text-purple-600 dark:prose-code:text-purple-400
|
||||
prose-code:bg-purple-50 dark:prose-code:bg-purple-900/30
|
||||
prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded
|
||||
prose-code:before:content-none prose-code:after:content-none
|
||||
prose-headings:text-slate-900 dark:prose-headings:text-slate-100
|
||||
prose-strong:text-slate-900 dark:prose-strong:text-slate-100
|
||||
prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5"
|
||||
innerHTML={renderMarkdown(props.message.content)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{/* Pending approvals */}
|
||||
<Show when={props.message.pendingApprovals && props.message.pendingApprovals.length > 0}>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="mt-3 space-y-2">
|
||||
<For each={props.message.pendingApprovals}>
|
||||
{(approval) => (
|
||||
<ApprovalCard
|
||||
@@ -89,31 +169,22 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Streaming indicator */}
|
||||
<Show when={props.message.isStreaming && !props.message.pendingTools?.length}>
|
||||
<div class="mt-2 flex items-center gap-2 text-purple-500 dark:text-purple-400">
|
||||
<div class="flex gap-1">
|
||||
<span class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 0ms" />
|
||||
<span class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 150ms" />
|
||||
<span class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 300ms" />
|
||||
</div>
|
||||
{/* Streaming text indicator */}
|
||||
<Show when={isStreamingText()}>
|
||||
<span class="inline-block w-2 h-4 ml-0.5 bg-purple-500 dark:bg-purple-400 animate-pulse rounded-sm" />
|
||||
</Show>
|
||||
|
||||
{/* Token count footer */}
|
||||
<Show when={props.message.tokens && !props.message.isStreaming}>
|
||||
<div class="mt-3 pt-2 border-t border-slate-100 dark:border-slate-800 text-[10px] text-slate-400 dark:text-slate-500">
|
||||
{props.message.tokens!.input + props.message.tokens!.output} tokens
|
||||
<span class="mx-1">·</span>
|
||||
{props.message.tokens!.input} in / {props.message.tokens!.output} out
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Message metadata footer */}
|
||||
<Show when={!isUser() && props.message.model && !props.message.isStreaming}>
|
||||
<div class="px-4 py-1.5 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between text-[10px] text-gray-400 dark:text-gray-500">
|
||||
<span>{props.message.model}</span>
|
||||
<Show when={props.message.tokens}>
|
||||
<span>
|
||||
{props.message.tokens!.input + props.message.tokens!.output} tokens
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,44 +1,72 @@
|
||||
import { Component, createSignal } from 'solid-js';
|
||||
import { Component, createSignal, Show, createMemo } from 'solid-js';
|
||||
import { sanitizeThinking } from '../aiChatUtils';
|
||||
|
||||
interface ThinkingBlockProps {
|
||||
content: string;
|
||||
maxLength?: number;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ThinkingBlock - Displays AI's reasoning/thinking in a collapsed-by-default block.
|
||||
*
|
||||
* Inspired by OpenCode's terminal TUI which shows thinking as a subtle,
|
||||
* collapsible section that doesn't distract from the main response.
|
||||
*/
|
||||
export const ThinkingBlock: Component<ThinkingBlockProps> = (props) => {
|
||||
const [expanded, setExpanded] = createSignal(false);
|
||||
|
||||
const truncated = () => {
|
||||
const max = props.maxLength ?? 300;
|
||||
const text = props.content;
|
||||
return text.length > max && !expanded() ? text.substring(0, max) + '...' : text;
|
||||
};
|
||||
// Count lines and words for preview
|
||||
const stats = createMemo(() => {
|
||||
const lines = props.content.split('\n').filter(l => l.trim()).length;
|
||||
const words = props.content.split(/\s+/).filter(w => w).length;
|
||||
return { lines, words };
|
||||
});
|
||||
|
||||
const needsExpansion = () => {
|
||||
const max = props.maxLength ?? 300;
|
||||
return props.content.length > max;
|
||||
};
|
||||
// Get a short preview (first line, truncated)
|
||||
const preview = createMemo(() => {
|
||||
const firstLine = props.content.split('\n').find(l => l.trim()) || '';
|
||||
const maxLen = 60;
|
||||
if (firstLine.length > maxLen) {
|
||||
return firstLine.substring(0, maxLen).trim() + '...';
|
||||
}
|
||||
return firstLine.trim();
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="rounded-lg overflow-hidden border border-blue-200 dark:border-blue-800 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20">
|
||||
{/* Header - clickable to toggle */}
|
||||
<div class="my-2 font-mono text-xs">
|
||||
{/* Collapsed header - always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded())}
|
||||
class="w-full px-3 py-2 flex items-center gap-2 text-left hover:bg-blue-100/50 dark:hover:bg-blue-800/30 transition-colors"
|
||||
class="w-full flex items-center gap-2 px-3 py-1.5 rounded-md bg-slate-100 dark:bg-slate-800/60 hover:bg-slate-200 dark:hover:bg-slate-700/60 transition-colors text-left group"
|
||||
>
|
||||
<div class="p-1 rounded bg-blue-100 dark:bg-blue-800/50">
|
||||
<svg class="w-3 h-3 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{/* Thinking icon */}
|
||||
<div class={`flex items-center justify-center w-4 h-4 ${props.isStreaming ? 'animate-pulse' : ''}`}>
|
||||
<svg class="w-3.5 h-3.5 text-blue-500 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[10px] font-medium uppercase tracking-wider text-blue-600 dark:text-blue-400">
|
||||
Thinking
|
||||
|
||||
{/* Label */}
|
||||
<span class="text-blue-600 dark:text-blue-400 font-medium uppercase text-[10px] tracking-wider">
|
||||
{props.isStreaming ? 'Thinking...' : 'Thinking'}
|
||||
</span>
|
||||
<span class="flex-1" />
|
||||
|
||||
{/* Preview (when collapsed) */}
|
||||
<Show when={!expanded() && preview()}>
|
||||
<span class="text-slate-500 dark:text-slate-400 truncate flex-1">
|
||||
{preview()}
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
{/* Stats */}
|
||||
<span class="text-slate-400 dark:text-slate-500 text-[10px] ml-auto">
|
||||
{stats().lines} lines · {stats().words} words
|
||||
</span>
|
||||
|
||||
{/* Expand/collapse chevron */}
|
||||
<svg
|
||||
class={`w-4 h-4 text-blue-500 transition-transform ${expanded() ? 'rotate-180' : ''}`}
|
||||
class={`w-3.5 h-3.5 text-slate-400 transition-transform ${expanded() ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -47,16 +75,14 @@ export const ThinkingBlock: Component<ThinkingBlockProps> = (props) => {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
class={`px-3 overflow-hidden transition-all duration-200 ${
|
||||
expanded() ? 'max-h-96 py-2' : needsExpansion() ? 'max-h-20 py-2' : 'max-h-40 py-2'
|
||||
}`}
|
||||
>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-wrap overflow-y-auto max-h-80">
|
||||
{sanitizeThinking(truncated())}
|
||||
{/* Expanded content */}
|
||||
<Show when={expanded()}>
|
||||
<div class="mt-1 ml-4 pl-3 border-l-2 border-blue-200 dark:border-blue-800">
|
||||
<pre class="text-[11px] text-slate-600 dark:text-slate-400 whitespace-pre-wrap leading-relaxed max-h-64 overflow-y-auto">
|
||||
{sanitizeThinking(props.content)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,66 +1,100 @@
|
||||
import { Component, Show, createSignal } from 'solid-js';
|
||||
import { Component, Show, createSignal, createMemo, For } from 'solid-js';
|
||||
import type { ToolExecution, PendingTool } from './types';
|
||||
|
||||
interface ToolExecutionBlockProps {
|
||||
tool: ToolExecution;
|
||||
maxOutputLength?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ToolExecutionBlock - Displays completed tool executions in a compact terminal-like style.
|
||||
*/
|
||||
export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) => {
|
||||
const [expanded, setExpanded] = createSignal(false);
|
||||
const [showOutput, setShowOutput] = createSignal(false);
|
||||
|
||||
const truncatedOutput = () => {
|
||||
const max = props.maxOutputLength ?? 500;
|
||||
const output = props.tool.output;
|
||||
if (!output) return '';
|
||||
return output.length > max && !expanded() ? output.substring(0, max) + '...' : output;
|
||||
};
|
||||
// Get display name for tool
|
||||
const toolLabel = createMemo(() => {
|
||||
const name = props.tool.name;
|
||||
if (name === 'run_command' || name === 'pulse_run_command') return 'cmd';
|
||||
if (name === 'fetch_url' || name === 'pulse_fetch_url') return 'fetch';
|
||||
if (name === 'get_infrastructure_state' || name === 'pulse_get_infrastructure_state') return 'infra';
|
||||
if (name === 'get_active_alerts' || name === 'pulse_get_active_alerts') return 'alerts';
|
||||
if (name === 'get_metrics_history' || name === 'pulse_get_metrics_history') return 'metrics';
|
||||
if (name === 'get_baselines' || name === 'pulse_get_baselines') return 'baselines';
|
||||
if (name === 'get_patterns' || name === 'pulse_get_patterns') return 'patterns';
|
||||
if (name === 'get_disk_health' || name === 'pulse_get_disk_health') return 'disks';
|
||||
if (name === 'get_storage' || name === 'pulse_get_storage') return 'storage';
|
||||
if (name === 'get_resource_details' || name === 'pulse_get_resource_details') return 'resource';
|
||||
if (name.includes('finding')) return 'finding';
|
||||
return name.replace(/^pulse_/, '').replace(/_/g, ' ').substring(0, 12);
|
||||
});
|
||||
|
||||
const needsTruncation = () => {
|
||||
const max = props.maxOutputLength ?? 500;
|
||||
return props.tool.output && props.tool.output.length > max;
|
||||
};
|
||||
// Check if output is non-empty and interesting
|
||||
const hasOutput = createMemo(() => {
|
||||
const output = props.tool.output || '';
|
||||
return output.trim().length > 0 && !output.includes('not available');
|
||||
});
|
||||
|
||||
// Truncate output
|
||||
const displayOutput = createMemo(() => {
|
||||
const output = props.tool.output || '';
|
||||
const maxLen = 300;
|
||||
if (!showOutput() && output.length > maxLen) {
|
||||
return output.substring(0, maxLen) + '...';
|
||||
}
|
||||
return output;
|
||||
});
|
||||
|
||||
const statusIcon = () => props.tool.success ? '✓' : '✗';
|
||||
const statusColor = () => props.tool.success
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400';
|
||||
|
||||
return (
|
||||
<div class="rounded-lg border overflow-hidden shadow-sm transition-all hover:shadow-md">
|
||||
{/* Header */}
|
||||
<div class="my-1 font-mono text-[11px]">
|
||||
{/* Compact single-line header */}
|
||||
<div
|
||||
class={`px-3 py-2 text-xs font-medium flex items-center gap-2 ${
|
||||
props.tool.success
|
||||
? 'bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/30 dark:to-emerald-900/30 text-green-800 dark:text-green-200 border-b border-green-200 dark:border-green-800'
|
||||
: 'bg-gradient-to-r from-red-50 to-rose-50 dark:from-red-900/30 dark:to-rose-900/30 text-red-800 dark:text-red-200 border-b border-red-200 dark:border-red-800'
|
||||
}`}
|
||||
class={`flex items-center gap-1.5 px-2 py-1 rounded ${hasOutput() ? 'cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800' : ''
|
||||
} ${showOutput() ? 'bg-slate-50 dark:bg-slate-800/50' : ''}`}
|
||||
onClick={() => hasOutput() && setShowOutput(!showOutput())}
|
||||
>
|
||||
<div class={`p-1 rounded ${props.tool.success ? 'bg-green-100 dark:bg-green-800/50' : 'bg-red-100 dark:bg-red-800/50'}`}>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<code class="font-mono flex-1 truncate">{props.tool.input}</code>
|
||||
<Show when={props.tool.success}>
|
||||
<svg class="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</Show>
|
||||
<Show when={!props.tool.success}>
|
||||
<svg class="w-4 h-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
{/* Status icon */}
|
||||
<span class={`${statusColor()} font-bold`}>{statusIcon()}</span>
|
||||
|
||||
{/* Tool label */}
|
||||
<span class="text-slate-500 dark:text-slate-400 uppercase text-[9px] font-medium tracking-wider min-w-[50px]">
|
||||
{toolLabel()}
|
||||
</span>
|
||||
|
||||
{/* Command/input - truncated */}
|
||||
<code class="text-slate-700 dark:text-slate-300 truncate flex-1">
|
||||
{props.tool.input.length > 60 ? props.tool.input.substring(0, 60) + '...' : props.tool.input}
|
||||
</code>
|
||||
|
||||
{/* Expand indicator if has output */}
|
||||
<Show when={hasOutput()}>
|
||||
<svg
|
||||
class={`w-3 h-3 text-slate-400 transition-transform ${showOutput() ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
<Show when={props.tool.output}>
|
||||
<div class="relative">
|
||||
<pre class="px-3 py-2 text-xs font-mono bg-gray-50 dark:bg-gray-900/80 text-gray-700 dark:text-gray-300 overflow-x-auto max-h-48 overflow-y-auto whitespace-pre-wrap break-words">
|
||||
{truncatedOutput()}
|
||||
{/* Expanded output */}
|
||||
<Show when={showOutput() && hasOutput()}>
|
||||
<div class="ml-4 mt-1 mb-2 pl-2 border-l-2 border-slate-200 dark:border-slate-700">
|
||||
<pre class="text-[10px] text-slate-600 dark:text-slate-400 whitespace-pre-wrap break-words leading-relaxed max-h-40 overflow-y-auto">
|
||||
{displayOutput()}
|
||||
</pre>
|
||||
<Show when={needsTruncation()}>
|
||||
<Show when={(props.tool.output || '').length > 300}>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded())}
|
||||
class="absolute bottom-2 right-2 px-2 py-1 text-[10px] font-medium bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 rounded transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); setShowOutput(!showOutput()); }}
|
||||
class="mt-1 text-[9px] text-purple-600 dark:text-purple-400 hover:underline"
|
||||
>
|
||||
{expanded() ? 'Show less' : 'Show more'}
|
||||
{showOutput() && (props.tool.output || '').length > 300 ? 'Show less' : 'Show all'}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -69,24 +103,126 @@ export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) =>
|
||||
);
|
||||
};
|
||||
|
||||
// Pending tool (still running)
|
||||
/**
|
||||
* PendingToolBlock - Compact single-line display for running tools
|
||||
*/
|
||||
interface PendingToolBlockProps {
|
||||
tool: PendingTool;
|
||||
}
|
||||
|
||||
export const PendingToolBlock: Component<PendingToolBlockProps> = (props) => {
|
||||
const toolLabel = createMemo(() => {
|
||||
const name = props.tool.name;
|
||||
if (name === 'run_command' || name === 'pulse_run_command') return 'cmd';
|
||||
if (name === 'fetch_url' || name === 'pulse_fetch_url') return 'fetch';
|
||||
if (name === 'get_infrastructure_state' || name === 'pulse_get_infrastructure_state') return 'infra';
|
||||
if (name === 'get_active_alerts' || name === 'pulse_get_active_alerts') return 'alerts';
|
||||
if (name === 'get_metrics_history' || name === 'pulse_get_metrics_history') return 'metrics';
|
||||
if (name === 'get_baselines' || name === 'pulse_get_baselines') return 'baselines';
|
||||
if (name === 'get_patterns' || name === 'pulse_get_patterns') return 'patterns';
|
||||
if (name === 'get_disk_health' || name === 'pulse_get_disk_health') return 'disks';
|
||||
if (name === 'get_storage' || name === 'pulse_get_storage') return 'storage';
|
||||
if (name === 'get_resource_details' || name === 'pulse_get_resource_details') return 'resource';
|
||||
if (name.includes('finding')) return 'finding';
|
||||
return name.replace(/^pulse_/, '').replace(/_/g, ' ').substring(0, 12);
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="rounded-lg border border-purple-300 dark:border-purple-700 overflow-hidden animate-pulse">
|
||||
<div class="px-3 py-2 text-xs font-medium flex items-center gap-2 bg-gradient-to-r from-purple-50 to-violet-50 dark:from-purple-900/30 dark:to-violet-900/30 text-purple-800 dark:text-purple-200">
|
||||
<div class="p-1 rounded bg-purple-100 dark:bg-purple-800/50">
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
<code class="font-mono flex-1 truncate">{props.tool.input}</code>
|
||||
<span class="text-[10px] text-purple-600 dark:text-purple-400 font-semibold uppercase tracking-wider">Running</span>
|
||||
</div>
|
||||
<div class="my-0.5 font-mono text-[11px] flex items-center gap-1.5 px-2 py-1 rounded bg-purple-50 dark:bg-purple-900/20">
|
||||
{/* Spinner */}
|
||||
<svg class="w-3 h-3 text-purple-500 dark:text-purple-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
|
||||
{/* Tool label */}
|
||||
<span class="text-purple-600 dark:text-purple-400 uppercase text-[9px] font-medium tracking-wider min-w-[50px]">
|
||||
{toolLabel()}
|
||||
</span>
|
||||
|
||||
{/* Command - truncated */}
|
||||
<code class="text-purple-700 dark:text-purple-300 truncate flex-1">
|
||||
{props.tool.input.length > 50 ? props.tool.input.substring(0, 50) + '...' : props.tool.input}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* PendingToolsList - Groups multiple pending tools into a compact list
|
||||
*/
|
||||
interface PendingToolsListProps {
|
||||
tools: PendingTool[];
|
||||
}
|
||||
|
||||
export const PendingToolsList: Component<PendingToolsListProps> = (props) => {
|
||||
const [expanded, setExpanded] = createSignal(false);
|
||||
|
||||
// If 3 or fewer, show all. Otherwise show collapsed.
|
||||
const shouldCollapse = () => props.tools.length > 3;
|
||||
const visibleTools = () => {
|
||||
if (!shouldCollapse() || expanded()) return props.tools;
|
||||
return props.tools.slice(0, 2);
|
||||
};
|
||||
const hiddenCount = () => props.tools.length - 2;
|
||||
|
||||
return (
|
||||
<div class="my-1">
|
||||
<For each={visibleTools()}>
|
||||
{(tool) => <PendingToolBlock tool={tool} />}
|
||||
</For>
|
||||
|
||||
<Show when={shouldCollapse() && !expanded()}>
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
class="w-full mt-0.5 py-1 text-[10px] text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded text-center font-medium"
|
||||
>
|
||||
+ {hiddenCount()} more tools running...
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* ToolExecutionsList - Compact list for multiple completed tools
|
||||
*/
|
||||
interface ToolExecutionsListProps {
|
||||
tools: ToolExecution[];
|
||||
}
|
||||
|
||||
export const ToolExecutionsList: Component<ToolExecutionsListProps> = (props) => {
|
||||
const [showAll, setShowAll] = createSignal(false);
|
||||
|
||||
// If more than 5 tools, collapse them
|
||||
const shouldCollapse = () => props.tools.length > 5;
|
||||
const visibleTools = () => {
|
||||
if (!shouldCollapse() || showAll()) return props.tools;
|
||||
return props.tools.slice(0, 3);
|
||||
};
|
||||
const hiddenCount = () => props.tools.length - 3;
|
||||
|
||||
// Count successes/failures
|
||||
const stats = createMemo(() => {
|
||||
const success = props.tools.filter(t => t.success).length;
|
||||
const failed = props.tools.length - success;
|
||||
return { success, failed };
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="my-1">
|
||||
<For each={visibleTools()}>
|
||||
{(tool) => <ToolExecutionBlock tool={tool} />}
|
||||
</For>
|
||||
|
||||
<Show when={shouldCollapse() && !showAll()}>
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
class="w-full mt-0.5 py-1 text-[10px] text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded text-center font-medium"
|
||||
>
|
||||
+ {hiddenCount()} more tools ({stats().success} ✓ / {stats().failed} ✗)
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
// Helper to add stream event for chronological display
|
||||
const addStreamEvent = (msg: ChatMessage, event: StreamDisplayEvent): ChatMessage => {
|
||||
const events = msg.streamEvents || [];
|
||||
|
||||
// For content events, merge consecutive content into one
|
||||
if (event.type === 'content' && events.length > 0) {
|
||||
const last = events[events.length - 1];
|
||||
@@ -62,6 +63,21 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// For thinking events, merge consecutive thinking into one
|
||||
if (event.type === 'thinking' && events.length > 0) {
|
||||
const last = events[events.length - 1];
|
||||
if (last.type === 'thinking') {
|
||||
return {
|
||||
...msg,
|
||||
streamEvents: [
|
||||
...events.slice(0, -1),
|
||||
{ ...last, thinking: (last.thinking || '') + (event.thinking || '') },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...msg,
|
||||
streamEvents: [...events, event],
|
||||
@@ -103,18 +119,31 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
|
||||
case 'tool_start': {
|
||||
const data = event.data as { name: string; input: string };
|
||||
const toolId = generateId(); // Unique ID to track this tool
|
||||
const pendingTool = { name: data.name, input: data.input };
|
||||
|
||||
// Add to streamEvents in chronological position
|
||||
const updated = addStreamEvent(msg, {
|
||||
type: 'pending_tool',
|
||||
pendingTool,
|
||||
toolId,
|
||||
});
|
||||
|
||||
return {
|
||||
...msg,
|
||||
pendingTools: [...(msg.pendingTools || []), { name: data.name, input: data.input }],
|
||||
...updated,
|
||||
pendingTools: [...(msg.pendingTools || []), { ...pendingTool, id: toolId } as any],
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool_end': {
|
||||
const data = event.data as { name: string; input: string; output: string; success: boolean };
|
||||
const pendingTools = msg.pendingTools || [];
|
||||
const matchingIndex = pendingTools.findIndex((t) => t.name === data.name);
|
||||
const updatedPending = matchingIndex >= 0
|
||||
? [...pendingTools.slice(0, matchingIndex), ...pendingTools.slice(matchingIndex + 1)]
|
||||
const events = msg.streamEvents || [];
|
||||
|
||||
// Find the matching pending tool (by name, since we may not have ID in the event)
|
||||
const matchingPendingIndex = pendingTools.findIndex((t) => t.name === data.name);
|
||||
const updatedPending = matchingPendingIndex >= 0
|
||||
? [...pendingTools.slice(0, matchingPendingIndex), ...pendingTools.slice(matchingPendingIndex + 1)]
|
||||
: pendingTools;
|
||||
|
||||
const newToolCall: ToolExecution = {
|
||||
@@ -124,10 +153,21 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
success: data.success,
|
||||
};
|
||||
|
||||
// Add tool to streamEvents for chronological display
|
||||
const updated = addStreamEvent(msg, { type: 'tool', tool: newToolCall });
|
||||
// Find the pending_tool event in streamEvents and replace it with completed tool
|
||||
// Search from the end to find the most recent matching pending tool
|
||||
let updatedEvents = [...events];
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const evt = events[i];
|
||||
if (evt.type === 'pending_tool' && evt.pendingTool?.name === data.name) {
|
||||
// Replace pending with completed
|
||||
updatedEvents[i] = { type: 'tool', tool: newToolCall };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...updated,
|
||||
...msg,
|
||||
streamEvents: updatedEvents,
|
||||
pendingTools: updatedPending,
|
||||
toolCalls: [...(msg.toolCalls || []), newToolCall],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, Show, createSignal, onMount, For } from 'solid-js';
|
||||
import { Component, Show, createSignal, onMount, For, createMemo } from 'solid-js';
|
||||
import { OpenCodeAPI, type ChatSession } from '@/api/opencode';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { logger } from '@/utils/logger';
|
||||
@@ -10,6 +10,12 @@ interface AIChatProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* AIChat - Main chat panel component.
|
||||
*
|
||||
* Provides a terminal-like chat experience with clear status indicators,
|
||||
* session management, and streaming response display.
|
||||
*/
|
||||
export const AIChat: Component<AIChatProps> = (props) => {
|
||||
// UI state
|
||||
const [isOpen] = createSignal(true);
|
||||
@@ -20,6 +26,30 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
// Chat hook
|
||||
const chat = useChat();
|
||||
|
||||
// Compute current status for display
|
||||
const currentStatus = createMemo(() => {
|
||||
if (!chat.isLoading()) return null;
|
||||
|
||||
const messages = chat.messages();
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
if (!lastMessage || lastMessage.role !== 'assistant') {
|
||||
return { type: 'thinking', text: 'Thinking...' };
|
||||
}
|
||||
|
||||
if (lastMessage.pendingTools && lastMessage.pendingTools.length > 0) {
|
||||
const tool = lastMessage.pendingTools[0];
|
||||
const toolName = tool.name.replace(/^pulse_/, '').replace(/_/g, ' ');
|
||||
return { type: 'tool', text: `Running ${toolName}...` };
|
||||
}
|
||||
|
||||
if (lastMessage.isStreaming) {
|
||||
return { type: 'generating', text: 'Generating response...' };
|
||||
}
|
||||
|
||||
return { type: 'thinking', text: 'Thinking...' };
|
||||
});
|
||||
|
||||
// Load sessions on mount
|
||||
onMount(async () => {
|
||||
try {
|
||||
@@ -79,38 +109,37 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
};
|
||||
|
||||
// Empty state for approval (not used with OpenCode but keeping interface)
|
||||
const handleApprove = (_messageId: string, _approval: PendingApproval) => {};
|
||||
const handleSkip = (_messageId: string, _toolId: string) => {};
|
||||
const handleApprove = (_messageId: string, _approval: PendingApproval) => { };
|
||||
const handleSkip = (_messageId: string, _toolId: string) => { };
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`flex-shrink-0 h-full bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-700 flex flex-col transition-all duration-300 overflow-hidden ${
|
||||
isOpen() ? 'w-[420px]' : 'w-0 border-l-0'
|
||||
}`}
|
||||
class={`flex-shrink-0 h-full bg-white dark:bg-slate-900 border-l border-slate-200 dark:border-slate-700 flex flex-col transition-all duration-300 overflow-hidden ${isOpen() ? 'w-[480px]' : 'w-0 border-l-0'
|
||||
}`}
|
||||
>
|
||||
<Show when={isOpen()}>
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-purple-50 to-violet-50 dark:from-purple-900/20 dark:to-violet-900/20">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-gradient-to-br from-purple-500 to-violet-500 rounded-xl shadow-lg">
|
||||
<div class="p-2 bg-gradient-to-br from-purple-500 to-violet-500 rounded-xl shadow-lg shadow-purple-500/20">
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611l-2.576.43a18.003 18.003 0 01-5.118 0l-2.576-.43c-1.717-.293-2.299-2.379-1.067-3.611L5 14.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-gray-100">AI Assistant</h2>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<h2 class="text-sm font-semibold text-slate-900 dark:text-slate-100">AI Assistant</h2>
|
||||
<p class="text-[11px] text-slate-500 dark:text-slate-400">
|
||||
Powered by OpenCode
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
{/* Session picker */}
|
||||
<div class="relative">
|
||||
<button
|
||||
onClick={() => setShowSessions(!showSessions())}
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||
title="Chat sessions"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -119,10 +148,10 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
</button>
|
||||
|
||||
<Show when={showSessions()}>
|
||||
<div class="absolute right-0 top-full mt-1 w-72 max-h-96 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 z-50 overflow-hidden">
|
||||
<div class="absolute right-0 top-full mt-1 w-72 max-h-96 bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 z-50 overflow-hidden">
|
||||
<button
|
||||
onClick={handleNewConversation}
|
||||
class="w-full px-3 py-2.5 text-left text-sm flex items-center gap-2 text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 border-b border-gray-200 dark:border-gray-700"
|
||||
class="w-full px-3 py-2.5 text-left text-sm flex items-center gap-2 text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 border-b border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
@@ -132,27 +161,27 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<Show when={sessions().length > 0} fallback={
|
||||
<div class="px-3 py-6 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
<div class="px-3 py-6 text-center text-xs text-slate-500 dark:text-slate-400">
|
||||
No previous conversations
|
||||
</div>
|
||||
}>
|
||||
<For each={sessions()}>
|
||||
{(session) => (
|
||||
<div
|
||||
class={`group relative px-3 py-2.5 flex items-start gap-2 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer ${chat.sessionId() === session.id ? 'bg-purple-50 dark:bg-purple-900/20' : ''}`}
|
||||
class={`group relative px-3 py-2.5 flex items-start gap-2 hover:bg-slate-50 dark:hover:bg-slate-700/50 cursor-pointer ${chat.sessionId() === session.id ? 'bg-purple-50 dark:bg-purple-900/20' : ''}`}
|
||||
onClick={() => handleLoadSession(session.id)}
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium truncate text-gray-900 dark:text-gray-100">
|
||||
<div class="text-sm font-medium truncate text-slate-900 dark:text-slate-100">
|
||||
{session.title || 'Untitled'}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<div class="text-xs text-slate-500 dark:text-slate-400">
|
||||
{session.message_count} messages
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-shrink-0 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-400 hover:text-red-500 transition-opacity"
|
||||
class="flex-shrink-0 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-red-100 dark:hover:bg-red-900/30 text-slate-400 hover:text-red-500 transition-opacity"
|
||||
onClick={(e) => handleDeleteSession(session.id, e)}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -171,7 +200,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M6 5l7 7-7 7" />
|
||||
@@ -197,19 +226,48 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Loading indicator */}
|
||||
<Show when={chat.isLoading()}>
|
||||
<div class="px-4 py-2 bg-purple-50 dark:bg-purple-900/20 border-t border-purple-200 dark:border-purple-800 flex items-center gap-2 text-sm text-purple-700 dark:text-purple-300">
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<span>Thinking...</span>
|
||||
{/* Status indicator bar */}
|
||||
<Show when={currentStatus()}>
|
||||
<div class="px-4 py-2 bg-slate-50 dark:bg-slate-800/50 border-t border-slate-200 dark:border-slate-700 flex items-center gap-2.5 text-xs">
|
||||
{/* Status icon based on type */}
|
||||
<Show when={currentStatus()?.type === 'thinking'}>
|
||||
<div class="flex items-center justify-center w-4 h-4">
|
||||
<svg class="w-3.5 h-3.5 text-blue-500 dark:text-blue-400 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={currentStatus()?.type === 'tool'}>
|
||||
<div class="flex items-center justify-center w-4 h-4">
|
||||
<svg class="w-3.5 h-3.5 text-purple-500 dark:text-purple-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={currentStatus()?.type === 'generating'}>
|
||||
<div class="flex items-center justify-center w-4 h-4">
|
||||
<svg class="w-3.5 h-3.5 text-emerald-500 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<span class="text-slate-600 dark:text-slate-400 font-medium">
|
||||
{currentStatus()?.text}
|
||||
</span>
|
||||
|
||||
{/* Subtle animated dots */}
|
||||
<div class="flex gap-0.5 ml-1">
|
||||
<span class="w-1 h-1 rounded-full bg-slate-400 dark:bg-slate-500 animate-bounce" style="animation-delay: 0ms; animation-duration: 1s" />
|
||||
<span class="w-1 h-1 rounded-full bg-slate-400 dark:bg-slate-500 animate-bounce" style="animation-delay: 150ms; animation-duration: 1s" />
|
||||
<span class="w-1 h-1 rounded-full bg-slate-400 dark:bg-slate-500 animate-bounce" style="animation-delay: 300ms; animation-duration: 1s" />
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Input */}
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 p-4 bg-white dark:bg-gray-900">
|
||||
<div class="border-t border-slate-200 dark:border-slate-700 p-4 bg-white dark:bg-slate-900">
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="flex gap-2">
|
||||
<textarea
|
||||
value={input()}
|
||||
@@ -217,7 +275,8 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask about your infrastructure..."
|
||||
rows={2}
|
||||
class="flex-1 px-4 py-3 text-sm rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none"
|
||||
disabled={chat.isLoading()}
|
||||
class="flex-1 px-4 py-3 text-sm rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div class="flex flex-col gap-1.5 self-end">
|
||||
<Show
|
||||
@@ -226,7 +285,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input().trim()}
|
||||
class="px-4 py-3 bg-gradient-to-r from-purple-600 to-violet-600 hover:from-purple-700 hover:to-violet-700 text-white rounded-xl disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg hover:shadow-xl"
|
||||
class="px-4 py-3 bg-gradient-to-r from-purple-600 to-violet-600 hover:from-purple-700 hover:to-violet-700 text-white rounded-xl disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-purple-500/20 hover:shadow-xl hover:shadow-purple-500/30"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
@@ -237,7 +296,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={chat.stop}
|
||||
class="px-4 py-3 bg-red-500 hover:bg-red-600 text-white rounded-xl transition-colors shadow-sm"
|
||||
class="px-4 py-3 bg-red-500 hover:bg-red-600 text-white rounded-xl transition-colors shadow-lg shadow-red-500/20"
|
||||
title="Stop"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
@@ -247,8 +306,8 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
</form>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2 text-center">
|
||||
Press Enter to send, Shift+Enter for new line
|
||||
<p class="text-[10px] text-slate-400 dark:text-slate-500 mt-2 text-center">
|
||||
Press Enter to send · Shift+Enter for new line
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -22,13 +22,15 @@ export interface PendingApproval {
|
||||
}
|
||||
|
||||
// Unified event for chronological display
|
||||
export type StreamEventType = 'thinking' | 'tool' | 'content';
|
||||
export type StreamEventType = 'thinking' | 'tool' | 'content' | 'pending_tool';
|
||||
|
||||
export interface StreamDisplayEvent {
|
||||
type: StreamEventType;
|
||||
thinking?: string;
|
||||
tool?: ToolExecution;
|
||||
pendingTool?: PendingTool;
|
||||
content?: string;
|
||||
toolId?: string; // Used to match pending_tool with completed tool
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
|
||||
@@ -138,6 +138,9 @@ export const AISettings: Component = () => {
|
||||
costBudgetUSD30d: '',
|
||||
// Request timeout (seconds) - for slow Ollama hardware
|
||||
requestTimeoutSeconds: 300,
|
||||
// Infrastructure control settings
|
||||
controlLevel: 'read_only' as 'read_only' | 'suggest' | 'controlled' | 'autonomous',
|
||||
protectedGuests: '' as string, // Comma-separated VMIDs/names
|
||||
});
|
||||
|
||||
const resetForm = (data: AISettingsType | null) => {
|
||||
@@ -166,6 +169,8 @@ export const AISettings: Component = () => {
|
||||
openaiBaseUrl: '',
|
||||
costBudgetUSD30d: '',
|
||||
requestTimeoutSeconds: 300,
|
||||
controlLevel: 'read_only',
|
||||
protectedGuests: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -197,6 +202,8 @@ export const AISettings: Component = () => {
|
||||
? String(data.cost_budget_usd_30d)
|
||||
: '',
|
||||
requestTimeoutSeconds: data.request_timeout_seconds ?? 300,
|
||||
controlLevel: (data.control_level as 'read_only' | 'suggest' | 'controlled' | 'autonomous') || 'read_only',
|
||||
protectedGuests: Array.isArray(data.protected_guests) ? data.protected_guests.join(', ') : '',
|
||||
});
|
||||
|
||||
// Auto-expand providers that are configured
|
||||
@@ -406,6 +413,24 @@ export const AISettings: Component = () => {
|
||||
payload.request_timeout_seconds = form.requestTimeoutSeconds;
|
||||
}
|
||||
|
||||
// Infrastructure control settings
|
||||
if (form.controlLevel !== (settings()?.control_level || 'read_only')) {
|
||||
payload.control_level = form.controlLevel;
|
||||
}
|
||||
|
||||
// Protected guests (comma-separated string to array)
|
||||
const currentProtected = settings()?.protected_guests || [];
|
||||
const newProtected = form.protectedGuests
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter((s: string) => s.length > 0);
|
||||
const protectedChanged =
|
||||
newProtected.length !== currentProtected.length ||
|
||||
newProtected.some((g: string, i: number) => g !== currentProtected[i]);
|
||||
if (protectedChanged) {
|
||||
payload.protected_guests = newProtected;
|
||||
}
|
||||
|
||||
const updated = await AIAPI.updateSettings(payload);
|
||||
setSettings(updated);
|
||||
resetForm(updated);
|
||||
@@ -1441,6 +1466,69 @@ export const AISettings: Component = () => {
|
||||
💡 Increase for slow Ollama hardware (default: 300s / 5 min)
|
||||
</p>
|
||||
|
||||
{/* Infrastructure Control Settings */}
|
||||
<div class="space-y-3 p-4 rounded-lg border border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-900/20">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Infrastructure Control</span>
|
||||
<Show when={form.controlLevel !== 'read_only'}>
|
||||
<span class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${
|
||||
form.controlLevel === 'autonomous'
|
||||
? 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300'
|
||||
: form.controlLevel === 'controlled'
|
||||
? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300'
|
||||
: 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
}`}>
|
||||
{form.controlLevel}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Control Level */}
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 w-28 flex-shrink-0">Control Level</label>
|
||||
<select
|
||||
value={form.controlLevel}
|
||||
onChange={(e) => setForm('controlLevel', e.currentTarget.value as 'read_only' | 'suggest' | 'controlled' | 'autonomous')}
|
||||
class="flex-1 px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
|
||||
disabled={saving()}
|
||||
>
|
||||
<option value="read_only">Read Only - AI can only observe</option>
|
||||
<option value="suggest">Suggest - AI suggests commands to copy/paste</option>
|
||||
<option value="controlled">Controlled - AI executes with approval</option>
|
||||
<option value="autonomous">Autonomous - AI executes without approval (Pro)</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-[10px] text-gray-500 dark:text-gray-400 ml-[7.5rem]">
|
||||
{form.controlLevel === 'read_only' && '🔒 AI can only query infrastructure, no control actions'}
|
||||
{form.controlLevel === 'suggest' && '💬 AI suggests commands like "pct stop 101" for you to run'}
|
||||
{form.controlLevel === 'controlled' && '✅ AI can start/stop VMs and containers with your approval'}
|
||||
{form.controlLevel === 'autonomous' && '⚠️ AI executes control actions without asking'}
|
||||
</p>
|
||||
|
||||
{/* Protected Guests - Only show if control is enabled */}
|
||||
<Show when={form.controlLevel !== 'read_only'}>
|
||||
<div class="flex items-start gap-3 pt-2 border-t border-purple-200 dark:border-purple-700">
|
||||
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 w-28 flex-shrink-0 pt-1">Protected</label>
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={form.protectedGuests}
|
||||
onInput={(e) => setForm('protectedGuests', e.currentTarget.value)}
|
||||
placeholder="e.g., 100, 101, prod-db"
|
||||
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
|
||||
disabled={saving()}
|
||||
/>
|
||||
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">
|
||||
Comma-separated VMIDs or names that AI cannot control
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { Component, createSignal, createMemo, onMount, Show, For } from 'solid-js';
|
||||
import { useWebSocket } from '@/App';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { AgentProfilesAPI, type AgentProfile, type AgentProfileAssignment } from '@/api/agentProfiles';
|
||||
import { AgentProfilesAPI, type AgentProfile, type AgentProfileAssignment, type ProfileSuggestion } from '@/api/agentProfiles';
|
||||
import { LicenseAPI } from '@/api/license';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import { SuggestProfileModal } from './SuggestProfileModal';
|
||||
import Plus from 'lucide-solid/icons/plus';
|
||||
import Pencil from 'lucide-solid/icons/pencil';
|
||||
import Trash2 from 'lucide-solid/icons/trash-2';
|
||||
import Crown from 'lucide-solid/icons/crown';
|
||||
import Users from 'lucide-solid/icons/users';
|
||||
import Settings from 'lucide-solid/icons/settings';
|
||||
import Sparkles from 'lucide-solid/icons/sparkles';
|
||||
|
||||
// Known agent settings with their types and descriptions
|
||||
interface BooleanSetting {
|
||||
@@ -36,14 +38,36 @@ interface DurationSetting {
|
||||
description: string;
|
||||
}
|
||||
|
||||
type KnownSetting = BooleanSetting | SelectSetting | DurationSetting;
|
||||
interface StringSetting {
|
||||
key: string;
|
||||
type: 'string';
|
||||
label: string;
|
||||
description: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
type KnownSetting = BooleanSetting | SelectSetting | DurationSetting | StringSetting;
|
||||
|
||||
// Settings that the agent actually supports (from applyRemoteSettings in cmd/pulse-agent/main.go)
|
||||
const KNOWN_SETTINGS: KnownSetting[] = [
|
||||
{ key: 'enable_docker', type: 'boolean', label: 'Enable Docker Monitoring', description: 'Monitor Docker containers on this agent' },
|
||||
// Core monitoring
|
||||
{ key: 'enable_host', type: 'boolean', label: 'Enable Host Monitoring', description: 'Collect host metrics and allow command execution' },
|
||||
{ key: 'enable_docker', type: 'boolean', label: 'Enable Docker Monitoring', description: 'Monitor Docker or Podman containers on this agent' },
|
||||
{ key: 'docker_runtime', type: 'select', label: 'Docker Runtime', description: 'Force a specific container runtime', options: ['auto', 'docker', 'podman'] },
|
||||
{ key: 'enable_kubernetes', type: 'boolean', label: 'Enable Kubernetes Monitoring', description: 'Monitor Kubernetes workloads' },
|
||||
{ key: 'kube_include_all_pods', type: 'boolean', label: 'Include All Pods', description: 'Include all non-succeeded pods in reports' },
|
||||
{ key: 'kube_include_all_deployments', type: 'boolean', label: 'Include All Deployments', description: 'Include all deployments, not just problem ones' },
|
||||
{ key: 'enable_proxmox', type: 'boolean', label: 'Enable Proxmox Mode', description: 'Auto-detect and configure Proxmox API access' },
|
||||
{ key: 'log_level', type: 'select', label: 'Log Level', description: 'Agent logging verbosity', options: ['debug', 'info', 'warn', 'error'] },
|
||||
{ key: 'proxmox_type', type: 'select', label: 'Proxmox Type', description: 'Force PVE or PBS mode', options: ['auto', 'pve', 'pbs'] },
|
||||
{ key: 'disable_ceph', type: 'boolean', label: 'Disable Ceph Monitoring', description: 'Skip local Ceph status polling' },
|
||||
// Timing
|
||||
{ key: 'interval', type: 'duration', label: 'Reporting Interval', description: 'How often the agent reports metrics (e.g., 30s, 1m)' },
|
||||
// Network
|
||||
{ key: 'report_ip', type: 'string', label: 'Report IP Override', description: 'Override the reported IP address', placeholder: '' },
|
||||
// Operations
|
||||
{ key: 'disable_auto_update', type: 'boolean', label: 'Disable Auto Updates', description: 'Stop the unified agent from auto-updating' },
|
||||
{ key: 'disable_docker_update_checks', type: 'boolean', label: 'Disable Docker Update Checks', description: 'Skip Docker image update detection (avoid registry rate limits)' },
|
||||
{ key: 'log_level', type: 'select', label: 'Log Level', description: 'Agent logging verbosity', options: ['debug', 'info', 'warn', 'error'] },
|
||||
];
|
||||
|
||||
export const AgentProfilesPanel: Component = () => {
|
||||
@@ -60,11 +84,13 @@ export const AgentProfilesPanel: Component = () => {
|
||||
|
||||
// Modal state
|
||||
const [showModal, setShowModal] = createSignal(false);
|
||||
const [showSuggestModal, setShowSuggestModal] = createSignal(false);
|
||||
const [editingProfile, setEditingProfile] = createSignal<AgentProfile | null>(null);
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
|
||||
// Form state
|
||||
const [formName, setFormName] = createSignal('');
|
||||
const [formDescription, setFormDescription] = createSignal('');
|
||||
const [formSettings, setFormSettings] = createSignal<Record<string, unknown>>({});
|
||||
|
||||
// Connected agents from WebSocket state
|
||||
@@ -99,6 +125,15 @@ export const AgentProfilesPanel: Component = () => {
|
||||
return Object.keys(profile.config || {}).length;
|
||||
};
|
||||
|
||||
// Get known setting keys for filtering
|
||||
const knownKeys = KNOWN_SETTINGS.map(s => s.key);
|
||||
|
||||
// Get unknown keys in the form settings
|
||||
const unknownKeys = createMemo(() => {
|
||||
const settings = formSettings();
|
||||
return Object.keys(settings).filter(key => !knownKeys.includes(key));
|
||||
});
|
||||
|
||||
// Load data
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
@@ -140,14 +175,31 @@ export const AgentProfilesPanel: Component = () => {
|
||||
const handleCreate = () => {
|
||||
setEditingProfile(null);
|
||||
setFormName('');
|
||||
setFormDescription('');
|
||||
setFormSettings({});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// Open suggest modal
|
||||
const handleSuggest = () => {
|
||||
setShowSuggestModal(true);
|
||||
};
|
||||
|
||||
// Handle AI suggestion acceptance
|
||||
const handleSuggestionAccepted = (suggestion: ProfileSuggestion) => {
|
||||
setShowSuggestModal(false);
|
||||
setEditingProfile(null);
|
||||
setFormName(suggestion.name);
|
||||
setFormDescription(suggestion.description || '');
|
||||
setFormSettings(suggestion.config);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// Open modal for editing a profile
|
||||
const handleEdit = (profile: AgentProfile) => {
|
||||
setEditingProfile(profile);
|
||||
setFormName(profile.name);
|
||||
setFormDescription(profile.description || '');
|
||||
setFormSettings({ ...profile.config });
|
||||
setShowModal(true);
|
||||
};
|
||||
@@ -182,13 +234,14 @@ export const AgentProfilesPanel: Component = () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const config = formSettings();
|
||||
const description = formDescription().trim() || undefined;
|
||||
const existing = editingProfile();
|
||||
|
||||
if (existing) {
|
||||
await AgentProfilesAPI.updateProfile(existing.id, name, config);
|
||||
await AgentProfilesAPI.updateProfile(existing.id, name, config, description);
|
||||
notificationStore.success(`Profile "${name}" updated`);
|
||||
} else {
|
||||
await AgentProfilesAPI.createProfile(name, config);
|
||||
await AgentProfilesAPI.createProfile(name, config, description);
|
||||
notificationStore.success(`Profile "${name}" created`);
|
||||
}
|
||||
|
||||
@@ -287,14 +340,24 @@ export const AgentProfilesPanel: Component = () => {
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Reusable agent configurations</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
New Profile
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSuggest}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-purple-700"
|
||||
>
|
||||
<Sparkles class="w-4 h-4" />
|
||||
Suggest Profile
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
New Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={loading()}>
|
||||
@@ -485,6 +548,20 @@ export const AgentProfilesPanel: Component = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Profile Description */}
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Description <span class="text-gray-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={formDescription()}
|
||||
onInput={(e) => setFormDescription(e.currentTarget.value)}
|
||||
placeholder="What is this profile for?"
|
||||
rows={2}
|
||||
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-800/60 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<div class="space-y-3">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
@@ -549,11 +626,66 @@ export const AgentProfilesPanel: Component = () => {
|
||||
class="w-24 rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</Show>
|
||||
<Show when={setting.type === 'string'}>
|
||||
<input
|
||||
type="text"
|
||||
value={(formSettings()[setting.key] as string) || ''}
|
||||
onInput={(e) => updateSetting(setting.key, e.currentTarget.value || undefined)}
|
||||
placeholder={(setting as StringSetting).placeholder || ''}
|
||||
class="w-40 rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{setting.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
{/* Unknown Keys Section */}
|
||||
<Show when={unknownKeys().length > 0}>
|
||||
<div class="pt-3 mt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<p class="text-xs text-amber-600 dark:text-amber-400 mb-2">
|
||||
Additional settings (not in standard list):
|
||||
</p>
|
||||
<For each={unknownKeys()}>
|
||||
{(key) => (
|
||||
<div class="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 mb-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300 font-mono">
|
||||
{key}
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={String(formSettings()[key] ?? '')}
|
||||
onInput={(e) => {
|
||||
const val = e.currentTarget.value;
|
||||
// Try to parse as JSON for complex values
|
||||
try {
|
||||
updateSetting(key, JSON.parse(val));
|
||||
} catch {
|
||||
updateSetting(key, val || undefined);
|
||||
}
|
||||
}}
|
||||
class="w-32 rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateSetting(key, undefined)}
|
||||
class="p-1 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30"
|
||||
title="Remove this setting"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -577,6 +709,14 @@ export const AgentProfilesPanel: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Suggest Profile Modal */}
|
||||
<Show when={showSuggestModal()}>
|
||||
<SuggestProfileModal
|
||||
onClose={() => setShowSuggestModal(false)}
|
||||
onSuggestionAccepted={handleSuggestionAccepted}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -14,6 +14,7 @@ import Download from 'lucide-solid/icons/download';
|
||||
import CheckCircle from 'lucide-solid/icons/check-circle';
|
||||
import XCircle from 'lucide-solid/icons/x-circle';
|
||||
import AlertTriangle from 'lucide-solid/icons/alert-triangle';
|
||||
import Sparkles from 'lucide-solid/icons/sparkles';
|
||||
|
||||
// Type definitions
|
||||
interface DiagnosticsNode {
|
||||
@@ -111,6 +112,18 @@ interface AlertsDiagnostic {
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
interface OpenCodeDiagnostic {
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
healthy: boolean;
|
||||
port?: number;
|
||||
url?: string;
|
||||
model?: string;
|
||||
mcpConnected: boolean;
|
||||
mcpToolCount?: number;
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
interface DiagnosticsData {
|
||||
version: string;
|
||||
runtime: string;
|
||||
@@ -122,6 +135,7 @@ interface DiagnosticsData {
|
||||
apiTokens?: APITokenDiagnostic | null;
|
||||
dockerAgents?: DockerAgentDiagnostic | null;
|
||||
alerts?: AlertsDiagnostic | null;
|
||||
openCode?: OpenCodeDiagnostic | null;
|
||||
discovery?: DiscoveryDiagnostic | null;
|
||||
errors: string[];
|
||||
}
|
||||
@@ -650,6 +664,51 @@ export const DiagnosticsPanel: Component = () => {
|
||||
</Show>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* OpenCode AI Status */}
|
||||
<Show when={diagnosticsData()?.openCode}>
|
||||
<Card padding="md">
|
||||
<div class="flex items-center gap-3 mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="p-2 rounded-lg bg-indigo-100 dark:bg-indigo-900/30">
|
||||
<Sparkles class="w-4 h-4 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">AI Assistant</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">OpenCode Sidecar</p>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<StatusBadge
|
||||
status={diagnosticsData()?.openCode?.running ? 'online' : (diagnosticsData()?.openCode?.enabled ? 'offline' : 'unknown')}
|
||||
label={diagnosticsData()?.openCode?.running ? 'Running' : (diagnosticsData()?.openCode?.enabled ? 'Stopped' : 'Disabled')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2 text-xs">
|
||||
<MetricRow label="Model" value={diagnosticsData()?.openCode?.model} />
|
||||
<MetricRow label="Port" value={diagnosticsData()?.openCode?.port} mono />
|
||||
<MetricRow label="Status" value={diagnosticsData()?.openCode?.healthy ? 'Healthy' : 'Unhealthy'} />
|
||||
</div>
|
||||
<div class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700/50 flex items-center justify-between text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">MCP Connection</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
{diagnosticsData()?.openCode?.mcpConnected ?
|
||||
<CheckCircle class="w-3.5 h-3.5 text-green-500" /> :
|
||||
<XCircle class="w-3.5 h-3.5 text-red-500" />
|
||||
}
|
||||
<span class={diagnosticsData()?.openCode?.mcpConnected ? 'text-green-700 dark:text-green-300' : 'text-gray-500'}>
|
||||
{diagnosticsData()?.openCode?.mcpConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={(diagnosticsData()?.openCode?.notes?.length || 0) > 0}>
|
||||
<ul class="mt-3 bg-amber-50 dark:bg-amber-900/10 p-2 rounded text-xs text-amber-700 dark:text-amber-400 list-disc pl-4">
|
||||
<For each={diagnosticsData()?.openCode?.notes || []}>
|
||||
{(note) => <li>{note}</li>}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Errors Section */}
|
||||
@@ -671,8 +730,8 @@ export const DiagnosticsPanel: Component = () => {
|
||||
</ul>
|
||||
</Card>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show >
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Component, createSignal, Show, For } from 'solid-js';
|
||||
import { AgentProfilesAPI, type ProfileSuggestion } from '@/api/agentProfiles';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { logger } from '@/utils/logger';
|
||||
import Sparkles from 'lucide-solid/icons/sparkles';
|
||||
import AlertCircle from 'lucide-solid/icons/alert-circle';
|
||||
import Check from 'lucide-solid/icons/check';
|
||||
import Loader2 from 'lucide-solid/icons/loader-2';
|
||||
|
||||
interface SuggestProfileModalProps {
|
||||
onClose: () => void;
|
||||
onSuggestionAccepted: (suggestion: ProfileSuggestion) => void;
|
||||
}
|
||||
|
||||
export const SuggestProfileModal: Component<SuggestProfileModalProps> = (props) => {
|
||||
const [prompt, setPrompt] = createSignal('');
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [suggestion, setSuggestion] = createSignal<ProfileSuggestion | null>(null);
|
||||
|
||||
// Example prompts for inspiration
|
||||
const examplePrompts = [
|
||||
'Create a profile for production servers with minimal logging',
|
||||
'Profile for Docker hosts that need container monitoring',
|
||||
'Kubernetes monitoring profile with all pods visible',
|
||||
'Development environment profile with debug logging',
|
||||
];
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const userPrompt = prompt().trim();
|
||||
if (!userPrompt) {
|
||||
setError('Please enter a description for the profile you need');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuggestion(null);
|
||||
|
||||
try {
|
||||
const result = await AgentProfilesAPI.suggestProfile({
|
||||
prompt: userPrompt,
|
||||
});
|
||||
setSuggestion(result);
|
||||
} catch (err) {
|
||||
logger.error('Failed to get profile suggestion', err);
|
||||
const message = err instanceof Error ? err.message : 'Failed to get suggestion';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccept = () => {
|
||||
const currentSuggestion = suggestion();
|
||||
if (currentSuggestion) {
|
||||
props.onSuggestionAccepted(currentSuggestion);
|
||||
notificationStore.success(`Profile "${currentSuggestion.name}" ready to create`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseExample = (example: string) => {
|
||||
setPrompt(example);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div class="w-full max-w-2xl bg-white dark:bg-gray-900 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-purple-100 dark:bg-purple-900/30">
|
||||
<Sparkles class="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
AI Profile Suggestion
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Describe what you need, and AI will draft a profile
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
class="p-1.5 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="px-6 py-4 space-y-4 overflow-y-auto flex-1">
|
||||
{/* Prompt Input */}
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
What kind of profile do you need?
|
||||
</label>
|
||||
<textarea
|
||||
value={prompt()}
|
||||
onInput={(e) => setPrompt(e.currentTarget.value)}
|
||||
placeholder="Describe the agents and use case for this profile..."
|
||||
rows={3}
|
||||
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-purple-400 dark:focus:ring-purple-800/60 resize-none"
|
||||
disabled={loading()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Example Prompts */}
|
||||
<Show when={!suggestion()}>
|
||||
<div class="space-y-2">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Examples:</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={examplePrompts}>
|
||||
{(example) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUseExample(example)}
|
||||
class="text-xs px-2 py-1 rounded-md bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 transition-colors"
|
||||
disabled={loading()}
|
||||
>
|
||||
{example}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Error Message */}
|
||||
<Show when={error()}>
|
||||
<div class="flex items-start gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<AlertCircle class="w-4 h-4 text-red-600 dark:text-red-400 mt-0.5 shrink-0" />
|
||||
<p class="text-sm text-red-700 dark:text-red-300">{error()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Loading State */}
|
||||
<Show when={loading()}>
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<Loader2 class="w-6 h-6 text-purple-600 dark:text-purple-400 animate-spin" />
|
||||
<span class="ml-3 text-gray-600 dark:text-gray-400">Generating suggestion...</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Suggestion Result */}
|
||||
<Show when={suggestion()}>
|
||||
{(sugg) => (
|
||||
<div class="space-y-4">
|
||||
{/* Draft Warning */}
|
||||
<div class="flex items-start gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
|
||||
<AlertCircle class="w-4 h-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
||||
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||
This is a draft suggestion. Review the settings before creating the profile.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Preview */}
|
||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Name & Description */}
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<h4 class="font-medium text-gray-900 dark:text-gray-100">{sugg().name}</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{sugg().description}</p>
|
||||
</div>
|
||||
|
||||
{/* Config */}
|
||||
<div class="p-4 space-y-3">
|
||||
<h5 class="text-sm font-medium text-gray-700 dark:text-gray-300">Settings</h5>
|
||||
<div class="bg-gray-900 dark:bg-gray-950 rounded-md p-3 overflow-x-auto">
|
||||
<pre class="text-xs text-gray-300 font-mono">
|
||||
{JSON.stringify(sugg().config, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rationale */}
|
||||
<Show when={sugg().rationale && sugg().rationale.length > 0}>
|
||||
<div class="p-4 border-t border-gray-200 dark:border-gray-700 space-y-2">
|
||||
<h5 class="text-sm font-medium text-gray-700 dark:text-gray-300">Rationale</h5>
|
||||
<ul class="space-y-1">
|
||||
<For each={sugg().rationale}>
|
||||
{(reason) => (
|
||||
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Check class="w-4 h-4 text-green-500 mt-0.5 shrink-0" />
|
||||
<span>{reason}</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<Show
|
||||
when={suggestion()}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading() || !prompt().trim()}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-purple-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Sparkles class="w-4 h-4" />
|
||||
{loading() ? 'Generating...' : 'Suggest Profile'}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSuggestion(null);
|
||||
setError(null);
|
||||
}}
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAccept}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700"
|
||||
>
|
||||
<Check class="w-4 h-4" />
|
||||
Use This Profile
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuggestProfileModal;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,17 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, fireEvent, screen, waitFor, cleanup } from '@solidjs/testing-library';
|
||||
import { render, fireEvent, screen, waitFor, cleanup, within } from '@solidjs/testing-library';
|
||||
import { createStore } from 'solid-js/store';
|
||||
import { UnifiedAgents } from '../UnifiedAgents';
|
||||
import type { Host, DockerHost } from '@/types/api';
|
||||
import type { Host, DockerHost, KubernetesCluster, RemovedDockerHost, RemovedKubernetesCluster } from '@/types/api';
|
||||
|
||||
let mockWsStore: {
|
||||
state: { hosts: Host[]; dockerHosts: DockerHost[] };
|
||||
state: {
|
||||
hosts: Host[];
|
||||
dockerHosts: DockerHost[];
|
||||
kubernetesClusters?: KubernetesCluster[];
|
||||
removedDockerHosts?: RemovedDockerHost[];
|
||||
removedKubernetesClusters?: RemovedKubernetesCluster[];
|
||||
};
|
||||
connected: () => boolean;
|
||||
reconnecting: () => boolean;
|
||||
activeAlerts: unknown[];
|
||||
@@ -20,6 +26,8 @@ const notificationErrorMock = vi.fn();
|
||||
const notificationInfoMock = vi.fn();
|
||||
const clipboardSpy = vi.fn();
|
||||
const fetchMock = vi.fn();
|
||||
const listProfilesMock = vi.fn();
|
||||
const listAssignmentsMock = vi.fn();
|
||||
|
||||
vi.mock('@/App', () => ({
|
||||
useWebSocket: () => mockWsStore,
|
||||
@@ -40,6 +48,13 @@ vi.mock('@/api/security', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/api/agentProfiles', () => ({
|
||||
AgentProfilesAPI: {
|
||||
listProfiles: (...args: unknown[]) => listProfilesMock(...args),
|
||||
listAssignments: (...args: unknown[]) => listAssignmentsMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: {
|
||||
success: (...args: unknown[]) => notificationSuccessMock(...args),
|
||||
@@ -100,10 +115,37 @@ const createDockerHost = (overrides?: Partial<DockerHost>): DockerHost => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupComponent = (hosts: Host[] = [], dockerHosts: DockerHost[] = []) => {
|
||||
const createKubernetesCluster = (overrides?: Partial<KubernetesCluster>): KubernetesCluster => ({
|
||||
id: 'cluster-1',
|
||||
agentId: 'cluster-agent-1',
|
||||
name: 'cluster-1',
|
||||
displayName: 'Cluster One',
|
||||
status: 'online',
|
||||
lastSeen: Date.now(),
|
||||
intervalSeconds: 30,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createRemovedDockerHost = (overrides?: Partial<RemovedDockerHost>): RemovedDockerHost => ({
|
||||
id: 'removed-docker-1',
|
||||
hostname: 'old-docker.local',
|
||||
removedAt: Date.now() - 60_000,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupComponent = (
|
||||
hosts: Host[] = [],
|
||||
dockerHosts: DockerHost[] = [],
|
||||
kubernetesClusters: KubernetesCluster[] = [],
|
||||
removedDockerHosts: RemovedDockerHost[] = [],
|
||||
removedKubernetesClusters: RemovedKubernetesCluster[] = [],
|
||||
) => {
|
||||
const [state] = createStore({
|
||||
hosts,
|
||||
dockerHosts,
|
||||
kubernetesClusters,
|
||||
removedDockerHosts,
|
||||
removedKubernetesClusters,
|
||||
});
|
||||
|
||||
mockWsStore = {
|
||||
@@ -124,6 +166,8 @@ beforeEach(() => {
|
||||
notificationSuccessMock.mockReset();
|
||||
notificationErrorMock.mockReset();
|
||||
notificationInfoMock.mockReset();
|
||||
listProfilesMock.mockReset();
|
||||
listAssignmentsMock.mockReset();
|
||||
clipboardSpy.mockReset().mockResolvedValue(undefined);
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(
|
||||
@@ -134,6 +178,9 @@ beforeEach(() => {
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText: clipboardSpy } } as unknown as Navigator);
|
||||
|
||||
listProfilesMock.mockResolvedValue([]);
|
||||
listAssignmentsMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -298,8 +345,15 @@ describe('UnifiedAgents managed agents table', () => {
|
||||
});
|
||||
|
||||
expect(screen.getByText('Test Server')).toBeInTheDocument();
|
||||
expect(screen.getByText('Host')).toBeInTheDocument();
|
||||
expect(screen.getByText('online')).toBeInTheDocument();
|
||||
|
||||
const toggle = screen.getByRole('button', { name: /details for Test Server/i });
|
||||
fireEvent.click(toggle);
|
||||
|
||||
const detailsRow = document.getElementById('agent-details-agent-host-1');
|
||||
expect(detailsRow).not.toBeNull();
|
||||
const details = within(detailsRow as HTMLElement);
|
||||
expect(details.getByText('Host')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays docker hosts in the table', async () => {
|
||||
@@ -314,7 +368,14 @@ describe('UnifiedAgents managed agents table', () => {
|
||||
});
|
||||
|
||||
expect(screen.getByText('Docker Server')).toBeInTheDocument();
|
||||
expect(screen.getByText('Docker')).toBeInTheDocument();
|
||||
|
||||
const toggle = screen.getByRole('button', { name: /details for Docker Server/i });
|
||||
fireEvent.click(toggle);
|
||||
|
||||
const detailsRow = document.getElementById('agent-details-agent-docker-host-1');
|
||||
expect(detailsRow).not.toBeNull();
|
||||
const details = within(detailsRow as HTMLElement);
|
||||
expect(details.getByText('Docker')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no agents are installed', async () => {
|
||||
@@ -332,7 +393,49 @@ describe('UnifiedAgents managed agents table', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/legacy agent.*detected/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Legacy')).toBeInTheDocument();
|
||||
|
||||
const toggle = screen.getByRole('button', { name: /details for Host One/i });
|
||||
fireEvent.click(toggle);
|
||||
|
||||
const detailsRow = document.getElementById('agent-details-agent-host-1');
|
||||
expect(detailsRow).not.toBeNull();
|
||||
const details = within(detailsRow as HTMLElement);
|
||||
expect(details.getByText('Legacy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters removed agents with the status filter', async () => {
|
||||
const host = createHost({ displayName: 'Active Host' });
|
||||
const removedHost = createRemovedDockerHost();
|
||||
setupComponent([host], [], [], [removedHost]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Managed Agents')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Active Host')).toBeInTheDocument();
|
||||
expect(screen.getByText('old-docker.local')).toBeInTheDocument();
|
||||
|
||||
const statusSelect = screen.getByLabelText('Status');
|
||||
fireEvent.change(statusSelect, { target: { value: 'removed' } });
|
||||
|
||||
expect(screen.queryByText('Active Host')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('old-docker.local')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Kubernetes clusters in the unified table', async () => {
|
||||
const cluster = createKubernetesCluster({ displayName: 'K8s Alpha' });
|
||||
setupComponent([], [], [cluster]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Managed Agents')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('K8s Alpha')).toBeInTheDocument();
|
||||
|
||||
const typeSelect = screen.getByLabelText('Type');
|
||||
fireEvent.change(typeSelect, { target: { value: 'kubernetes' } });
|
||||
|
||||
expect(screen.getByText('K8s Alpha')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -224,71 +224,6 @@ export default defineConfig({
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
// OpenCode API proxies - when OpenCode is embedded in iframe, its frontend
|
||||
// makes requests to window.location.origin. We proxy these to the backend
|
||||
// which forwards them to OpenCode's actual backend.
|
||||
// Note: /global is OpenCode's client-side route, not an API endpoint
|
||||
'/session': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
ws: true, // WebSocket support for session events
|
||||
},
|
||||
'/tui': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/config': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/file': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/find': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/instance': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/mcp': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/permission': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/project': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/provider': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/pty': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
ws: true, // WebSocket support for PTY
|
||||
},
|
||||
'/question': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/experimental': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
// OpenCode Web UI proxy - serves OpenCode's built-in interface
|
||||
'/opencode': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
// WebSocket support for OpenCode's real-time features
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
+869
-16
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -30,6 +32,22 @@ type AgentServer interface {
|
||||
ExecuteCommand(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error)
|
||||
}
|
||||
|
||||
// AgentProfileManager manages centralized agent profiles and assignments.
|
||||
type AgentProfileManager interface {
|
||||
ApplyAgentScope(ctx context.Context, agentID, agentLabel string, settings map[string]interface{}) (profileID, profileName string, created bool, err error)
|
||||
AssignProfile(ctx context.Context, agentID, profileID string) (profileName string, err error)
|
||||
GetAgentScope(ctx context.Context, agentID string) (*AgentScope, error)
|
||||
}
|
||||
|
||||
// AgentScope summarizes profile scope applied to an agent.
|
||||
type AgentScope struct {
|
||||
AgentID string
|
||||
ProfileID string
|
||||
ProfileName string
|
||||
ProfileVersion int
|
||||
Settings map[string]interface{}
|
||||
}
|
||||
|
||||
// MetadataUpdater updates resource metadata
|
||||
type MetadataUpdater interface {
|
||||
SetResourceURL(resourceType, resourceID, url string) error
|
||||
@@ -168,6 +186,20 @@ type DiskHealthProvider interface {
|
||||
GetHosts() []models.Host
|
||||
}
|
||||
|
||||
// ControlLevel represents the AI's permission level for infrastructure control
|
||||
type ControlLevel string
|
||||
|
||||
const (
|
||||
// ControlLevelReadOnly - AI can only query, no control tools available
|
||||
ControlLevelReadOnly ControlLevel = "read_only"
|
||||
// ControlLevelSuggest - AI suggests commands, user must copy/paste to execute
|
||||
ControlLevelSuggest ControlLevel = "suggest"
|
||||
// ControlLevelControlled - AI can execute with per-command approval
|
||||
ControlLevelControlled ControlLevel = "controlled"
|
||||
// ControlLevelAutonomous - AI executes without approval (requires Pro license)
|
||||
ControlLevelAutonomous ControlLevel = "autonomous"
|
||||
)
|
||||
|
||||
// PulseToolExecutor implements ToolExecutor for Pulse-specific tools
|
||||
type PulseToolExecutor struct {
|
||||
stateProvider StateProvider
|
||||
@@ -188,6 +220,12 @@ type PulseToolExecutor struct {
|
||||
storageProvider StorageProvider
|
||||
diskHealthProvider DiskHealthProvider
|
||||
|
||||
agentProfileManager AgentProfileManager
|
||||
|
||||
// Control settings
|
||||
controlLevel ControlLevel
|
||||
protectedGuests []string // VMIDs that AI cannot control
|
||||
|
||||
// Current execution context
|
||||
targetType string
|
||||
targetID string
|
||||
@@ -257,6 +295,21 @@ func (e *PulseToolExecutor) SetDiskHealthProvider(provider DiskHealthProvider) {
|
||||
e.diskHealthProvider = provider
|
||||
}
|
||||
|
||||
// SetAgentProfileManager sets the manager for centralized agent profiles.
|
||||
func (e *PulseToolExecutor) SetAgentProfileManager(manager AgentProfileManager) {
|
||||
e.agentProfileManager = manager
|
||||
}
|
||||
|
||||
// SetControlLevel sets the AI control permission level
|
||||
func (e *PulseToolExecutor) SetControlLevel(level ControlLevel) {
|
||||
e.controlLevel = level
|
||||
}
|
||||
|
||||
// SetProtectedGuests sets the list of VMIDs that AI cannot control
|
||||
func (e *PulseToolExecutor) SetProtectedGuests(vmids []string) {
|
||||
e.protectedGuests = vmids
|
||||
}
|
||||
|
||||
// SetContext sets the current execution context
|
||||
func (e *PulseToolExecutor) SetContext(targetType, targetID string, autonomous bool) {
|
||||
e.targetType = targetType
|
||||
@@ -266,27 +319,22 @@ func (e *PulseToolExecutor) SetContext(targetType, targetID string, autonomous b
|
||||
|
||||
// ListTools returns the list of available tools
|
||||
func (e *PulseToolExecutor) ListTools() []Tool {
|
||||
return []Tool{
|
||||
tools := []Tool{
|
||||
{
|
||||
Name: "pulse_run_command",
|
||||
Description: "Execute a shell command on Pulse-managed infrastructure. By default runs on the current target, set run_on_host=true for host commands.",
|
||||
Name: "pulse_get_agent_scope",
|
||||
Description: "Get the current unified agent scope (profile assignment and settings).",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"command": {
|
||||
"agent_id": {
|
||||
Type: "string",
|
||||
Description: "The shell command to execute",
|
||||
Description: "Unified agent ID (preferred if known)",
|
||||
},
|
||||
"run_on_host": {
|
||||
Type: "boolean",
|
||||
Description: "If true, run on the host instead of inside the container/VM",
|
||||
},
|
||||
"target_host": {
|
||||
"hostname": {
|
||||
Type: "string",
|
||||
Description: "Optional hostname of the specific host/node to run the command on",
|
||||
Description: "Hostname or display name to resolve the agent ID",
|
||||
},
|
||||
},
|
||||
Required: []string{"command"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -492,6 +540,108 @@ func (e *PulseToolExecutor) ListTools() []Tool {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add control tools if not in read_only mode
|
||||
if e.controlLevel != ControlLevelReadOnly && e.controlLevel != "" {
|
||||
controlTools := []Tool{
|
||||
{
|
||||
Name: "pulse_run_command",
|
||||
Description: "Execute a shell command on Pulse-managed infrastructure. By default runs on the current target, set run_on_host=true for host commands.",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"command": {
|
||||
Type: "string",
|
||||
Description: "The shell command to execute",
|
||||
},
|
||||
"run_on_host": {
|
||||
Type: "boolean",
|
||||
Description: "If true, run on the host instead of inside the container/VM",
|
||||
},
|
||||
"target_host": {
|
||||
Type: "string",
|
||||
Description: "Optional hostname of the specific host/node to run the command on",
|
||||
},
|
||||
},
|
||||
Required: []string{"command"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pulse_control_guest",
|
||||
Description: "Control Proxmox VMs and LXC containers. Actions: start, stop, shutdown (graceful), restart. Requires an agent on the Proxmox host.",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"guest_id": {
|
||||
Type: "string",
|
||||
Description: "The VMID (e.g., '101') or name of the VM/container to control",
|
||||
},
|
||||
"action": {
|
||||
Type: "string",
|
||||
Description: "Action to perform: start, stop, shutdown, restart",
|
||||
Enum: []string{"start", "stop", "shutdown", "restart"},
|
||||
},
|
||||
"force": {
|
||||
Type: "boolean",
|
||||
Description: "If true, force stop without graceful shutdown (use with caution)",
|
||||
},
|
||||
},
|
||||
Required: []string{"guest_id", "action"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pulse_control_docker",
|
||||
Description: "Control Docker containers. Actions: start, stop, restart. Requires an agent on the Docker host.",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"container": {
|
||||
Type: "string",
|
||||
Description: "The container name or ID to control",
|
||||
},
|
||||
"host": {
|
||||
Type: "string",
|
||||
Description: "The Docker host name (required if multiple hosts)",
|
||||
},
|
||||
"action": {
|
||||
Type: "string",
|
||||
Description: "Action to perform: start, stop, restart",
|
||||
Enum: []string{"start", "stop", "restart"},
|
||||
},
|
||||
},
|
||||
Required: []string{"container", "action"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pulse_set_agent_scope",
|
||||
Description: "Update a unified agent's scope via safe profile settings. Use this instead of running raw commands to enable/disable modules like Docker, Kubernetes, or Proxmox.",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"agent_id": {
|
||||
Type: "string",
|
||||
Description: "Unified agent ID (preferred if known)",
|
||||
},
|
||||
"hostname": {
|
||||
Type: "string",
|
||||
Description: "Hostname or display name to resolve the agent ID",
|
||||
},
|
||||
"profile_id": {
|
||||
Type: "string",
|
||||
Description: "Assign an existing profile ID (optional; omit to use settings)",
|
||||
},
|
||||
"settings": {
|
||||
Type: "object",
|
||||
Description: "Profile settings (e.g., enable_host, enable_docker, enable_kubernetes, enable_proxmox, proxmox_type, docker_runtime, disable_auto_update, disable_docker_update_checks, kube_include_all_pods, kube_include_all_deployments, log_level, interval, report_ip, disable_ceph)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tools = append(tools, controlTools...)
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
// ExecuteTool executes a tool and returns the result
|
||||
@@ -508,6 +658,8 @@ func (e *PulseToolExecutor) ExecuteTool(ctx context.Context, name string, args m
|
||||
return e.executeFetchURL(ctx, args)
|
||||
case "pulse_get_infrastructure_state":
|
||||
return e.executeGetInfrastructureState(ctx)
|
||||
case "pulse_get_agent_scope":
|
||||
return e.executeGetAgentScope(ctx, args)
|
||||
case "pulse_set_resource_url":
|
||||
return e.executeSetResourceURL(ctx, args)
|
||||
case "pulse_resolve_finding":
|
||||
@@ -532,6 +684,12 @@ func (e *PulseToolExecutor) ExecuteTool(ctx context.Context, name string, args m
|
||||
return e.executeGetResourceDetails(ctx, args)
|
||||
case "pulse_get_disk_health":
|
||||
return e.executeGetDiskHealth(ctx, args)
|
||||
case "pulse_control_guest":
|
||||
return e.executeControlGuest(ctx, args)
|
||||
case "pulse_control_docker":
|
||||
return e.executeControlDocker(ctx, args)
|
||||
case "pulse_set_agent_scope":
|
||||
return e.executeSetAgentScope(ctx, args)
|
||||
default:
|
||||
return NewErrorResult(fmt.Errorf("unknown tool: %s", name)), nil
|
||||
}
|
||||
@@ -546,15 +704,28 @@ func (e *PulseToolExecutor) executeRunCommand(ctx context.Context, args map[stri
|
||||
return NewErrorResult(fmt.Errorf("command is required")), nil
|
||||
}
|
||||
|
||||
if e.controlLevel == ControlLevelReadOnly || e.controlLevel == "" {
|
||||
return NewTextResult("Control tools are disabled. Enable them in Settings > AI > Control Level."), nil
|
||||
}
|
||||
|
||||
// Check security policy
|
||||
decision := agentexec.PolicyAllow
|
||||
if e.policy != nil {
|
||||
decision := e.policy.Evaluate(command)
|
||||
decision = e.policy.Evaluate(command)
|
||||
if decision == agentexec.PolicyBlock {
|
||||
return NewTextResult(formatPolicyBlocked(command, "This command is blocked by security policy")), nil
|
||||
}
|
||||
if decision == agentexec.PolicyRequireApproval && !e.isAutonomous {
|
||||
return NewTextResult(formatApprovalNeeded(command, "Security policy requires approval")), nil
|
||||
}
|
||||
}
|
||||
|
||||
if e.controlLevel == ControlLevelSuggest {
|
||||
return NewTextResult(formatCommandSuggestion(command, runOnHost, targetHost)), nil
|
||||
}
|
||||
|
||||
if e.controlLevel == ControlLevelControlled {
|
||||
return NewTextResult(formatApprovalNeeded(command, "Control level requires approval")), nil
|
||||
}
|
||||
if decision == agentexec.PolicyRequireApproval && !e.isAutonomous {
|
||||
return NewTextResult(formatApprovalNeeded(command, "Security policy requires approval")), nil
|
||||
}
|
||||
|
||||
// Execute via agent server
|
||||
@@ -595,6 +766,297 @@ func (e *PulseToolExecutor) executeRunCommand(ctx context.Context, args map[stri
|
||||
return NewTextResult(output), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeSetAgentScope(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
if e.agentProfileManager == nil {
|
||||
return NewTextResult("Agent scope management is not available."), nil
|
||||
}
|
||||
if e.controlLevel == ControlLevelReadOnly || e.controlLevel == "" {
|
||||
return NewTextResult("Agent scope tools are disabled. Enable them in Settings > AI > Control Level."), nil
|
||||
}
|
||||
|
||||
agentID, _ := args["agent_id"].(string)
|
||||
hostname, _ := args["hostname"].(string)
|
||||
profileID, _ := args["profile_id"].(string)
|
||||
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
hostname = strings.TrimSpace(hostname)
|
||||
profileID = strings.TrimSpace(profileID)
|
||||
|
||||
settings := map[string]interface{}{}
|
||||
if rawSettings, ok := args["settings"].(map[string]interface{}); ok {
|
||||
for key, value := range rawSettings {
|
||||
if value != nil {
|
||||
settings[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if agentID == "" && hostname == "" {
|
||||
return NewErrorResult(fmt.Errorf("agent_id or hostname is required")), nil
|
||||
}
|
||||
|
||||
agentLabel := agentID
|
||||
if agentID == "" {
|
||||
if e.stateProvider == nil {
|
||||
return NewErrorResult(fmt.Errorf("state provider not available to resolve hostname")), nil
|
||||
}
|
||||
resolvedID, resolvedLabel := resolveAgentFromHostname(e.stateProvider.GetState(), hostname)
|
||||
if resolvedID == "" {
|
||||
return NewTextResult(fmt.Sprintf("No agent found for hostname '%s'.", hostname)), nil
|
||||
}
|
||||
agentID = resolvedID
|
||||
agentLabel = resolvedLabel
|
||||
} else if e.stateProvider != nil {
|
||||
if resolvedLabel := resolveAgentLabel(e.stateProvider.GetState(), agentID); resolvedLabel != "" {
|
||||
agentLabel = resolvedLabel
|
||||
}
|
||||
}
|
||||
|
||||
if profileID != "" && len(settings) > 0 {
|
||||
return NewErrorResult(fmt.Errorf("use either profile_id or settings, not both")), nil
|
||||
}
|
||||
|
||||
if e.controlLevel == ControlLevelSuggest {
|
||||
if profileID != "" {
|
||||
return NewTextResult(fmt.Sprintf("Suggestion: assign profile %s to agent %s.", profileID, agentLabel)), nil
|
||||
}
|
||||
if len(settings) == 0 {
|
||||
return NewErrorResult(fmt.Errorf("settings are required when profile_id is not provided")), nil
|
||||
}
|
||||
return NewTextResult(fmt.Sprintf("Suggestion: apply agent scope to %s with settings: %s", agentLabel, formatSettingsSummary(settings))), nil
|
||||
}
|
||||
|
||||
if profileID != "" {
|
||||
profileName, err := e.agentProfileManager.AssignProfile(ctx, agentID, profileID)
|
||||
if err != nil {
|
||||
return NewErrorResult(err), nil
|
||||
}
|
||||
return NewTextResult(fmt.Sprintf("Assigned profile '%s' (%s) to agent %s. Restart the agent to apply changes.", profileName, profileID, agentLabel)), nil
|
||||
}
|
||||
|
||||
if len(settings) == 0 {
|
||||
return NewErrorResult(fmt.Errorf("settings are required when profile_id is not provided")), nil
|
||||
}
|
||||
|
||||
profileID, profileName, created, err := e.agentProfileManager.ApplyAgentScope(ctx, agentID, agentLabel, settings)
|
||||
if err != nil {
|
||||
return NewErrorResult(err), nil
|
||||
}
|
||||
|
||||
action := "Updated"
|
||||
if created {
|
||||
action = "Created"
|
||||
}
|
||||
return NewTextResult(fmt.Sprintf("%s profile '%s' (%s) and assigned to agent %s. Restart the agent to apply changes. Settings: %s", action, profileName, profileID, agentLabel, formatSettingsSummary(settings))), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeGetAgentScope(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
agentID, _ := args["agent_id"].(string)
|
||||
hostname, _ := args["hostname"].(string)
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
hostname = strings.TrimSpace(hostname)
|
||||
|
||||
if agentID == "" && hostname == "" {
|
||||
return NewErrorResult(fmt.Errorf("agent_id or hostname is required")), nil
|
||||
}
|
||||
|
||||
agentLabel := agentID
|
||||
if agentID == "" {
|
||||
if e.stateProvider == nil {
|
||||
return NewErrorResult(fmt.Errorf("state provider not available to resolve hostname")), nil
|
||||
}
|
||||
resolvedID, resolvedLabel := resolveAgentFromHostname(e.stateProvider.GetState(), hostname)
|
||||
if resolvedID == "" {
|
||||
return NewTextResult(fmt.Sprintf("No agent found for hostname '%s'.", hostname)), nil
|
||||
}
|
||||
agentID = resolvedID
|
||||
agentLabel = resolvedLabel
|
||||
} else if e.stateProvider != nil {
|
||||
if resolvedLabel := resolveAgentLabel(e.stateProvider.GetState(), agentID); resolvedLabel != "" {
|
||||
agentLabel = resolvedLabel
|
||||
}
|
||||
}
|
||||
|
||||
var scope *AgentScope
|
||||
if e.agentProfileManager != nil {
|
||||
var err error
|
||||
scope, err = e.agentProfileManager.GetAgentScope(ctx, agentID)
|
||||
if err != nil {
|
||||
return NewTextResult(fmt.Sprintf("Failed to load agent scope for %s: %v", agentLabel, err)), nil
|
||||
}
|
||||
}
|
||||
|
||||
var observed []string
|
||||
var commandsEnabled *bool
|
||||
if e.stateProvider != nil {
|
||||
observed, commandsEnabled = detectAgentModules(e.stateProvider.GetState(), agentID)
|
||||
}
|
||||
|
||||
var summary strings.Builder
|
||||
summary.WriteString(fmt.Sprintf("Agent: %s\n", agentLabel))
|
||||
summary.WriteString(fmt.Sprintf("Agent ID: %s\n", agentID))
|
||||
|
||||
if scope == nil {
|
||||
summary.WriteString("Assigned profile: none\n")
|
||||
} else {
|
||||
summary.WriteString(fmt.Sprintf("Assigned profile: %s (%s)\n", scope.ProfileName, scope.ProfileID))
|
||||
if scope.ProfileVersion > 0 {
|
||||
summary.WriteString(fmt.Sprintf("Profile version: %d\n", scope.ProfileVersion))
|
||||
}
|
||||
}
|
||||
|
||||
if len(observed) > 0 {
|
||||
summary.WriteString(fmt.Sprintf("Observed modules: %s\n", strings.Join(observed, ", ")))
|
||||
}
|
||||
if commandsEnabled != nil {
|
||||
if *commandsEnabled {
|
||||
summary.WriteString("AI commands: enabled\n")
|
||||
} else {
|
||||
summary.WriteString("AI commands: disabled\n")
|
||||
}
|
||||
}
|
||||
|
||||
if scope != nil && len(scope.Settings) > 0 {
|
||||
summary.WriteString("Profile settings:\n")
|
||||
for _, line := range formatSettingsLines(scope.Settings) {
|
||||
summary.WriteString(line)
|
||||
}
|
||||
} else {
|
||||
summary.WriteString("Profile settings: none\n")
|
||||
}
|
||||
|
||||
summary.WriteString("Note: profile changes apply after the agent restarts.")
|
||||
|
||||
return NewTextResult(summary.String()), nil
|
||||
}
|
||||
|
||||
func resolveAgentFromHostname(state models.StateSnapshot, hostname string) (string, string) {
|
||||
needle := strings.TrimSpace(hostname)
|
||||
if needle == "" {
|
||||
return "", ""
|
||||
}
|
||||
for _, host := range state.Hosts {
|
||||
if strings.EqualFold(host.Hostname, needle) || strings.EqualFold(host.DisplayName, needle) || strings.EqualFold(host.ID, needle) {
|
||||
label := firstNonEmpty(host.DisplayName, host.Hostname, host.ID)
|
||||
return host.ID, label
|
||||
}
|
||||
}
|
||||
for _, host := range state.DockerHosts {
|
||||
if strings.EqualFold(host.Hostname, needle) || strings.EqualFold(host.DisplayName, needle) || strings.EqualFold(host.CustomDisplayName, needle) || strings.EqualFold(host.ID, needle) {
|
||||
label := firstNonEmpty(host.CustomDisplayName, host.DisplayName, host.Hostname, host.ID)
|
||||
agentID := strings.TrimSpace(host.AgentID)
|
||||
if agentID == "" {
|
||||
agentID = host.ID
|
||||
}
|
||||
return agentID, label
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func resolveAgentLabel(state models.StateSnapshot, agentID string) string {
|
||||
needle := strings.TrimSpace(agentID)
|
||||
if needle == "" {
|
||||
return ""
|
||||
}
|
||||
for _, host := range state.Hosts {
|
||||
if strings.EqualFold(host.ID, needle) {
|
||||
return firstNonEmpty(host.DisplayName, host.Hostname, host.ID)
|
||||
}
|
||||
}
|
||||
for _, host := range state.DockerHosts {
|
||||
if strings.EqualFold(host.AgentID, needle) || strings.EqualFold(host.ID, needle) {
|
||||
return firstNonEmpty(host.CustomDisplayName, host.DisplayName, host.Hostname, host.ID)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatSettingsSummary(settings map[string]interface{}) string {
|
||||
if len(settings) == 0 {
|
||||
return "none"
|
||||
}
|
||||
keys := make([]string, 0, len(settings))
|
||||
for key := range settings {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%v", key, settings[key]))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func formatSettingsLines(settings map[string]interface{}) []string {
|
||||
if len(settings) == 0 {
|
||||
return []string{" - none\n"}
|
||||
}
|
||||
keys := make([]string, 0, len(settings))
|
||||
for key := range settings {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
lines := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
lines = append(lines, fmt.Sprintf(" - %s: %v\n", key, settings[key]))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func detectAgentModules(state models.StateSnapshot, agentID string) ([]string, *bool) {
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
if agentID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var modules []string
|
||||
var commandsEnabled *bool
|
||||
|
||||
for _, host := range state.Hosts {
|
||||
if strings.EqualFold(host.ID, agentID) {
|
||||
modules = append(modules, "host")
|
||||
val := host.CommandsEnabled
|
||||
commandsEnabled = &val
|
||||
if host.LinkedNodeID != "" {
|
||||
modules = append(modules, "proxmox")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, dockerHost := range state.DockerHosts {
|
||||
if strings.EqualFold(dockerHost.AgentID, agentID) || strings.EqualFold(dockerHost.ID, agentID) {
|
||||
modules = append(modules, "docker")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, cluster := range state.KubernetesClusters {
|
||||
if strings.EqualFold(cluster.AgentID, agentID) {
|
||||
modules = append(modules, "kubernetes")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(modules) == 0 {
|
||||
return nil, commandsEnabled
|
||||
}
|
||||
|
||||
sort.Strings(modules)
|
||||
return modules, commandsEnabled
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeFetchURL(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
url, _ := args["url"].(string)
|
||||
if url == "" {
|
||||
@@ -774,6 +1236,17 @@ func formatPolicyBlocked(command, reason string) string {
|
||||
return "POLICY_BLOCKED: " + string(b)
|
||||
}
|
||||
|
||||
func formatCommandSuggestion(command string, runOnHost bool, targetHost string) string {
|
||||
target := "current target"
|
||||
if runOnHost {
|
||||
target = "host"
|
||||
}
|
||||
if strings.TrimSpace(targetHost) != "" {
|
||||
target = fmt.Sprintf("host %s", targetHost)
|
||||
}
|
||||
return fmt.Sprintf("Suggested command for %s:\n%s", target, command)
|
||||
}
|
||||
|
||||
// Patrol context tool implementations
|
||||
|
||||
func (e *PulseToolExecutor) executeGetMetricsHistory(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
@@ -1423,3 +1896,383 @@ func (e *PulseToolExecutor) executeGetDiskHealth(_ context.Context, _ map[string
|
||||
|
||||
return NewTextResult(result.String()), nil
|
||||
}
|
||||
|
||||
// Control tool implementations
|
||||
|
||||
// GuestInfo represents resolved guest information
|
||||
type GuestInfo struct {
|
||||
VMID int
|
||||
Name string
|
||||
Node string
|
||||
Type string // "vm" or "lxc"
|
||||
Status string
|
||||
Instance string
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeControlGuest(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
guestID, _ := args["guest_id"].(string)
|
||||
action, _ := args["action"].(string)
|
||||
force, _ := args["force"].(bool)
|
||||
|
||||
if guestID == "" {
|
||||
return NewErrorResult(fmt.Errorf("guest_id is required")), nil
|
||||
}
|
||||
if action == "" {
|
||||
return NewErrorResult(fmt.Errorf("action is required")), nil
|
||||
}
|
||||
|
||||
// Validate action
|
||||
validActions := map[string]bool{"start": true, "stop": true, "shutdown": true, "restart": true}
|
||||
if !validActions[action] {
|
||||
return NewErrorResult(fmt.Errorf("invalid action: %s. Use start, stop, shutdown, or restart", action)), nil
|
||||
}
|
||||
|
||||
// Check control level
|
||||
if e.controlLevel == ControlLevelReadOnly || e.controlLevel == "" {
|
||||
return NewTextResult("Control tools are disabled. Enable them in Settings > AI > Control Level."), nil
|
||||
}
|
||||
|
||||
// Resolve guest to find VMID, node, and type
|
||||
guest, err := e.resolveGuest(guestID)
|
||||
if err != nil {
|
||||
return NewTextResult(fmt.Sprintf("Could not find guest '%s': %v", guestID, err)), nil
|
||||
}
|
||||
|
||||
// Check if guest is protected
|
||||
vmidStr := fmt.Sprintf("%d", guest.VMID)
|
||||
for _, protected := range e.protectedGuests {
|
||||
if protected == vmidStr || protected == guest.Name {
|
||||
return NewTextResult(fmt.Sprintf("Guest %s (VMID %d) is protected and cannot be controlled by AI.", guest.Name, guest.VMID)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Build the command based on guest type and action
|
||||
var command string
|
||||
cmdTool := "pct" // LXC containers
|
||||
if guest.Type == "vm" {
|
||||
cmdTool = "qm"
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "start":
|
||||
command = fmt.Sprintf("%s start %d", cmdTool, guest.VMID)
|
||||
case "stop":
|
||||
command = fmt.Sprintf("%s stop %d", cmdTool, guest.VMID)
|
||||
case "shutdown":
|
||||
command = fmt.Sprintf("%s shutdown %d", cmdTool, guest.VMID)
|
||||
case "restart":
|
||||
// Restart is shutdown + start, but we'll use reboot for simplicity
|
||||
command = fmt.Sprintf("%s reboot %d", cmdTool, guest.VMID)
|
||||
}
|
||||
|
||||
// Add force flag if requested (only for stop)
|
||||
if force && action == "stop" {
|
||||
command = fmt.Sprintf("%s stop %d --skiplock", cmdTool, guest.VMID)
|
||||
}
|
||||
|
||||
// Check security policy
|
||||
if e.policy != nil {
|
||||
decision := e.policy.Evaluate(command)
|
||||
if decision == agentexec.PolicyBlock {
|
||||
return NewTextResult(formatPolicyBlocked(command, "This command is blocked by security policy")), nil
|
||||
}
|
||||
// For control level "controlled", always require approval
|
||||
if e.controlLevel == ControlLevelControlled || (decision == agentexec.PolicyRequireApproval && !e.isAutonomous) {
|
||||
return NewTextResult(formatControlApprovalNeeded(guest.Name, guest.VMID, action, command)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// For "suggest" mode, just return the command suggestion
|
||||
if e.controlLevel == ControlLevelSuggest {
|
||||
return NewTextResult(formatControlSuggestion(guest.Name, guest.VMID, action, command, guest.Node)), nil
|
||||
}
|
||||
|
||||
// Execute the command via agent
|
||||
if e.agentServer == nil {
|
||||
return NewErrorResult(fmt.Errorf("no agent server available")), nil
|
||||
}
|
||||
|
||||
// Find agent for the node that owns this guest
|
||||
agentID := e.findAgentForNode(guest.Node)
|
||||
if agentID == "" {
|
||||
return NewTextResult(fmt.Sprintf("No agent available on node '%s'. Install Pulse Unified Agent on the Proxmox host to enable control.", guest.Node)), nil
|
||||
}
|
||||
|
||||
// Execute command
|
||||
result, err := e.agentServer.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{
|
||||
Command: command,
|
||||
TargetType: "host",
|
||||
TargetID: "",
|
||||
})
|
||||
if err != nil {
|
||||
return NewErrorResult(err), nil
|
||||
}
|
||||
|
||||
// Format result
|
||||
output := result.Stdout
|
||||
if result.Stderr != "" {
|
||||
output += "\n" + result.Stderr
|
||||
}
|
||||
|
||||
if result.ExitCode == 0 {
|
||||
return NewTextResult(fmt.Sprintf("Successfully executed '%s' on %s (VMID %d).\n%s", action, guest.Name, guest.VMID, output)), nil
|
||||
}
|
||||
|
||||
return NewTextResult(fmt.Sprintf("Command failed (exit code %d):\n%s", result.ExitCode, output)), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeControlDocker(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
containerName, _ := args["container"].(string)
|
||||
hostName, _ := args["host"].(string)
|
||||
action, _ := args["action"].(string)
|
||||
|
||||
if containerName == "" {
|
||||
return NewErrorResult(fmt.Errorf("container name is required")), nil
|
||||
}
|
||||
if action == "" {
|
||||
return NewErrorResult(fmt.Errorf("action is required")), nil
|
||||
}
|
||||
|
||||
// Validate action
|
||||
validActions := map[string]bool{"start": true, "stop": true, "restart": true}
|
||||
if !validActions[action] {
|
||||
return NewErrorResult(fmt.Errorf("invalid action: %s. Use start, stop, or restart", action)), nil
|
||||
}
|
||||
|
||||
// Check control level
|
||||
if e.controlLevel == ControlLevelReadOnly || e.controlLevel == "" {
|
||||
return NewTextResult("Control tools are disabled. Enable them in Settings > AI > Control Level."), nil
|
||||
}
|
||||
|
||||
// Find the Docker container and its host
|
||||
container, dockerHost, err := e.resolveDockerContainer(containerName, hostName)
|
||||
if err != nil {
|
||||
return NewTextResult(fmt.Sprintf("Could not find Docker container '%s': %v", containerName, err)), nil
|
||||
}
|
||||
|
||||
// Build the command
|
||||
command := fmt.Sprintf("docker %s %s", action, container.Name)
|
||||
|
||||
// Check security policy
|
||||
if e.policy != nil {
|
||||
decision := e.policy.Evaluate(command)
|
||||
if decision == agentexec.PolicyBlock {
|
||||
return NewTextResult(formatPolicyBlocked(command, "This command is blocked by security policy")), nil
|
||||
}
|
||||
if e.controlLevel == ControlLevelControlled || (decision == agentexec.PolicyRequireApproval && !e.isAutonomous) {
|
||||
return NewTextResult(formatDockerApprovalNeeded(container.Name, dockerHost.Hostname, action, command)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// For "suggest" mode, just return the command suggestion
|
||||
if e.controlLevel == ControlLevelSuggest {
|
||||
return NewTextResult(formatDockerSuggestion(container.Name, dockerHost.Hostname, action, command)), nil
|
||||
}
|
||||
|
||||
// Execute the command via agent
|
||||
if e.agentServer == nil {
|
||||
return NewErrorResult(fmt.Errorf("no agent server available")), nil
|
||||
}
|
||||
|
||||
// Find agent for this Docker host
|
||||
agentID := e.findAgentForDockerHost(dockerHost)
|
||||
if agentID == "" {
|
||||
return NewTextResult(fmt.Sprintf("No agent available on Docker host '%s'. Install Pulse Unified Agent on the host to enable control.", dockerHost.Hostname)), nil
|
||||
}
|
||||
|
||||
// Execute command
|
||||
result, err := e.agentServer.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{
|
||||
Command: command,
|
||||
TargetType: "host",
|
||||
TargetID: "",
|
||||
})
|
||||
if err != nil {
|
||||
return NewErrorResult(err), nil
|
||||
}
|
||||
|
||||
// Format result
|
||||
output := result.Stdout
|
||||
if result.Stderr != "" {
|
||||
output += "\n" + result.Stderr
|
||||
}
|
||||
|
||||
if result.ExitCode == 0 {
|
||||
return NewTextResult(fmt.Sprintf("Successfully executed 'docker %s' on container '%s' (host: %s).\n%s", action, container.Name, dockerHost.Hostname, output)), nil
|
||||
}
|
||||
|
||||
return NewTextResult(fmt.Sprintf("Command failed (exit code %d):\n%s", result.ExitCode, output)), nil
|
||||
}
|
||||
|
||||
// resolveGuest finds a guest (VM or container) by VMID or name
|
||||
func (e *PulseToolExecutor) resolveGuest(guestID string) (*GuestInfo, error) {
|
||||
if e.stateProvider == nil {
|
||||
return nil, fmt.Errorf("state provider not available")
|
||||
}
|
||||
|
||||
state := e.stateProvider.GetState()
|
||||
|
||||
// Try to parse as VMID
|
||||
vmid, err := strconv.Atoi(guestID)
|
||||
|
||||
// Search VMs
|
||||
for _, vm := range state.VMs {
|
||||
if (err == nil && vm.VMID == vmid) || vm.Name == guestID || vm.ID == guestID {
|
||||
return &GuestInfo{
|
||||
VMID: vm.VMID,
|
||||
Name: vm.Name,
|
||||
Node: vm.Node,
|
||||
Type: "vm",
|
||||
Status: vm.Status,
|
||||
Instance: vm.Instance,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Search containers
|
||||
for _, ct := range state.Containers {
|
||||
if (err == nil && ct.VMID == vmid) || ct.Name == guestID || ct.ID == guestID {
|
||||
return &GuestInfo{
|
||||
VMID: ct.VMID,
|
||||
Name: ct.Name,
|
||||
Node: ct.Node,
|
||||
Type: "lxc",
|
||||
Status: ct.Status,
|
||||
Instance: ct.Instance,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no VM or container found with ID or name '%s'", guestID)
|
||||
}
|
||||
|
||||
// resolveDockerContainer finds a Docker container by name or ID
|
||||
func (e *PulseToolExecutor) resolveDockerContainer(containerName, hostName string) (*models.DockerContainer, *models.DockerHost, error) {
|
||||
if e.stateProvider == nil {
|
||||
return nil, nil, fmt.Errorf("state provider not available")
|
||||
}
|
||||
|
||||
state := e.stateProvider.GetState()
|
||||
|
||||
for _, host := range state.DockerHosts {
|
||||
// If host name specified, only search that host
|
||||
if hostName != "" && host.Hostname != hostName && host.DisplayName != hostName {
|
||||
continue
|
||||
}
|
||||
|
||||
for i, container := range host.Containers {
|
||||
if container.Name == containerName ||
|
||||
container.ID == containerName ||
|
||||
strings.HasPrefix(container.ID, containerName) {
|
||||
return &host.Containers[i], &host, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hostName != "" {
|
||||
return nil, nil, fmt.Errorf("container '%s' not found on host '%s'", containerName, hostName)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("container '%s' not found on any Docker host", containerName)
|
||||
}
|
||||
|
||||
// findAgentForNode finds an agent connected to a specific Proxmox node
|
||||
func (e *PulseToolExecutor) findAgentForNode(nodeName string) string {
|
||||
if e.agentServer == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
agents := e.agentServer.GetConnectedAgents()
|
||||
for _, agent := range agents {
|
||||
// Check if agent hostname matches node name (common setup)
|
||||
if agent.Hostname == nodeName {
|
||||
return agent.AgentID
|
||||
}
|
||||
// Also check if agent has a linked node
|
||||
// Note: This requires the Host model to have LinkedNodeID populated
|
||||
}
|
||||
|
||||
// If no exact match, check hosts for linked agents
|
||||
if e.stateProvider != nil {
|
||||
state := e.stateProvider.GetState()
|
||||
for _, host := range state.Hosts {
|
||||
if host.LinkedNodeID != "" {
|
||||
// Check if this host's linked node matches
|
||||
for _, node := range state.Nodes {
|
||||
if node.ID == host.LinkedNodeID && node.Name == nodeName {
|
||||
// Find agent for this host
|
||||
for _, agent := range agents {
|
||||
if agent.Hostname == host.Hostname || agent.AgentID == host.ID {
|
||||
return agent.AgentID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// findAgentForDockerHost finds an agent connected to a Docker host
|
||||
func (e *PulseToolExecutor) findAgentForDockerHost(dockerHost *models.DockerHost) string {
|
||||
if e.agentServer == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
agents := e.agentServer.GetConnectedAgents()
|
||||
for _, agent := range agents {
|
||||
if agent.Hostname == dockerHost.Hostname {
|
||||
return agent.AgentID
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatControlApprovalNeeded formats a response for guest control needing approval
|
||||
func formatControlApprovalNeeded(name string, vmid int, action, command string) string {
|
||||
payload := map[string]interface{}{
|
||||
"type": "control_approval_required",
|
||||
"guest_name": name,
|
||||
"guest_vmid": vmid,
|
||||
"action": action,
|
||||
"command": command,
|
||||
"how_to_approve": "This action requires approval. Ask the user to confirm they want to proceed.",
|
||||
"do_not_retry": true,
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
return "APPROVAL_REQUIRED: " + string(b)
|
||||
}
|
||||
|
||||
// formatDockerApprovalNeeded formats a response for Docker control needing approval
|
||||
func formatDockerApprovalNeeded(name, host, action, command string) string {
|
||||
payload := map[string]interface{}{
|
||||
"type": "control_approval_required",
|
||||
"container_name": name,
|
||||
"docker_host": host,
|
||||
"action": action,
|
||||
"command": command,
|
||||
"how_to_approve": "This action requires approval. Ask the user to confirm they want to proceed.",
|
||||
"do_not_retry": true,
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
return "APPROVAL_REQUIRED: " + string(b)
|
||||
}
|
||||
|
||||
// formatControlSuggestion formats a command suggestion for "suggest" mode
|
||||
func formatControlSuggestion(name string, vmid int, action, command, node string) string {
|
||||
return fmt.Sprintf(`To %s %s (VMID %d), run this command on node %s:
|
||||
|
||||
%s
|
||||
|
||||
Copy and paste this command to execute it manually.`, action, name, vmid, node, command)
|
||||
}
|
||||
|
||||
// formatDockerSuggestion formats a Docker command suggestion for "suggest" mode
|
||||
func formatDockerSuggestion(name, host, action, command string) string {
|
||||
return fmt.Sprintf(`To %s container '%s' on host %s, run:
|
||||
|
||||
%s
|
||||
|
||||
Copy and paste this command to execute it manually.`, action, name, host, command)
|
||||
}
|
||||
|
||||
@@ -266,10 +266,36 @@ func (c *Client) Prompt(ctx context.Context, req PromptRequest) (*PromptResponse
|
||||
return nil, fmt.Errorf("prompt failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
// Parse the OpenCode response format which has info and parts fields
|
||||
var rawResponse struct {
|
||||
Info struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"sessionID"`
|
||||
Role string `json:"role"`
|
||||
} `json:"info"`
|
||||
Parts []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rawResponse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Extract text content from parts
|
||||
var contentParts []string
|
||||
for _, part := range rawResponse.Parts {
|
||||
if part.Type == "text" && part.Text != "" {
|
||||
contentParts = append(contentParts, part.Text)
|
||||
}
|
||||
}
|
||||
|
||||
var result PromptResponse
|
||||
result.SessionID = sessionID
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result.Message); err != nil {
|
||||
return nil, err
|
||||
result.Message = Message{
|
||||
ID: rawResponse.Info.ID,
|
||||
Role: rawResponse.Info.Role,
|
||||
Content: strings.Join(contentParts, ""),
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
|
||||
@@ -86,6 +86,13 @@ func NewService(cfg Config) *Service {
|
||||
|
||||
executor := mcp.NewPulseToolExecutor(stateProvider, policy, agentServer)
|
||||
|
||||
// Set control level from config
|
||||
if cfg.AIConfig != nil {
|
||||
controlLevel := cfg.AIConfig.GetControlLevel()
|
||||
executor.SetControlLevel(mcp.ControlLevel(controlLevel))
|
||||
executor.SetProtectedGuests(cfg.AIConfig.GetProtectedGuests())
|
||||
}
|
||||
|
||||
return &Service{
|
||||
cfg: cfg.AIConfig,
|
||||
executor: executor,
|
||||
@@ -258,6 +265,13 @@ func (s *Service) Restart(ctx context.Context, newCfg *config.AIConfig) error {
|
||||
s.sidecar.UpdateModel(model)
|
||||
log.Info().Str("model", model).Msg("Updating OpenCode model")
|
||||
}
|
||||
|
||||
// Update control settings on the executor (no restart needed)
|
||||
if s.executor != nil {
|
||||
s.executor.SetControlLevel(mcp.ControlLevel(cfg.GetControlLevel()))
|
||||
s.executor.SetProtectedGuests(cfg.GetProtectedGuests())
|
||||
log.Info().Str("control_level", cfg.GetControlLevel()).Msg("Updated MCP control settings")
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Msg("Restarting OpenCode sidecar with new configuration")
|
||||
@@ -524,3 +538,42 @@ func (s *Service) SetDiskHealthProvider(provider mcp.DiskHealthProvider) {
|
||||
s.executor.SetDiskHealthProvider(provider)
|
||||
}
|
||||
}
|
||||
|
||||
// SetAgentProfileManager sets the profile manager for agent scope updates.
|
||||
func (s *Service) SetAgentProfileManager(manager mcp.AgentProfileManager) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.executor != nil {
|
||||
s.executor.SetAgentProfileManager(manager)
|
||||
}
|
||||
}
|
||||
|
||||
// SetControlLevel sets the AI control level (read_only, suggest, controlled, autonomous)
|
||||
func (s *Service) SetControlLevel(level string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.executor != nil {
|
||||
s.executor.SetControlLevel(mcp.ControlLevel(level))
|
||||
}
|
||||
}
|
||||
|
||||
// SetProtectedGuests sets the list of VMIDs/names that AI cannot control
|
||||
func (s *Service) SetProtectedGuests(guests []string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.executor != nil {
|
||||
s.executor.SetProtectedGuests(guests)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateControlSettings updates both control level and protected guests from config
|
||||
func (s *Service) UpdateControlSettings(cfg *config.AIConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
s.SetControlLevel(cfg.GetControlLevel())
|
||||
s.SetProtectedGuests(cfg.GetProtectedGuests())
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -95,42 +96,9 @@ func (s *Sidecar) Start(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Create OpenCode config with MCP server connection and model
|
||||
if s.dataDir != "" {
|
||||
configPath := s.dataDir + "/opencode.json"
|
||||
|
||||
// Build config with optional model
|
||||
modelLine := ""
|
||||
if s.model != "" {
|
||||
modelLine = fmt.Sprintf(` "model": "%s",
|
||||
`, s.model)
|
||||
}
|
||||
|
||||
mcpConfig := ""
|
||||
if s.mcpURL != "" {
|
||||
mcpConfig = fmt.Sprintf(` "mcp": {
|
||||
"pulse": {
|
||||
"type": "remote",
|
||||
"url": "%s",
|
||||
"enabled": true
|
||||
}
|
||||
}`, s.mcpURL)
|
||||
}
|
||||
|
||||
// Note: API keys are passed via environment variables (not in config file)
|
||||
// OpenCode reads them from ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.
|
||||
providerConfig := ""
|
||||
|
||||
config := fmt.Sprintf(`{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
%s%s%s
|
||||
}`, modelLine, providerConfig, mcpConfig)
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(config), 0644); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to write OpenCode config")
|
||||
} else {
|
||||
log.Info().Str("config", configPath).Str("model", s.model).Str("mcpURL", s.mcpURL).Msg("Created OpenCode config")
|
||||
}
|
||||
// Write OpenCode config with MCP server connection, model, and system instructions
|
||||
if err := s.writeConfig(); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to write OpenCode config")
|
||||
}
|
||||
|
||||
// Create a cancellable context for the process
|
||||
@@ -138,11 +106,14 @@ func (s *Sidecar) Start(ctx context.Context) error {
|
||||
s.cancelCtx = cancel
|
||||
|
||||
// Build command - using npx to run opencode
|
||||
// Using globally installed binary to avoid npx cache issues
|
||||
// Note: Temporarily using 0.0.0.0 to test direct access
|
||||
s.cmd = exec.CommandContext(processCtx,
|
||||
"npx", "-y", "opencode-ai@latest",
|
||||
"opencode",
|
||||
"serve",
|
||||
"--port", fmt.Sprintf("%d", s.port),
|
||||
"--hostname", "127.0.0.1",
|
||||
"--hostname", "0.0.0.0",
|
||||
"--print-logs", // Enable logging output for debugging
|
||||
)
|
||||
|
||||
// Set working directory
|
||||
@@ -154,20 +125,32 @@ func (s *Sidecar) Start(ctx context.Context) error {
|
||||
env := append(os.Environ(),
|
||||
fmt.Sprintf("OPENCODE_PORT=%d", s.port),
|
||||
)
|
||||
|
||||
// Pass model via environment variable (more reliable than config file)
|
||||
if s.model != "" {
|
||||
env = append(env, fmt.Sprintf("OPENCODE_MODEL=%s", s.model))
|
||||
}
|
||||
|
||||
var configuredKeys []string
|
||||
if s.anthropicAPIKey != "" {
|
||||
env = append(env, fmt.Sprintf("ANTHROPIC_API_KEY=%s", s.anthropicAPIKey))
|
||||
configuredKeys = append(configuredKeys, "anthropic")
|
||||
}
|
||||
if s.openAIAPIKey != "" {
|
||||
env = append(env, fmt.Sprintf("OPENAI_API_KEY=%s", s.openAIAPIKey))
|
||||
configuredKeys = append(configuredKeys, "openai")
|
||||
}
|
||||
if s.deepSeekAPIKey != "" {
|
||||
env = append(env, fmt.Sprintf("DEEPSEEK_API_KEY=%s", s.deepSeekAPIKey))
|
||||
configuredKeys = append(configuredKeys, "deepseek")
|
||||
}
|
||||
if s.geminiAPIKey != "" {
|
||||
env = append(env, fmt.Sprintf("GEMINI_API_KEY=%s", s.geminiAPIKey))
|
||||
// OpenCode also accepts GOOGLE_GENERATIVE_AI_API_KEY - set both to ensure compatibility
|
||||
env = append(env, fmt.Sprintf("GOOGLE_GENERATIVE_AI_API_KEY=%s", s.geminiAPIKey))
|
||||
configuredKeys = append(configuredKeys, "gemini")
|
||||
}
|
||||
log.Info().Strs("api_keys", configuredKeys).Str("model", s.model).Int("port", s.port).Msg("Starting OpenCode sidecar")
|
||||
s.cmd.Env = env
|
||||
|
||||
// Capture output for debugging
|
||||
@@ -181,6 +164,17 @@ func (s *Sidecar) Start(ctx context.Context) error {
|
||||
}
|
||||
|
||||
s.started = true
|
||||
log.Info().Int("pid", s.cmd.Process.Pid).Int("port", s.port).Msg("OpenCode process started")
|
||||
|
||||
// Monitor process exit in background
|
||||
go func() {
|
||||
err := s.cmd.Wait()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Int("pid", s.cmd.Process.Pid).Msg("OpenCode process exited with error")
|
||||
} else {
|
||||
log.Info().Int("pid", s.cmd.Process.Pid).Msg("OpenCode process exited normally")
|
||||
}
|
||||
}()
|
||||
|
||||
// Release lock before waitForReady (which also needs the lock)
|
||||
s.mu.Unlock()
|
||||
@@ -189,7 +183,7 @@ func (s *Sidecar) Start(ctx context.Context) error {
|
||||
go s.healthLoop(ctx)
|
||||
|
||||
// Wait for server to be ready
|
||||
if err := s.waitForReady(ctx, 30*time.Second); err != nil {
|
||||
if err := s.waitForReady(ctx, 120*time.Second); err != nil {
|
||||
log.Error().Err(err).Msg("waitForReady failed")
|
||||
s.Stop()
|
||||
return fmt.Errorf("opencode failed to become ready: %w", err)
|
||||
@@ -257,27 +251,47 @@ func (s *Sidecar) writeConfig() error {
|
||||
configPath := s.dataDir + "/opencode.json"
|
||||
|
||||
// Build config with optional model
|
||||
modelLine := ""
|
||||
var configParts []string
|
||||
configParts = append(configParts, ` "$schema": "https://opencode.ai/config.json"`)
|
||||
|
||||
// Inject System Instructions
|
||||
instructions := ` "instructions": [
|
||||
"You are Pulse's AI assistant for infrastructure monitoring and management.",
|
||||
"You have access to pulse_* MCP tools. ALWAYS use them for infrastructure questions:",
|
||||
"- pulse_get_infrastructure_state: Get all VMs, containers, hosts",
|
||||
"- pulse_get_active_alerts: Get current alerts and warnings",
|
||||
"- pulse_get_metrics_history: Get CPU/memory/disk history for resources",
|
||||
"- pulse_get_resource_details: Get details for a specific VM/container",
|
||||
"- pulse_get_baselines: Get learned normal behavior",
|
||||
"- pulse_get_patterns: Get detected patterns and predictions",
|
||||
"- pulse_get_disk_health: Get SMART data and disk status",
|
||||
"- pulse_get_storage: Get storage pool information",
|
||||
"- pulse_get_agent_scope: Inspect agent scope and profile settings",
|
||||
"- pulse_set_agent_scope: Safely update unified agent scope via profiles",
|
||||
"- pulse_run_command: Execute commands on managed hosts (only when control level allows)",
|
||||
"When asked about infrastructure, VMs, containers, alerts, metrics, or system status, ALWAYS use pulse_* tools.",
|
||||
"Use pulse_set_agent_scope for agent module changes instead of running shell commands.",
|
||||
"Do NOT use webfetch for infrastructure questions - use the MCP tools.",
|
||||
"Be concise and direct. Focus on actionable insights."
|
||||
]`
|
||||
configParts = append(configParts, instructions)
|
||||
|
||||
if s.model != "" {
|
||||
modelLine = fmt.Sprintf(` "model": "%s",
|
||||
`, s.model)
|
||||
configParts = append(configParts, fmt.Sprintf(` "model": "%s"`, s.model))
|
||||
}
|
||||
|
||||
mcpConfig := ""
|
||||
if s.mcpURL != "" {
|
||||
mcpConfig = fmt.Sprintf(` "mcp": {
|
||||
mcpConfig := fmt.Sprintf(` "mcp": {
|
||||
"pulse": {
|
||||
"type": "remote",
|
||||
"url": "%s",
|
||||
"enabled": true
|
||||
}
|
||||
}`, s.mcpURL)
|
||||
configParts = append(configParts, mcpConfig)
|
||||
}
|
||||
|
||||
config := fmt.Sprintf(`{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
%s%s
|
||||
}`, modelLine, mcpConfig)
|
||||
config := fmt.Sprintf("{\n%s\n}", strings.Join(configParts, ",\n"))
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(config), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write OpenCode config: %w", err)
|
||||
@@ -350,7 +364,8 @@ func (s *Sidecar) waitForReady(ctx context.Context, timeout time.Duration) error
|
||||
// checkHealth performs a health check against the OpenCode server
|
||||
func (s *Sidecar) checkHealth() bool {
|
||||
client := newHTTPClient(5 * time.Second)
|
||||
resp, err := client.Get(s.baseURL + "/global/health")
|
||||
// Use /config endpoint for health check - it returns JSON and indicates the server is ready
|
||||
resp, err := client.Get(s.baseURL + "/config")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -411,7 +426,8 @@ func (w *logWriter) Write(p []byte) (n int, err error) {
|
||||
case "error":
|
||||
log.Error().Str("source", w.prefix).Msg(msg)
|
||||
default:
|
||||
log.Debug().Str("source", w.prefix).Msg(msg)
|
||||
// Use Info level to ensure OpenCode output is visible in logs
|
||||
log.Info().Str("source", w.prefix).Msg(msg)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
+9
-153
@@ -3,12 +3,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -43,12 +38,18 @@ type AIStateProvider interface {
|
||||
|
||||
// Start initializes and starts the OpenCode service
|
||||
func (h *AIHandler) Start(ctx context.Context, stateProvider AIStateProvider) error {
|
||||
log.Info().Msg("AIHandler.Start called")
|
||||
aiCfg := h.loadAIConfig()
|
||||
if aiCfg == nil || !aiCfg.Enabled {
|
||||
log.Info().Msg("AI is disabled")
|
||||
if aiCfg == nil {
|
||||
log.Info().Msg("AI config is nil, AI is disabled")
|
||||
return nil
|
||||
}
|
||||
if !aiCfg.Enabled {
|
||||
log.Info().Bool("enabled", aiCfg.Enabled).Msg("AI is disabled in config")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info().Bool("enabled", aiCfg.Enabled).Str("model", aiCfg.Model).Msg("Starting OpenCode service")
|
||||
h.service = opencode.NewService(opencode.Config{
|
||||
AIConfig: aiCfg,
|
||||
StateProvider: stateProvider,
|
||||
@@ -56,6 +57,7 @@ func (h *AIHandler) Start(ctx context.Context, stateProvider AIStateProvider) er
|
||||
})
|
||||
|
||||
if err := h.service.Start(ctx); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to start OpenCode service")
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -387,149 +389,3 @@ func (h *AIHandler) SetMetricsHistory(provider opencode.MCPMetricsHistoryProvide
|
||||
h.service.SetMetricsHistory(provider)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOpenCodeUI proxies requests to OpenCode's built-in web UI
|
||||
// This allows Pulse to embed OpenCode's UI while maintaining auth
|
||||
func (h *AIHandler) HandleOpenCodeUI(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.IsRunning() {
|
||||
http.Error(w, "AI is not running", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := h.service.GetBaseURL()
|
||||
if baseURL == "" {
|
||||
http.Error(w, "OpenCode URL not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
target, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid OpenCode URL", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Create reverse proxy
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
|
||||
// Customize the director to rewrite the path
|
||||
originalDirector := proxy.Director
|
||||
proxy.Director = func(req *http.Request) {
|
||||
originalDirector(req)
|
||||
// Strip the /opencode prefix from the path
|
||||
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/opencode")
|
||||
if req.URL.Path == "" {
|
||||
req.URL.Path = "/"
|
||||
}
|
||||
req.Host = target.Host
|
||||
}
|
||||
|
||||
// Modify response to allow embedding in iframe and fix asset paths
|
||||
// OpenCode sets X-Frame-Options: DENY and CSP frame-ancestors 'none'
|
||||
// which prevents embedding - we need to remove these for the Pulse panel
|
||||
// Also, OpenCode uses absolute paths for assets which need to be prefixed
|
||||
proxy.ModifyResponse = func(resp *http.Response) error {
|
||||
// Remove X-Frame-Options to allow iframe embedding
|
||||
resp.Header.Del("X-Frame-Options")
|
||||
|
||||
// Handle multiple CSP headers - get all values, modify, and set back
|
||||
cspHeaders := resp.Header.Values("Content-Security-Policy")
|
||||
if len(cspHeaders) > 0 {
|
||||
// Delete all existing CSP headers
|
||||
resp.Header.Del("Content-Security-Policy")
|
||||
// Add back modified versions
|
||||
for _, csp := range cspHeaders {
|
||||
// Replace frame-ancestors 'none' with 'self' to allow embedding
|
||||
modified := strings.ReplaceAll(csp, "frame-ancestors 'none'", "frame-ancestors 'self'")
|
||||
resp.Header.Add("Content-Security-Policy", modified)
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite asset paths in HTML and CSS responses
|
||||
// OpenCode uses absolute paths like /assets/... which need to be /opencode/assets/...
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if resp.Body != nil && (strings.Contains(contentType, "text/html") || strings.Contains(contentType, "text/css")) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content := string(body)
|
||||
|
||||
if strings.Contains(contentType, "text/html") {
|
||||
// Rewrite src="/..." and href="/..." to src="/opencode/..." and href="/opencode/..."
|
||||
// Be careful not to rewrite already-prefixed paths or external URLs
|
||||
content = strings.ReplaceAll(content, `src="/`, `src="/opencode/`)
|
||||
content = strings.ReplaceAll(content, `href="/`, `href="/opencode/`)
|
||||
}
|
||||
|
||||
if strings.Contains(contentType, "text/css") {
|
||||
// Rewrite url(/...) and url("/...") and url('/...') in CSS for fonts and other assets
|
||||
content = strings.ReplaceAll(content, `url(/`, `url(/opencode/`)
|
||||
content = strings.ReplaceAll(content, `url("/`, `url("/opencode/`)
|
||||
content = strings.ReplaceAll(content, `url('/`, `url('/opencode/`)
|
||||
}
|
||||
|
||||
// Update response body
|
||||
resp.Body = io.NopCloser(strings.NewReader(content))
|
||||
resp.ContentLength = int64(len(content))
|
||||
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle WebSocket upgrades
|
||||
if r.Header.Get("Upgrade") == "websocket" {
|
||||
proxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the proxied request
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// HandleOpenCodeAPI proxies OpenCode's API requests
|
||||
// When OpenCode is embedded in an iframe, its frontend makes requests to window.location.origin
|
||||
// which is Pulse. This handler proxies those requests to OpenCode's actual backend.
|
||||
func (h *AIHandler) HandleOpenCodeAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.IsRunning() {
|
||||
http.Error(w, "AI is not running", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := h.service.GetBaseURL()
|
||||
if baseURL == "" {
|
||||
http.Error(w, "OpenCode URL not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
target, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid OpenCode URL", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Create reverse proxy - no path modification needed
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
|
||||
originalDirector := proxy.Director
|
||||
proxy.Director = func(req *http.Request) {
|
||||
originalDirector(req)
|
||||
// Keep the path as-is (no stripping)
|
||||
req.Host = target.Host
|
||||
// OpenCode uses Accept header to distinguish API vs SPA requests
|
||||
// Set Accept: application/json for API requests so we get JSON not HTML
|
||||
if req.Header.Get("Accept") == "" || req.Header.Get("Accept") == "*/*" {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
// Handle WebSocket upgrades (for /pty/ and other real-time endpoints)
|
||||
if r.Header.Get("Upgrade") == "websocket" {
|
||||
proxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
@@ -31,11 +31,12 @@ import (
|
||||
|
||||
// AISettingsHandler handles AI settings endpoints
|
||||
type AISettingsHandler struct {
|
||||
config *config.Config
|
||||
persistence *config.ConfigPersistence
|
||||
aiService *ai.Service
|
||||
agentServer *agentexec.Server
|
||||
onModelChange func() // Called when model or other OpenCode-affecting settings change
|
||||
config *config.Config
|
||||
persistence *config.ConfigPersistence
|
||||
aiService *ai.Service
|
||||
agentServer *agentexec.Server
|
||||
onModelChange func() // Called when model or other OpenCode-affecting settings change
|
||||
onControlSettingsChange func() // Called when control level or protected guests change
|
||||
}
|
||||
|
||||
// NewAISettingsHandler creates a new AI settings handler
|
||||
@@ -168,6 +169,12 @@ func (h *AISettingsHandler) SetOnModelChange(callback func()) {
|
||||
h.onModelChange = callback
|
||||
}
|
||||
|
||||
// SetOnControlSettingsChange sets a callback to be invoked when control settings change
|
||||
// Used by Router to update MCP tool visibility without restarting OpenCode
|
||||
func (h *AISettingsHandler) SetOnControlSettingsChange(callback func()) {
|
||||
h.onControlSettingsChange = callback
|
||||
}
|
||||
|
||||
// AISettingsResponse is returned by GET /api/settings/ai
|
||||
// API keys are masked for security
|
||||
type AISettingsResponse struct {
|
||||
@@ -205,6 +212,9 @@ type AISettingsResponse struct {
|
||||
CostBudgetUSD30d float64 `json:"cost_budget_usd_30d,omitempty"`
|
||||
// Request timeout (seconds) - for slow hardware running local models
|
||||
RequestTimeoutSeconds int `json:"request_timeout_seconds,omitempty"`
|
||||
// Infrastructure control settings
|
||||
ControlLevel string `json:"control_level"` // "read_only", "suggest", "controlled", "autonomous"
|
||||
ProtectedGuests []string `json:"protected_guests,omitempty"` // VMIDs/names that AI cannot control
|
||||
}
|
||||
|
||||
// AISettingsUpdateRequest is the request body for PUT /api/settings/ai
|
||||
@@ -243,6 +253,9 @@ type AISettingsUpdateRequest struct {
|
||||
CostBudgetUSD30d *float64 `json:"cost_budget_usd_30d,omitempty"`
|
||||
// Request timeout (seconds) - for slow hardware running local models
|
||||
RequestTimeoutSeconds *int `json:"request_timeout_seconds,omitempty"`
|
||||
// Infrastructure control settings
|
||||
ControlLevel *string `json:"control_level,omitempty"` // "read_only", "suggest", "controlled", "autonomous"
|
||||
ProtectedGuests []string `json:"protected_guests,omitempty"` // VMIDs/names that AI cannot control (nil = don't update, empty = clear)
|
||||
}
|
||||
|
||||
// HandleGetAISettings returns the current AI settings (GET /api/settings/ai)
|
||||
@@ -304,6 +317,8 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R
|
||||
ConfiguredProviders: settings.GetConfiguredProviders(),
|
||||
CostBudgetUSD30d: settings.CostBudgetUSD30d,
|
||||
RequestTimeoutSeconds: settings.RequestTimeoutSeconds,
|
||||
ControlLevel: settings.GetControlLevel(),
|
||||
ProtectedGuests: settings.GetProtectedGuests(),
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, response); err != nil {
|
||||
@@ -589,6 +604,34 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
|
||||
settings.RequestTimeoutSeconds = *req.RequestTimeoutSeconds
|
||||
}
|
||||
|
||||
// Handle infrastructure control settings
|
||||
if req.ControlLevel != nil {
|
||||
if !config.IsValidControlLevel(*req.ControlLevel) {
|
||||
http.Error(w, "invalid control_level: must be read_only, suggest, controlled, or autonomous", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// "autonomous" requires Pro license (same as autonomous_mode)
|
||||
if *req.ControlLevel == config.ControlLevelAutonomous {
|
||||
if !h.aiService.HasLicenseFeature(ai.FeatureAIAutoFix) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusPaymentRequired)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": "license_required",
|
||||
"message": "Autonomous control requires Pulse Pro",
|
||||
"feature": ai.FeatureAIAutoFix,
|
||||
"upgrade_url": "https://pulserelay.pro/",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
settings.ControlLevel = *req.ControlLevel
|
||||
}
|
||||
|
||||
// Handle protected guests (nil = don't update)
|
||||
if req.ProtectedGuests != nil {
|
||||
settings.ProtectedGuests = req.ProtectedGuests
|
||||
}
|
||||
|
||||
// Save settings
|
||||
if err := h.persistence.SaveAIConfig(*settings); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save AI settings")
|
||||
@@ -615,6 +658,12 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
|
||||
h.onModelChange()
|
||||
}
|
||||
|
||||
// Update MCP control settings if control level or protected guests changed
|
||||
// This updates tool visibility without restarting OpenCode
|
||||
if h.onControlSettingsChange != nil && (req.ControlLevel != nil || req.ProtectedGuests != nil) {
|
||||
h.onControlSettingsChange()
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Bool("enabled", settings.Enabled).
|
||||
Str("provider", settings.Provider).
|
||||
@@ -662,6 +711,8 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
|
||||
OpenAIBaseURL: settings.OpenAIBaseURL,
|
||||
ConfiguredProviders: settings.GetConfiguredProviders(),
|
||||
RequestTimeoutSeconds: settings.RequestTimeoutSeconds,
|
||||
ControlLevel: settings.GetControlLevel(),
|
||||
ProtectedGuests: settings.GetProtectedGuests(),
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, response); err != nil {
|
||||
|
||||
@@ -16,9 +16,10 @@ import (
|
||||
|
||||
// ConfigProfileHandler handles configuration profile operations
|
||||
type ConfigProfileHandler struct {
|
||||
persistence *config.ConfigPersistence
|
||||
validator *models.ProfileValidator
|
||||
mu sync.RWMutex
|
||||
persistence *config.ConfigPersistence
|
||||
validator *models.ProfileValidator
|
||||
mu sync.RWMutex
|
||||
suggestionHandler *ProfileSuggestionHandler
|
||||
}
|
||||
|
||||
// NewConfigProfileHandler creates a new handler
|
||||
@@ -29,6 +30,11 @@ func NewConfigProfileHandler(persistence *config.ConfigPersistence) *ConfigProfi
|
||||
}
|
||||
}
|
||||
|
||||
// SetAIHandler sets the AI handler for profile suggestions
|
||||
func (h *ConfigProfileHandler) SetAIHandler(aiHandler *AIHandler) {
|
||||
h.suggestionHandler = NewProfileSuggestionHandler(h.persistence, aiHandler)
|
||||
}
|
||||
|
||||
// ServeHTTP implements the http.Handler interface
|
||||
func (h *ConfigProfileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Simple routing
|
||||
@@ -68,6 +74,16 @@ func (h *ConfigProfileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
h.ValidateConfig(w, r)
|
||||
return
|
||||
}
|
||||
} else if path == "/suggestions" {
|
||||
// POST /suggestions - AI-assisted profile suggestion
|
||||
if r.Method == http.MethodPost {
|
||||
if h.suggestionHandler != nil {
|
||||
h.suggestionHandler.HandleSuggestProfile(w, r)
|
||||
} else {
|
||||
http.Error(w, "AI service not configured", http.StatusServiceUnavailable)
|
||||
}
|
||||
return
|
||||
}
|
||||
} else if path == "/changelog" {
|
||||
// GET /changelog - Return profile change history
|
||||
if r.Method == http.MethodGet {
|
||||
|
||||
@@ -43,6 +43,7 @@ type DiagnosticsInfo struct {
|
||||
APITokens *APITokenDiagnostic `json:"apiTokens,omitempty"`
|
||||
DockerAgents *DockerAgentDiagnostic `json:"dockerAgents,omitempty"`
|
||||
Alerts *AlertsDiagnostic `json:"alerts,omitempty"`
|
||||
OpenCode *OpenCodeDiagnostic `json:"openCode,omitempty"`
|
||||
Errors []string `json:"errors"`
|
||||
// NodeSnapshots captures the raw memory payload and derived usage Pulse last observed per node.
|
||||
NodeSnapshots []monitoring.NodeMemorySnapshot `json:"nodeSnapshots,omitempty"`
|
||||
@@ -355,6 +356,19 @@ type AlertsDiagnostic struct {
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// OpenCodeDiagnostic reports on the OpenCode AI sidecar status.
|
||||
type OpenCodeDiagnostic struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Running bool `json:"running"`
|
||||
Healthy bool `json:"healthy"`
|
||||
Port int `json:"port,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
MCPConnected bool `json:"mcpConnected"`
|
||||
MCPToolCount int `json:"mcpToolCount,omitempty"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// handleDiagnostics returns comprehensive diagnostic information
|
||||
func (r *Router) handleDiagnostics(w http.ResponseWriter, req *http.Request) {
|
||||
diagnosticsMetricsOnce.Do(func() {
|
||||
@@ -555,6 +569,7 @@ func (r *Router) computeDiagnostics(ctx context.Context) DiagnosticsInfo {
|
||||
|
||||
diag.DockerAgents = buildDockerAgentDiagnostic(r.monitor, diag.Version)
|
||||
diag.Alerts = buildAlertsDiagnostic(r.monitor)
|
||||
diag.OpenCode = buildOpenCodeDiagnostic(r.config, r.aiHandler)
|
||||
|
||||
diag.Discovery = buildDiscoveryDiagnostic(r.config, r.monitor)
|
||||
|
||||
@@ -1860,3 +1875,60 @@ func interfaceToStringSlice(value interface{}) []string {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildOpenCodeDiagnostic(cfg *config.Config, aiHandler *AIHandler) *OpenCodeDiagnostic {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
diag := &OpenCodeDiagnostic{
|
||||
Enabled: false,
|
||||
Notes: []string{},
|
||||
}
|
||||
|
||||
// Calculate enabled state based on AI config
|
||||
// NOTE: aiHandler might be nil during early startup
|
||||
if aiHandler != nil {
|
||||
aiCfg := aiHandler.GetAIConfig()
|
||||
if aiCfg != nil {
|
||||
diag.Enabled = aiCfg.UseOpenCode
|
||||
diag.Model = aiCfg.GetChatModel()
|
||||
|
||||
// Pulse legacy config check
|
||||
if !diag.Enabled && aiCfg.Enabled {
|
||||
diag.Notes = append(diag.Notes, "AI is enabled but UseOpenCode is false - using legacy implementation")
|
||||
}
|
||||
}
|
||||
|
||||
svc := aiHandler.GetService()
|
||||
if svc != nil {
|
||||
diag.Running = svc.IsRunning()
|
||||
diag.Healthy = svc.IsRunning() // Consolidate for now
|
||||
|
||||
// Get connection details
|
||||
baseURL := svc.GetBaseURL()
|
||||
if baseURL != "" {
|
||||
diag.URL = baseURL
|
||||
// Parse port from URL
|
||||
if parts := strings.Split(baseURL, ":"); len(parts) > 2 {
|
||||
if port, err := strconv.Atoi(parts[2]); err == nil {
|
||||
diag.Port = port
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check MCP connection (if we had access to check it)
|
||||
diag.MCPConnected = diag.Running // Assume connected if running for now
|
||||
|
||||
if !diag.Running && diag.Enabled {
|
||||
diag.Notes = append(diag.Notes, "OpenCode service is enabled but not running")
|
||||
}
|
||||
} else if diag.Enabled {
|
||||
diag.Notes = append(diag.Notes, "OpenCode service is nil")
|
||||
}
|
||||
} else {
|
||||
diag.Notes = append(diag.Notes, "AI Handler not initialized")
|
||||
}
|
||||
|
||||
return diag
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/opencode"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// ProfileSuggestionHandler handles AI-assisted profile suggestions
|
||||
type ProfileSuggestionHandler struct {
|
||||
persistence *config.ConfigPersistence
|
||||
aiHandler *AIHandler
|
||||
}
|
||||
|
||||
// NewProfileSuggestionHandler creates a new suggestion handler
|
||||
func NewProfileSuggestionHandler(persistence *config.ConfigPersistence, aiHandler *AIHandler) *ProfileSuggestionHandler {
|
||||
return &ProfileSuggestionHandler{
|
||||
persistence: persistence,
|
||||
aiHandler: aiHandler,
|
||||
}
|
||||
}
|
||||
|
||||
// SuggestionRequest is the request body for profile suggestions
|
||||
type SuggestionRequest struct {
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
// ProfileSuggestion is the AI-generated profile suggestion
|
||||
type ProfileSuggestion struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
Rationale []string `json:"rationale"`
|
||||
}
|
||||
|
||||
// HandleSuggestProfile handles POST /api/admin/profiles/suggestions
|
||||
func (h *ProfileSuggestionHandler) HandleSuggestProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if AI is running
|
||||
if h.aiHandler == nil || !h.aiHandler.IsRunning() {
|
||||
http.Error(w, "AI service is not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request
|
||||
var req SuggestionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate prompt is not empty
|
||||
req.Prompt = strings.TrimSpace(req.Prompt)
|
||||
if req.Prompt == "" {
|
||||
http.Error(w, "Prompt is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Build context for the AI
|
||||
contextParts := []string{}
|
||||
|
||||
// Add existing profiles for reference
|
||||
profiles, err := h.persistence.LoadAgentProfiles()
|
||||
if err == nil && len(profiles) > 0 {
|
||||
profileNames := make([]string, len(profiles))
|
||||
for i, p := range profiles {
|
||||
profileNames[i] = p.Name
|
||||
}
|
||||
contextParts = append(contextParts, fmt.Sprintf("Existing profiles: %s", strings.Join(profileNames, ", ")))
|
||||
}
|
||||
|
||||
// Build config schema documentation from the actual definitions
|
||||
configDocs := buildConfigSchemaDoc()
|
||||
|
||||
// Build the prompt for the AI (schema docs only in system prompt, not in context)
|
||||
systemPrompt := fmt.Sprintf(`You are an infrastructure configuration assistant for Pulse, a monitoring platform.
|
||||
Your task is to suggest an agent configuration profile based on the user's request.
|
||||
|
||||
IMPORTANT: You must respond ONLY with a valid JSON object in this exact format:
|
||||
{
|
||||
"name": "Profile Name",
|
||||
"description": "Brief description of what this profile is for",
|
||||
"config": {
|
||||
"key": "value"
|
||||
},
|
||||
"rationale": ["Reason 1", "Reason 2"]
|
||||
}
|
||||
|
||||
Available configuration keys and their types:
|
||||
%s
|
||||
|
||||
Only include settings that are relevant to the user's request. Do not include settings with default values.
|
||||
`, configDocs)
|
||||
|
||||
userPrompt := req.Prompt
|
||||
if len(contextParts) > 0 {
|
||||
userPrompt = fmt.Sprintf("Context:\n%s\n\nRequest: %s", strings.Join(contextParts, "\n"), req.Prompt)
|
||||
}
|
||||
|
||||
fullPrompt := fmt.Sprintf("%s\n\nUser request: %s\n\nRespond with ONLY the JSON object, no markdown, no explanation.", systemPrompt, userPrompt)
|
||||
|
||||
// Call the AI service
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
response, err := h.aiHandler.GetService().Execute(ctx, opencode.ExecuteRequest{
|
||||
Prompt: fullPrompt,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get AI suggestion")
|
||||
http.Error(w, "Failed to generate suggestion", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
fullResponse := response.Message.Content
|
||||
if fullResponse == "" {
|
||||
log.Error().Msg("AI returned empty response")
|
||||
http.Error(w, "AI returned empty response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
suggestion, err := parseAISuggestion(fullResponse)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("response", fullResponse).Msg("Failed to parse AI suggestion")
|
||||
// Return a friendly error with partial info if available
|
||||
http.Error(w, fmt.Sprintf("Failed to parse AI response: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the suggested config
|
||||
validator := models.NewProfileValidator()
|
||||
if suggestion.Config != nil {
|
||||
configMap := models.AgentConfigMap{}
|
||||
for k, v := range suggestion.Config {
|
||||
configMap[k] = v
|
||||
}
|
||||
result := validator.Validate(configMap)
|
||||
if !result.Valid {
|
||||
// Include warnings in response but don't fail
|
||||
log.Warn().Interface("errors", result.Errors).Msg("Suggestion has validation warnings")
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(suggestion)
|
||||
}
|
||||
|
||||
// parseAISuggestion extracts the ProfileSuggestion from the AI response
|
||||
func parseAISuggestion(text string) (*ProfileSuggestion, error) {
|
||||
// Try to find JSON in the response
|
||||
text = strings.TrimSpace(text)
|
||||
|
||||
// Remove ALL markdown code block markers
|
||||
text = strings.ReplaceAll(text, "```json", "")
|
||||
text = strings.ReplaceAll(text, "```", "")
|
||||
text = strings.TrimSpace(text)
|
||||
|
||||
// Find JSON object boundaries - use brace counting to find the complete JSON
|
||||
start := strings.Index(text, "{")
|
||||
if start == -1 {
|
||||
return nil, fmt.Errorf("no JSON object found in response")
|
||||
}
|
||||
|
||||
// Count braces to find the matching closing brace
|
||||
braceCount := 0
|
||||
end := -1
|
||||
inString := false
|
||||
escape := false
|
||||
for i := start; i < len(text); i++ {
|
||||
c := text[i]
|
||||
if escape {
|
||||
escape = false
|
||||
continue
|
||||
}
|
||||
if c == '\\' {
|
||||
escape = true
|
||||
continue
|
||||
}
|
||||
if c == '"' {
|
||||
inString = !inString
|
||||
continue
|
||||
}
|
||||
if inString {
|
||||
continue
|
||||
}
|
||||
if c == '{' {
|
||||
braceCount++
|
||||
} else if c == '}' {
|
||||
braceCount--
|
||||
if braceCount == 0 {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if end == -1 {
|
||||
return nil, fmt.Errorf("no complete JSON object found in response")
|
||||
}
|
||||
|
||||
jsonStr := text[start : end+1]
|
||||
|
||||
var suggestion ProfileSuggestion
|
||||
if err := json.Unmarshal([]byte(jsonStr), &suggestion); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if suggestion.Name == "" {
|
||||
suggestion.Name = "Suggested Profile"
|
||||
}
|
||||
if suggestion.Description == "" {
|
||||
suggestion.Description = "AI-generated configuration profile"
|
||||
}
|
||||
if suggestion.Config == nil {
|
||||
suggestion.Config = make(map[string]interface{})
|
||||
}
|
||||
if suggestion.Rationale == nil {
|
||||
suggestion.Rationale = []string{}
|
||||
}
|
||||
|
||||
return &suggestion, nil
|
||||
}
|
||||
|
||||
// buildConfigSchemaDoc generates documentation for all config keys from the schema
|
||||
func buildConfigSchemaDoc() string {
|
||||
defs := models.GetConfigKeyDefinitions()
|
||||
var lines []string
|
||||
|
||||
for _, def := range defs {
|
||||
var typeStr string
|
||||
switch def.Type {
|
||||
case models.ConfigTypeBool:
|
||||
typeStr = "boolean"
|
||||
case models.ConfigTypeString:
|
||||
typeStr = "string"
|
||||
case models.ConfigTypeInt:
|
||||
typeStr = "integer"
|
||||
if def.Min != nil || def.Max != nil {
|
||||
constraints := []string{}
|
||||
if def.Min != nil {
|
||||
constraints = append(constraints, fmt.Sprintf("min: %.0f", *def.Min))
|
||||
}
|
||||
if def.Max != nil {
|
||||
constraints = append(constraints, fmt.Sprintf("max: %.0f", *def.Max))
|
||||
}
|
||||
typeStr += " (" + strings.Join(constraints, ", ") + ")"
|
||||
}
|
||||
case models.ConfigTypeFloat:
|
||||
typeStr = "number"
|
||||
if def.Min != nil || def.Max != nil {
|
||||
constraints := []string{}
|
||||
if def.Min != nil {
|
||||
constraints = append(constraints, fmt.Sprintf("min: %.1f", *def.Min))
|
||||
}
|
||||
if def.Max != nil {
|
||||
constraints = append(constraints, fmt.Sprintf("max: %.1f", *def.Max))
|
||||
}
|
||||
typeStr += " (" + strings.Join(constraints, ", ") + ")"
|
||||
}
|
||||
case models.ConfigTypeDuration:
|
||||
typeStr = "duration string (e.g., \"30s\", \"1m\", \"5m\")"
|
||||
case models.ConfigTypeEnum:
|
||||
typeStr = fmt.Sprintf("enum: %s", strings.Join(def.Enum, ", "))
|
||||
default:
|
||||
typeStr = string(def.Type)
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("- %s (%s): %s", def.Key, typeStr, def.Description)
|
||||
if def.Default != nil && def.Default != "" {
|
||||
line += fmt.Sprintf(" [default: %v]", def.Default)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
+20
-53
@@ -1258,6 +1258,20 @@ func (r *Router) setupRoutes() {
|
||||
r.aiSettingsHandler.SetOnModelChange(func() {
|
||||
r.RestartOpenCodeAI(context.Background())
|
||||
})
|
||||
// Wire control settings change callback to update MCP tool visibility
|
||||
r.aiSettingsHandler.SetOnControlSettingsChange(func() {
|
||||
if r.aiHandler != nil {
|
||||
if svc := r.aiHandler.GetService(); svc != nil {
|
||||
cfg := r.aiHandler.GetAIConfig()
|
||||
if cfg != nil {
|
||||
svc.UpdateControlSettings(cfg)
|
||||
log.Info().Str("control_level", cfg.GetControlLevel()).Msg("Updated AI control settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// Wire AI handler to profile handler for AI-assisted suggestions
|
||||
r.configProfileHandler.SetAIHandler(r.aiHandler)
|
||||
// Wire license checker for alert manager Pro features (Update Alerts)
|
||||
if r.monitor != nil {
|
||||
alertMgr := r.monitor.GetAlertManager()
|
||||
@@ -1401,42 +1415,6 @@ func (r *Router) setupRoutes() {
|
||||
}))
|
||||
r.mux.HandleFunc("/api/ai/sessions/", RequireAuth(r.config, r.routeOpenCodeSessions))
|
||||
|
||||
// OpenCode Web UI proxy - serves OpenCode's built-in web interface
|
||||
// This allows users to access OpenCode directly through Pulse with auth
|
||||
r.mux.HandleFunc("/opencode/", RequireAuth(r.config, r.aiHandler.HandleOpenCodeUI))
|
||||
r.mux.HandleFunc("/opencode", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
|
||||
// Redirect /opencode to /opencode/ for proper asset loading
|
||||
http.Redirect(w, req, "/opencode/", http.StatusMovedPermanently)
|
||||
}))
|
||||
|
||||
// OpenCode API proxy - these routes are used by OpenCode's frontend
|
||||
// When embedded in iframe, OpenCode's JS makes requests to window.location.origin
|
||||
// We proxy these to OpenCode's backend so the iframe works correctly
|
||||
// NOTE: Register both /path and /path/ because Go's ServeMux treats them differently:
|
||||
// - /path/ matches any path starting with /path/
|
||||
// - /path (no trailing slash) matches exactly /path
|
||||
// Note: /global is a client-side route in OpenCode, not an API endpoint
|
||||
openCodeAPIBases := []string{
|
||||
"/session",
|
||||
"/tui",
|
||||
"/config",
|
||||
"/file",
|
||||
"/find",
|
||||
"/instance",
|
||||
"/mcp",
|
||||
"/permission",
|
||||
"/project",
|
||||
"/provider",
|
||||
"/pty",
|
||||
"/question",
|
||||
"/experimental",
|
||||
}
|
||||
for _, base := range openCodeAPIBases {
|
||||
// Register both exact match and prefix match
|
||||
r.mux.HandleFunc(base, RequireAuth(r.config, r.aiHandler.HandleOpenCodeAPI))
|
||||
r.mux.HandleFunc(base+"/", RequireAuth(r.config, r.aiHandler.HandleOpenCodeAPI))
|
||||
}
|
||||
|
||||
// Agent WebSocket for AI command execution
|
||||
r.mux.HandleFunc("/api/agent/ws", r.handleAgentWebSocket)
|
||||
|
||||
@@ -1952,6 +1930,12 @@ func (r *Router) wireOpenCodeProviders() {
|
||||
}
|
||||
}
|
||||
|
||||
if r.persistence != nil {
|
||||
manager := NewMCPAgentProfileManager(r.persistence, r.licenseHandlers.Service())
|
||||
service.SetAgentProfileManager(manager)
|
||||
log.Debug().Msg("OpenCode: Agent profile manager wired")
|
||||
}
|
||||
|
||||
log.Info().Msg("OpenCode MCP tool providers wired")
|
||||
}
|
||||
|
||||
@@ -2452,23 +2436,6 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
strings.HasPrefix(req.URL.Path, "/ws") ||
|
||||
strings.HasPrefix(req.URL.Path, "/socket.io/") ||
|
||||
strings.HasPrefix(req.URL.Path, "/download/") ||
|
||||
strings.HasPrefix(req.URL.Path, "/opencode") ||
|
||||
// OpenCode API paths - proxied to OpenCode backend for iframe embedding
|
||||
// Note: Use "/path" (not "/path/") to match both exact and prefix paths
|
||||
// Note: /global is a client-side route, not included here
|
||||
strings.HasPrefix(req.URL.Path, "/session") ||
|
||||
strings.HasPrefix(req.URL.Path, "/tui") ||
|
||||
strings.HasPrefix(req.URL.Path, "/config") ||
|
||||
strings.HasPrefix(req.URL.Path, "/file") ||
|
||||
strings.HasPrefix(req.URL.Path, "/find") ||
|
||||
strings.HasPrefix(req.URL.Path, "/instance") ||
|
||||
strings.HasPrefix(req.URL.Path, "/mcp") ||
|
||||
strings.HasPrefix(req.URL.Path, "/permission") ||
|
||||
strings.HasPrefix(req.URL.Path, "/project") ||
|
||||
strings.HasPrefix(req.URL.Path, "/provider") ||
|
||||
strings.HasPrefix(req.URL.Path, "/pty") ||
|
||||
strings.HasPrefix(req.URL.Path, "/question") ||
|
||||
strings.HasPrefix(req.URL.Path, "/experimental") ||
|
||||
req.URL.Path == "/simple-stats" ||
|
||||
req.URL.Path == "/install-docker-agent.sh" ||
|
||||
req.URL.Path == "/install-container-agent.sh" ||
|
||||
|
||||
@@ -409,14 +409,8 @@ func ResetLockout(identifier string) {
|
||||
// SecurityHeadersWithConfig applies security headers with embedding configuration
|
||||
func SecurityHeadersWithConfig(next http.Handler, allowEmbedding bool, allowedOrigins string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip frame-related headers for /opencode/ paths - these are managed by the proxy
|
||||
// The OpenCode proxy modifies headers to allow embedding within Pulse's AI panel
|
||||
isOpenCodePath := strings.HasPrefix(r.URL.Path, "/opencode")
|
||||
|
||||
// Configure clickjacking protection based on embedding settings
|
||||
if isOpenCodePath {
|
||||
// OpenCode proxy manages its own iframe headers
|
||||
} else if allowEmbedding {
|
||||
if allowEmbedding {
|
||||
// When embedding is allowed, don't set X-Frame-Options header
|
||||
// This allows embedding from any origin
|
||||
// Security note: User explicitly enabled this for iframe embedding
|
||||
@@ -442,10 +436,7 @@ func SecurityHeadersWithConfig(next http.Handler, allowEmbedding bool, allowedOr
|
||||
}
|
||||
|
||||
// Add frame-ancestors based on embedding settings
|
||||
// Skip for /opencode/ paths - the proxy manages its own CSP
|
||||
if isOpenCodePath {
|
||||
// OpenCode proxy manages its own CSP headers
|
||||
} else if allowEmbedding {
|
||||
if allowEmbedding {
|
||||
if allowedOrigins != "" {
|
||||
// Parse comma-separated origins and add them to frame-ancestors
|
||||
origins := strings.Split(allowedOrigins, ",")
|
||||
@@ -466,10 +457,7 @@ func SecurityHeadersWithConfig(next http.Handler, allowEmbedding bool, allowedOr
|
||||
cspDirectives = append(cspDirectives, "frame-ancestors 'none'")
|
||||
}
|
||||
|
||||
// Only set CSP for non-OpenCode paths (OpenCode proxy manages its own headers)
|
||||
if !isOpenCodePath {
|
||||
w.Header().Set("Content-Security-Policy", strings.Join(cspDirectives, "; "))
|
||||
}
|
||||
w.Header().Set("Content-Security-Policy", strings.Join(cspDirectives, "; "))
|
||||
|
||||
// Referrer Policy
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
|
||||
+50
-1
@@ -67,6 +67,11 @@ type AIConfig struct {
|
||||
UseOpenCode bool `json:"use_opencode,omitempty"` // Enable OpenCode backend
|
||||
OpenCodeDataDir string `json:"opencode_data_dir,omitempty"` // Data directory for OpenCode (default: /tmp/pulse-opencode)
|
||||
OpenCodePort int `json:"opencode_port,omitempty"` // Port for OpenCode server (0 = auto-assign)
|
||||
|
||||
// AI Infrastructure Control settings
|
||||
// These control whether AI can take actions on infrastructure (start/stop VMs, containers, etc.)
|
||||
ControlLevel string `json:"control_level,omitempty"` // "read_only", "suggest", "controlled", "autonomous"
|
||||
ProtectedGuests []string `json:"protected_guests,omitempty"` // VMIDs or names that AI cannot control
|
||||
}
|
||||
|
||||
// AIProvider constants
|
||||
@@ -78,9 +83,21 @@ const (
|
||||
AIProviderGemini = "gemini"
|
||||
)
|
||||
|
||||
// AI Control Level constants
|
||||
const (
|
||||
// ControlLevelReadOnly - AI can only query infrastructure, no control tools available
|
||||
ControlLevelReadOnly = "read_only"
|
||||
// ControlLevelSuggest - AI suggests commands, user must copy/paste to execute
|
||||
ControlLevelSuggest = "suggest"
|
||||
// ControlLevelControlled - AI can execute with per-command approval
|
||||
ControlLevelControlled = "controlled"
|
||||
// ControlLevelAutonomous - AI executes without approval (requires Pro license)
|
||||
ControlLevelAutonomous = "autonomous"
|
||||
)
|
||||
|
||||
// Default models per provider
|
||||
const (
|
||||
DefaultAIModelAnthropic = "claude-opus-4-5-20251101"
|
||||
DefaultAIModelAnthropic = "claude-3-5-haiku-latest"
|
||||
DefaultAIModelOpenAI = "gpt-4o"
|
||||
DefaultAIModelOllama = "llama3"
|
||||
DefaultAIModelDeepSeek = "deepseek-chat" // V3.2 with tool-use support
|
||||
@@ -483,3 +500,35 @@ func (c *AIConfig) GetRequestTimeout() time.Duration {
|
||||
}
|
||||
return 300 * time.Second // 5 minutes default
|
||||
}
|
||||
|
||||
// GetControlLevel returns the AI control level, defaulting to read_only if not set
|
||||
func (c *AIConfig) GetControlLevel() string {
|
||||
if c.ControlLevel == "" {
|
||||
return ControlLevelReadOnly
|
||||
}
|
||||
return c.ControlLevel
|
||||
}
|
||||
|
||||
// IsControlEnabled returns true if AI has any control capability beyond read-only
|
||||
func (c *AIConfig) IsControlEnabled() bool {
|
||||
level := c.GetControlLevel()
|
||||
return level != ControlLevelReadOnly
|
||||
}
|
||||
|
||||
// IsValidControlLevel checks if a control level string is valid
|
||||
func IsValidControlLevel(level string) bool {
|
||||
switch level {
|
||||
case ControlLevelReadOnly, ControlLevelSuggest, ControlLevelControlled, ControlLevelAutonomous:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// GetProtectedGuests returns the list of protected guests (VMIDs or names)
|
||||
func (c *AIConfig) GetProtectedGuests() []string {
|
||||
if c.ProtectedGuests == nil {
|
||||
return []string{}
|
||||
}
|
||||
return c.ProtectedGuests
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ const (
|
||||
ConfigTypeEnum ConfigType = "enum"
|
||||
)
|
||||
|
||||
// ValidConfigKeys defines all valid agent configuration keys.
|
||||
// ValidConfigKeys defines agent configuration keys that are actually applied by the agent.
|
||||
// These match the keys handled in applyRemoteSettings() in cmd/pulse-agent/main.go.
|
||||
var ValidConfigKeys = []ConfigKeyDefinition{
|
||||
{
|
||||
Key: "interval",
|
||||
@@ -40,6 +41,12 @@ var ValidConfigKeys = []ConfigKeyDefinition{
|
||||
Description: "Polling interval for metrics collection",
|
||||
Default: "30s",
|
||||
},
|
||||
{
|
||||
Key: "enable_host",
|
||||
Type: ConfigTypeBool,
|
||||
Description: "Enable host monitoring (metrics + command execution)",
|
||||
Default: true,
|
||||
},
|
||||
{
|
||||
Key: "enable_docker",
|
||||
Type: ConfigTypeBool,
|
||||
@@ -47,22 +54,54 @@ var ValidConfigKeys = []ConfigKeyDefinition{
|
||||
Default: true,
|
||||
},
|
||||
{
|
||||
Key: "enable_system_metrics",
|
||||
Key: "enable_kubernetes",
|
||||
Type: ConfigTypeBool,
|
||||
Description: "Enable system-level metrics (CPU, memory, disk)",
|
||||
Default: true,
|
||||
},
|
||||
{
|
||||
Key: "enable_process_metrics",
|
||||
Type: ConfigTypeBool,
|
||||
Description: "Enable process-level metrics",
|
||||
Description: "Enable Kubernetes workload monitoring",
|
||||
Default: false,
|
||||
},
|
||||
{
|
||||
Key: "enable_network_metrics",
|
||||
Key: "enable_proxmox",
|
||||
Type: ConfigTypeBool,
|
||||
Description: "Enable network interface metrics",
|
||||
Default: true,
|
||||
Description: "Enable Proxmox mode for node registration",
|
||||
Default: false,
|
||||
},
|
||||
{
|
||||
Key: "proxmox_type",
|
||||
Type: ConfigTypeEnum,
|
||||
Description: "Proxmox type override (pve or pbs; auto-detect if unset)",
|
||||
Default: "auto",
|
||||
Enum: []string{"pve", "pbs", "auto"},
|
||||
},
|
||||
{
|
||||
Key: "docker_runtime",
|
||||
Type: ConfigTypeEnum,
|
||||
Description: "Container runtime preference (auto, docker, podman)",
|
||||
Default: "auto",
|
||||
Enum: []string{"auto", "docker", "podman"},
|
||||
},
|
||||
{
|
||||
Key: "disable_auto_update",
|
||||
Type: ConfigTypeBool,
|
||||
Description: "Disable automatic agent updates",
|
||||
Default: false,
|
||||
},
|
||||
{
|
||||
Key: "disable_docker_update_checks",
|
||||
Type: ConfigTypeBool,
|
||||
Description: "Disable Docker image update detection",
|
||||
Default: false,
|
||||
},
|
||||
{
|
||||
Key: "kube_include_all_pods",
|
||||
Type: ConfigTypeBool,
|
||||
Description: "Include all non-succeeded pods in Kubernetes reports",
|
||||
Default: false,
|
||||
},
|
||||
{
|
||||
Key: "kube_include_all_deployments",
|
||||
Type: ConfigTypeBool,
|
||||
Description: "Include all deployments in Kubernetes reports",
|
||||
Default: false,
|
||||
},
|
||||
{
|
||||
Key: "log_level",
|
||||
@@ -72,98 +111,16 @@ var ValidConfigKeys = []ConfigKeyDefinition{
|
||||
Enum: []string{"debug", "info", "warn", "error"},
|
||||
},
|
||||
{
|
||||
Key: "metric_buffer_size",
|
||||
Type: ConfigTypeInt,
|
||||
Description: "Size of the metric buffer before flush",
|
||||
Default: 100,
|
||||
Min: ptrFloat(10),
|
||||
Max: ptrFloat(10000),
|
||||
},
|
||||
{
|
||||
Key: "connection_timeout",
|
||||
Type: ConfigTypeDuration,
|
||||
Description: "Timeout for server connections",
|
||||
Default: "30s",
|
||||
},
|
||||
{
|
||||
Key: "retry_interval",
|
||||
Type: ConfigTypeDuration,
|
||||
Description: "Interval between connection retries",
|
||||
Default: "5s",
|
||||
},
|
||||
{
|
||||
Key: "max_retries",
|
||||
Type: ConfigTypeInt,
|
||||
Description: "Maximum number of connection retries",
|
||||
Default: 3,
|
||||
Min: ptrFloat(0),
|
||||
Max: ptrFloat(100),
|
||||
},
|
||||
{
|
||||
Key: "disk_paths",
|
||||
Key: "report_ip",
|
||||
Type: ConfigTypeString,
|
||||
Description: "Comma-separated list of disk paths to monitor",
|
||||
Default: "/",
|
||||
},
|
||||
{
|
||||
Key: "exclude_containers",
|
||||
Type: ConfigTypeString,
|
||||
Description: "Regex pattern for container names to exclude",
|
||||
Description: "Override the reported IP address for the agent",
|
||||
Default: "",
|
||||
},
|
||||
{
|
||||
Key: "include_containers",
|
||||
Type: ConfigTypeString,
|
||||
Description: "Regex pattern for container names to include (empty = all)",
|
||||
Default: "",
|
||||
},
|
||||
{
|
||||
Key: "cpu_threshold_warning",
|
||||
Type: ConfigTypeFloat,
|
||||
Description: "CPU usage threshold for warnings (%)",
|
||||
Default: 80.0,
|
||||
Min: ptrFloat(0),
|
||||
Max: ptrFloat(100),
|
||||
},
|
||||
{
|
||||
Key: "cpu_threshold_critical",
|
||||
Type: ConfigTypeFloat,
|
||||
Description: "CPU usage threshold for critical alerts (%)",
|
||||
Default: 95.0,
|
||||
Min: ptrFloat(0),
|
||||
Max: ptrFloat(100),
|
||||
},
|
||||
{
|
||||
Key: "memory_threshold_warning",
|
||||
Type: ConfigTypeFloat,
|
||||
Description: "Memory usage threshold for warnings (%)",
|
||||
Default: 80.0,
|
||||
Min: ptrFloat(0),
|
||||
Max: ptrFloat(100),
|
||||
},
|
||||
{
|
||||
Key: "memory_threshold_critical",
|
||||
Type: ConfigTypeFloat,
|
||||
Description: "Memory usage threshold for critical alerts (%)",
|
||||
Default: 95.0,
|
||||
Min: ptrFloat(0),
|
||||
Max: ptrFloat(100),
|
||||
},
|
||||
{
|
||||
Key: "disk_threshold_warning",
|
||||
Type: ConfigTypeFloat,
|
||||
Description: "Disk usage threshold for warnings (%)",
|
||||
Default: 80.0,
|
||||
Min: ptrFloat(0),
|
||||
Max: ptrFloat(100),
|
||||
},
|
||||
{
|
||||
Key: "disk_threshold_critical",
|
||||
Type: ConfigTypeFloat,
|
||||
Description: "Disk usage threshold for critical alerts (%)",
|
||||
Default: 95.0,
|
||||
Min: ptrFloat(0),
|
||||
Max: ptrFloat(100),
|
||||
Key: "disable_ceph",
|
||||
Type: ConfigTypeBool,
|
||||
Description: "Disable local Ceph status polling",
|
||||
Default: false,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -358,8 +315,3 @@ func GetConfigKeyDefinition(key string) (ConfigKeyDefinition, bool) {
|
||||
}
|
||||
return ConfigKeyDefinition{}, false
|
||||
}
|
||||
|
||||
// ptrFloat returns a pointer to a float64.
|
||||
func ptrFloat(v float64) *float64 {
|
||||
return &v
|
||||
}
|
||||
|
||||
@@ -15,14 +15,19 @@ func TestProfileValidator_ValidateStringType(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "valid string",
|
||||
config: AgentConfigMap{"disk_paths": "/,/home"},
|
||||
config: AgentConfigMap{"report_ip": "192.168.1.100"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid empty string",
|
||||
config: AgentConfigMap{"report_ip": ""},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid string type",
|
||||
config: AgentConfigMap{"disk_paths": 123},
|
||||
config: AgentConfigMap{"report_ip": 123},
|
||||
wantErr: true,
|
||||
errKey: "disk_paths",
|
||||
errKey: "report_ip",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -97,103 +102,11 @@ func TestProfileValidator_ValidateBoolType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileValidator_ValidateIntType(t *testing.T) {
|
||||
validator := NewProfileValidator()
|
||||
// Note: Int type validation tests removed - no schema keys currently use ConfigTypeInt.
|
||||
// The validation logic exists in validateValue() and can be tested if int keys are added.
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config AgentConfigMap
|
||||
wantErr bool
|
||||
errKey string
|
||||
}{
|
||||
{
|
||||
name: "valid int",
|
||||
config: AgentConfigMap{"metric_buffer_size": 100},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid int as float64 (JSON unmarshal)",
|
||||
config: AgentConfigMap{"metric_buffer_size": float64(100)},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "int below minimum",
|
||||
config: AgentConfigMap{"metric_buffer_size": 5},
|
||||
wantErr: true,
|
||||
errKey: "metric_buffer_size",
|
||||
},
|
||||
{
|
||||
name: "int above maximum",
|
||||
config: AgentConfigMap{"metric_buffer_size": 20000},
|
||||
wantErr: true,
|
||||
errKey: "metric_buffer_size",
|
||||
},
|
||||
{
|
||||
name: "invalid int type - string",
|
||||
config: AgentConfigMap{"metric_buffer_size": "100"},
|
||||
wantErr: true,
|
||||
errKey: "metric_buffer_size",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := validator.Validate(tt.config)
|
||||
if tt.wantErr && result.Valid {
|
||||
t.Errorf("expected validation to fail, but it passed")
|
||||
}
|
||||
if !tt.wantErr && !result.Valid {
|
||||
t.Errorf("expected validation to pass, but it failed: %v", result.Errors)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileValidator_ValidateFloatType(t *testing.T) {
|
||||
validator := NewProfileValidator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config AgentConfigMap
|
||||
wantErr bool
|
||||
errKey string
|
||||
}{
|
||||
{
|
||||
name: "valid float",
|
||||
config: AgentConfigMap{"cpu_threshold_warning": 80.5},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid float as int",
|
||||
config: AgentConfigMap{"cpu_threshold_warning": 80},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "float below minimum",
|
||||
config: AgentConfigMap{"cpu_threshold_warning": -10.0},
|
||||
wantErr: true,
|
||||
errKey: "cpu_threshold_warning",
|
||||
},
|
||||
{
|
||||
name: "float above maximum",
|
||||
config: AgentConfigMap{"cpu_threshold_warning": 150.0},
|
||||
wantErr: true,
|
||||
errKey: "cpu_threshold_warning",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := validator.Validate(tt.config)
|
||||
if tt.wantErr && result.Valid {
|
||||
t.Errorf("expected validation to fail, but it passed")
|
||||
}
|
||||
if !tt.wantErr && !result.Valid {
|
||||
t.Errorf("expected validation to pass, but it failed: %v", result.Errors)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// Note: Float type validation tests removed - no schema keys currently use ConfigTypeFloat.
|
||||
// The validation logic exists in validateValue() and can be tested if float keys are added.
|
||||
|
||||
func TestProfileValidator_ValidateDurationType(t *testing.T) {
|
||||
validator := NewProfileValidator()
|
||||
@@ -343,15 +256,17 @@ func TestProfileValidator_ComplexConfig(t *testing.T) {
|
||||
validator := NewProfileValidator()
|
||||
|
||||
config := AgentConfigMap{
|
||||
"interval": "30s",
|
||||
"enable_docker": true,
|
||||
"enable_system_metrics": true,
|
||||
"enable_process_metrics": false,
|
||||
"log_level": "info",
|
||||
"metric_buffer_size": 100,
|
||||
"cpu_threshold_warning": 80.0,
|
||||
"cpu_threshold_critical": 95.0,
|
||||
"disk_paths": "/,/home",
|
||||
"interval": "30s",
|
||||
"enable_host": true,
|
||||
"enable_docker": true,
|
||||
"enable_kubernetes": false,
|
||||
"enable_proxmox": true,
|
||||
"proxmox_type": "pve",
|
||||
"docker_runtime": "auto",
|
||||
"log_level": "info",
|
||||
"disable_auto_update": false,
|
||||
"disable_docker_update_checks": false,
|
||||
"report_ip": "192.168.1.100",
|
||||
}
|
||||
|
||||
result := validator.Validate(config)
|
||||
@@ -383,8 +298,8 @@ func TestGetConfigKeyDefinitions(t *testing.T) {
|
||||
t.Error("expected config key definitions to be non-empty")
|
||||
}
|
||||
|
||||
// Check some known keys exist
|
||||
expectedKeys := []string{"interval", "enable_docker", "log_level", "metric_buffer_size"}
|
||||
// Check some known keys exist (keys actually applied by the agent)
|
||||
expectedKeys := []string{"interval", "enable_docker", "log_level", "enable_host", "docker_runtime"}
|
||||
for _, key := range expectedKeys {
|
||||
found := false
|
||||
for _, def := range defs {
|
||||
|
||||
+20
-4
@@ -11,7 +11,7 @@
|
||||
# HOT_DEV_USE_PRO=true Build Pro binary (default: true if module available)
|
||||
# PULSE_MOCK_MODE=true Use isolated mock data directory
|
||||
# PULSE_DATA_DIR=/path Override data directory
|
||||
# PULSE_DEV_API_PORT=7656 Backend API port (default: 7656)
|
||||
# PULSE_DEV_API_PORT=7655 Backend API port (default: 7655)
|
||||
# FRONTEND_DEV_PORT=5173 Frontend dev server port (default: 5173)
|
||||
#
|
||||
# Pro Features Mode:
|
||||
@@ -104,7 +104,7 @@ fi
|
||||
FRONTEND_DEV_HOST=${FRONTEND_DEV_HOST:-0.0.0.0}
|
||||
FRONTEND_DEV_PORT=${FRONTEND_DEV_PORT:-${FRONTEND_PORT}}
|
||||
PULSE_DEV_API_HOST=${PULSE_DEV_API_HOST:-${LAN_IP}}
|
||||
PULSE_DEV_API_PORT=${PULSE_DEV_API_PORT:-7656}
|
||||
PULSE_DEV_API_PORT=${PULSE_DEV_API_PORT:-7655}
|
||||
|
||||
if [[ -z ${PULSE_DEV_API_URL:-} ]]; then
|
||||
PULSE_DEV_API_URL="http://${PULSE_DEV_API_HOST}:${PULSE_DEV_API_PORT}"
|
||||
@@ -220,6 +220,13 @@ pkill -x "pulse" 2>/dev/null || true
|
||||
sleep 1
|
||||
pkill -9 -x "pulse" 2>/dev/null || true
|
||||
|
||||
# Kill any stale OpenCode sidecar processes
|
||||
# These accumulate when Pulse restarts without proper cleanup
|
||||
log_info "Cleaning up stale OpenCode processes..."
|
||||
pkill -f "opencode.*serve" 2>/dev/null || true
|
||||
sleep 1
|
||||
pkill -9 -f "opencode.*serve" 2>/dev/null || true
|
||||
|
||||
kill_port "${FRONTEND_DEV_PORT}"
|
||||
kill_port "${PULSE_DEV_API_PORT}"
|
||||
kill_port "${EXTRA_CLEANUP_PORT}"
|
||||
@@ -407,7 +414,7 @@ log_info "Starting backend health monitor..."
|
||||
PULSE_DEV=${PULSE_DEV:-true} \
|
||||
PULSE_AUTH_USER=${PULSE_AUTH_USER} \
|
||||
PULSE_AUTH_PASS=${PULSE_AUTH_PASS} \
|
||||
./pulse &
|
||||
./pulse >> /opt/pulse/hotdev.log 2>&1 &
|
||||
NEW_PID=$!
|
||||
sleep 2
|
||||
if kill -0 "$NEW_PID" 2>/dev/null; then
|
||||
@@ -444,7 +451,11 @@ log_info "Starting backend file watcher..."
|
||||
fi
|
||||
fi
|
||||
|
||||
FRONTEND_PORT=${PULSE_DEV_API_PORT} PORT=${PULSE_DEV_API_PORT} PULSE_DATA_DIR=${PULSE_DATA_DIR} PULSE_USE_OPENCODE=${PULSE_USE_OPENCODE:-true} ALLOW_ADMIN_BYPASS=${ALLOW_ADMIN_BYPASS:-1} PULSE_DEV=${PULSE_DEV:-true} ./pulse &
|
||||
# Kill OpenCode sidecar - Pulse will spawn a fresh one
|
||||
# This prevents stale sidecars with lost session context
|
||||
pkill -f "opencode.*serve" 2>/dev/null || true
|
||||
|
||||
FRONTEND_PORT=${PULSE_DEV_API_PORT} PORT=${PULSE_DEV_API_PORT} PULSE_DATA_DIR=${PULSE_DATA_DIR} PULSE_USE_OPENCODE=${PULSE_USE_OPENCODE:-true} ALLOW_ADMIN_BYPASS=${ALLOW_ADMIN_BYPASS:-1} PULSE_DEV=${PULSE_DEV:-true} ./pulse >> /opt/pulse/hotdev.log 2>&1 &
|
||||
NEW_PID=$!
|
||||
sleep 1
|
||||
|
||||
@@ -517,6 +528,11 @@ cleanup() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Kill OpenCode sidecar (spawned by Pulse)
|
||||
pkill -f "opencode.*serve" 2>/dev/null || true
|
||||
sleep 1
|
||||
pkill -9 -f "opencode.*serve" 2>/dev/null || true
|
||||
|
||||
# Kill Frontend (Vite)
|
||||
if [[ -n ${VITE_PID:-} ]] && kill -0 "${VITE_PID}" 2>/dev/null; then
|
||||
kill "${VITE_PID}" 2>/dev/null || true
|
||||
|
||||
Reference in New Issue
Block a user