fix: normalize guest ID fallbacks to canonical instance:node:vmid format
Multiple frontend components were using - as a fallback when guest.id was falsy. This format drops the node component, which is critical for clustered setups where the same VMID can exist on different nodes. Changes: - GuestDrawer.tsx: Updated guestId() and handleAskAI() to use canonical format - GuestRow.tsx: Updated buildGuestId() to use canonical format - Dashboard.tsx: Updated handleGuestRowClick() and guest rendering loop, also fixed legacy metadata fallback to use consistent keying - ThresholdsTable.tsx: Updated guestsGroupedByNode() to use canonical format Backend changes: - Removed temporary debug logging added during investigation - Added alert history section to AI buildEnrichedResourceContext() function The backend generates VM/Container IDs in instance:node:vmid format (e.g., delly:delly:101) via makeGuestID(). This format is now consistently used across all frontend fallbacks to prevent AI context, metadata, overrides, and metrics from colliding or desyncing in clustered environments.
This commit is contained in:
parent
215cecc555
commit
f0b983667c
6 changed files with 317 additions and 15 deletions
|
|
@ -1173,7 +1173,8 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
|||
const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o]));
|
||||
|
||||
const guests = (props.allGuests() ?? []).map((guest) => {
|
||||
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
|
||||
// Use canonical format: instance:node:vmid
|
||||
const guestId = guest.id || `${guest.instance}:${guest.node}:${guest.vmid}`;
|
||||
const override = overridesMap.get(guestId);
|
||||
const overrideSeverity = override?.poweredOffSeverity;
|
||||
|
||||
|
|
|
|||
|
|
@ -922,7 +922,8 @@ export function Dashboard(props: DashboardProps) {
|
|||
// Only enable if AI is configured
|
||||
if (!aiChatStore.enabled) return;
|
||||
|
||||
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
|
||||
// Use canonical format: instance:node:vmid
|
||||
const guestId = guest.id || `${guest.instance}:${guest.node}:${guest.vmid}`;
|
||||
const guestType = guest.type === 'qemu' ? 'vm' : 'container';
|
||||
const isOCI = guest.type === 'oci' || ('isOci' in guest && guest.isOci === true);
|
||||
|
||||
|
|
@ -1172,12 +1173,13 @@ export function Dashboard(props: DashboardProps) {
|
|||
</Show>
|
||||
<For each={guests} fallback={<></>}>
|
||||
{(guest, index) => {
|
||||
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
|
||||
// Use canonical format: instance:node:vmid
|
||||
const guestId = guest.id || `${guest.instance}:${guest.node}:${guest.vmid}`;
|
||||
// Create a getter function for metadata to ensure reactivity
|
||||
// Accessing guestMetadata() in a plain variable breaks SolidJS reactivity
|
||||
const getMetadata = () =>
|
||||
guestMetadata()[guestId] ||
|
||||
guestMetadata()[`${guest.node}-${guest.vmid}`];
|
||||
guestMetadata()[`${guest.instance}:${guest.node}:${guest.vmid}`];
|
||||
// PERFORMANCE: Use pre-computed parent node map instead of resolveParentNode
|
||||
const parentNode = node ?? guestParentNodeMap().get(guestId);
|
||||
const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true;
|
||||
|
|
@ -1185,8 +1187,8 @@ export function Dashboard(props: DashboardProps) {
|
|||
// Get adjacent guest IDs for merged AI context borders
|
||||
const prevGuest = guests[index() - 1];
|
||||
const nextGuest = guests[index() + 1];
|
||||
const prevGuestId = prevGuest ? (prevGuest.id || `${prevGuest.instance}-${prevGuest.vmid}`) : null;
|
||||
const nextGuestId = nextGuest ? (nextGuest.id || `${nextGuest.instance}-${nextGuest.vmid}`) : null;
|
||||
const prevGuestId = prevGuest ? (prevGuest.id || `${prevGuest.instance}:${prevGuest.node}:${prevGuest.vmid}`) : null;
|
||||
const nextGuestId = nextGuest ? (nextGuest.id || `${nextGuest.instance}:${nextGuest.node}:${nextGuest.vmid}`) : null;
|
||||
|
||||
return (
|
||||
<ComponentErrorBoundary name="GuestRow">
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
|||
const [newAnnotation, setNewAnnotation] = createSignal('');
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
|
||||
// Build guest ID for metadata
|
||||
const guestId = () => props.guest.id || `${props.guest.instance}-${props.guest.vmid}`;
|
||||
// Build guest ID for metadata - use canonical format: instance:node:vmid
|
||||
const guestId = () => props.guest.id || `${props.guest.instance}:${props.guest.node}:${props.guest.vmid}`;
|
||||
|
||||
// Check if AI is enabled and load annotations on mount
|
||||
createEffect(() => {
|
||||
|
|
@ -103,19 +103,23 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
|||
uptime: guest.uptime ? formatUptime(guest.uptime) : 'Not running',
|
||||
};
|
||||
|
||||
// CPU info
|
||||
// CPU info - include both formatted and raw values
|
||||
// Raw values enable backend baseline comparison
|
||||
if (guest.cpu !== undefined) {
|
||||
context.cpu_usage = formatPercent(guest.cpu * 100);
|
||||
const cpuPercent = guest.cpu * 100;
|
||||
context.cpu_usage = formatPercent(cpuPercent);
|
||||
context.cpu_usage_raw = cpuPercent; // For baseline comparison
|
||||
}
|
||||
if (guest.cpus) {
|
||||
context.cpu_cores = guest.cpus;
|
||||
}
|
||||
|
||||
// Memory info
|
||||
// Memory info - include both formatted and raw values
|
||||
if (guest.memory) {
|
||||
context.memory_used = formatBytes(guest.memory.used || 0);
|
||||
context.memory_total = formatBytes(guest.memory.total || 0);
|
||||
context.memory_usage = formatPercent(guest.memory.usage || 0);
|
||||
context.memory_usage_raw = guest.memory.usage || 0; // For baseline comparison
|
||||
if (guest.memory.balloon && guest.memory.balloon !== guest.memory.total) {
|
||||
context.memory_balloon = formatBytes(guest.memory.balloon);
|
||||
}
|
||||
|
|
@ -125,11 +129,13 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Disk info
|
||||
// Disk info - include both formatted and raw values
|
||||
if (guest.disk && guest.disk.total > 0) {
|
||||
const diskPercent = (guest.disk.used / guest.disk.total) * 100;
|
||||
context.disk_used = formatBytes(guest.disk.used || 0);
|
||||
context.disk_total = formatBytes(guest.disk.total || 0);
|
||||
context.disk_usage = formatPercent((guest.disk.used / guest.disk.total) * 100);
|
||||
context.disk_usage = formatPercent(diskPercent);
|
||||
context.disk_usage_raw = diskPercent; // For baseline comparison
|
||||
}
|
||||
|
||||
// I/O rates
|
||||
|
|
@ -185,7 +191,8 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
|||
|
||||
const handleAskAI = () => {
|
||||
const guestType = props.guest.type === 'qemu' ? 'vm' : 'container';
|
||||
const guestId = props.guest.id || `${props.guest.instance}-${props.guest.vmid}`;
|
||||
// Use canonical format: instance:node:vmid
|
||||
const guestId = props.guest.id || `${props.guest.instance}:${props.guest.node}:${props.guest.vmid}`;
|
||||
|
||||
aiChatStore.openForTarget(guestType, guestId, {
|
||||
guestName: props.guest.name,
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ const DEFAULT_FIRST_CELL_INDENT = 'pl-4';
|
|||
|
||||
const buildGuestId = (guest: Guest) => {
|
||||
if (guest.id) return guest.id;
|
||||
return `${guest.instance}-${guest.vmid}`;
|
||||
// Canonical format: instance:node:vmid
|
||||
return `${guest.instance}:${guest.node}:${guest.vmid}`;
|
||||
};
|
||||
|
||||
// Type guard for VM vs Container
|
||||
|
|
|
|||
|
|
@ -364,6 +364,13 @@ func (p *PatrolService) GetBaselineStore() *baseline.Store {
|
|||
return p.baselineStore
|
||||
}
|
||||
|
||||
// GetMetricsHistoryProvider returns the metrics history provider for trend analysis
|
||||
func (p *PatrolService) GetMetricsHistoryProvider() MetricsHistoryProvider {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.metricsHistory
|
||||
}
|
||||
|
||||
// SetChangeDetector sets the change detector for tracking infrastructure changes
|
||||
func (p *PatrolService) SetChangeDetector(detector *ChangeDetector) {
|
||||
p.mu.Lock()
|
||||
|
|
|
|||
|
|
@ -2935,6 +2935,10 @@ This is a 3-command job. Don't over-investigate.`
|
|||
prompt += fmt.Sprintf("\n- %s: %v", formatContextKey(k), v)
|
||||
}
|
||||
}
|
||||
|
||||
// Add enriched historical context (baselines, trends, predictions)
|
||||
// This is the Pulse Pro value-add that Claude Code can't replicate
|
||||
prompt += s.buildEnrichedResourceContext(req.TargetID, req.TargetType, req.Context)
|
||||
}
|
||||
|
||||
return prompt
|
||||
|
|
@ -3261,3 +3265,283 @@ func truncateString(s string, maxLen int) string {
|
|||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
// buildEnrichedResourceContext adds historical intelligence to regular AI chat
|
||||
// This is what makes Pulse Pro valuable - context that Claude Code can't have:
|
||||
// - Baseline comparisons ("this is 2x normal")
|
||||
// - Trend analysis ("memory growing 5%/day")
|
||||
// - Predictions ("disk full in 12 days")
|
||||
// - Recent changes ("config changed 2 hours ago")
|
||||
func (s *Service) buildEnrichedResourceContext(resourceID, resourceType string, currentMetrics map[string]interface{}) string {
|
||||
s.mu.RLock()
|
||||
patrol := s.patrolService
|
||||
s.mu.RUnlock()
|
||||
|
||||
if patrol == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sections []string
|
||||
|
||||
// Get baseline store for comparisons
|
||||
baselineStore := patrol.GetBaselineStore()
|
||||
if baselineStore != nil {
|
||||
var baselineInfo []string
|
||||
|
||||
// Helper to get raw numeric value from context
|
||||
// Prefers _raw fields, falls back to regular fields
|
||||
getRawValue := func(rawKey, formattedKey string) (float64, bool) {
|
||||
// Try raw value first
|
||||
if val, ok := currentMetrics[rawKey].(float64); ok && val > 0 {
|
||||
return val, true
|
||||
}
|
||||
// Fallback to formatted value if it's already a float
|
||||
if val, ok := currentMetrics[formattedKey].(float64); ok && val > 0 {
|
||||
return val, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Check CPU baseline
|
||||
if cpuVal, ok := getRawValue("cpu_usage_raw", "cpu_usage"); ok {
|
||||
if bl, exists := baselineStore.GetBaseline(resourceID, "cpu"); exists && bl.SampleCount >= 10 {
|
||||
ratio := cpuVal / bl.Mean
|
||||
if ratio > 1.5 {
|
||||
baselineInfo = append(baselineInfo, fmt.Sprintf("CPU %.0f%% is **%.1fx higher** than baseline %.0f%% (σ=%.1f)", cpuVal, ratio, bl.Mean, bl.StdDev))
|
||||
} else if ratio < 0.5 {
|
||||
baselineInfo = append(baselineInfo, fmt.Sprintf("CPU %.0f%% is **%.1fx lower** than baseline %.0f%%", cpuVal, 1/ratio, bl.Mean))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check memory baseline
|
||||
if memVal, ok := getRawValue("memory_usage_raw", "memory_usage"); ok {
|
||||
if bl, exists := baselineStore.GetBaseline(resourceID, "memory"); exists && bl.SampleCount >= 10 {
|
||||
ratio := memVal / bl.Mean
|
||||
if ratio > 1.3 {
|
||||
baselineInfo = append(baselineInfo, fmt.Sprintf("Memory %.0f%% is **%.1fx higher** than baseline %.0f%%", memVal, ratio, bl.Mean))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check disk baseline
|
||||
if diskVal, ok := getRawValue("disk_usage_raw", "disk_usage"); ok {
|
||||
if bl, exists := baselineStore.GetBaseline(resourceID, "disk"); exists && bl.SampleCount >= 10 {
|
||||
ratio := diskVal / bl.Mean
|
||||
if ratio > 1.2 { // More sensitive for disk
|
||||
baselineInfo = append(baselineInfo, fmt.Sprintf("Disk %.0f%% is **%.1fx higher** than baseline %.0f%%", diskVal, ratio, bl.Mean))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(baselineInfo) > 0 {
|
||||
sections = append(sections, "### Baseline Comparisons\n"+strings.Join(baselineInfo, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// Compute trends from metrics history (this works immediately, no learning period)
|
||||
metricsHistory := patrol.GetMetricsHistoryProvider()
|
||||
if metricsHistory != nil {
|
||||
var trendInfo []string
|
||||
duration := 24 * time.Hour // Last 24 hours
|
||||
|
||||
// Get metrics for this resource
|
||||
// Try guest metrics first, then node metrics
|
||||
allMetrics := metricsHistory.GetAllGuestMetrics(resourceID, duration)
|
||||
if len(allMetrics) == 0 {
|
||||
// Try as node
|
||||
cpuPoints := metricsHistory.GetNodeMetrics(resourceID, "cpu", duration)
|
||||
memPoints := metricsHistory.GetNodeMetrics(resourceID, "memory", duration)
|
||||
if len(cpuPoints) > 0 {
|
||||
allMetrics["cpu"] = cpuPoints
|
||||
}
|
||||
if len(memPoints) > 0 {
|
||||
allMetrics["memory"] = memPoints
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze trends for each metric
|
||||
for metric, points := range allMetrics {
|
||||
if len(points) < 3 { // Need at least 3 points for trends
|
||||
continue
|
||||
}
|
||||
|
||||
// Get first and last values
|
||||
first := points[0].Value
|
||||
last := points[len(points)-1].Value
|
||||
duration := points[len(points)-1].Timestamp.Sub(points[0].Timestamp)
|
||||
|
||||
if duration < 30*time.Minute { // Need at least 30min of data
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate rate of change per day
|
||||
change := last - first
|
||||
hoursSpan := duration.Hours()
|
||||
ratePerDay := change * (24 / hoursSpan)
|
||||
|
||||
// Only report significant trends
|
||||
if metric == "disk" && ratePerDay > 0.5 { // Growing by >0.5GB/day
|
||||
trendInfo = append(trendInfo, fmt.Sprintf("**Disk** growing %.1f GB/day over last %.0fh",
|
||||
ratePerDay, hoursSpan))
|
||||
} else if metric == "memory" && absFloat(ratePerDay) > 2 { // >2% change per day
|
||||
direction := "growing"
|
||||
if ratePerDay < 0 {
|
||||
direction = "declining"
|
||||
}
|
||||
trendInfo = append(trendInfo, fmt.Sprintf("**Memory** %s %.1f%%/day over last %.0fh",
|
||||
direction, absFloat(ratePerDay), hoursSpan))
|
||||
} else if metric == "cpu" && absFloat(ratePerDay) > 5 { // >5% change per day
|
||||
direction := "increasing"
|
||||
if ratePerDay < 0 {
|
||||
direction = "decreasing"
|
||||
}
|
||||
trendInfo = append(trendInfo, fmt.Sprintf("**CPU** %s %.1f%%/day over last %.0fh",
|
||||
direction, absFloat(ratePerDay), hoursSpan))
|
||||
}
|
||||
}
|
||||
|
||||
if len(trendInfo) > 0 {
|
||||
sections = append(sections, "### Trends (24h)\n"+strings.Join(trendInfo, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// Get pattern detector for predictions
|
||||
patternDetector := patrol.GetPatternDetector()
|
||||
if patternDetector != nil {
|
||||
predictions := patternDetector.GetPredictionsForResource(resourceID)
|
||||
if len(predictions) > 0 {
|
||||
var predInfo []string
|
||||
for _, pred := range predictions {
|
||||
if pred.DaysUntil < 30 { // Only show predictions within 30 days
|
||||
predInfo = append(predInfo, fmt.Sprintf("**%s** predicted in %.0f days (%.0f%% confidence)",
|
||||
pred.EventType, pred.DaysUntil, pred.Confidence*100))
|
||||
}
|
||||
}
|
||||
if len(predInfo) > 0 {
|
||||
sections = append(sections, "### Predictions\n"+strings.Join(predInfo, "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get change detector for recent changes
|
||||
changeDetector := patrol.GetChangeDetector()
|
||||
if changeDetector != nil {
|
||||
changes := changeDetector.GetChangesForResource(resourceID, 5)
|
||||
if len(changes) > 0 {
|
||||
var changeInfo []string
|
||||
for _, c := range changes {
|
||||
if len(changeInfo) >= 3 { // Limit to 3 recent changes
|
||||
break
|
||||
}
|
||||
ago := time.Since(c.DetectedAt).Truncate(time.Minute)
|
||||
changeInfo = append(changeInfo, fmt.Sprintf("**%s** %s (%s ago)", c.ChangeType, c.Description, ago))
|
||||
}
|
||||
if len(changeInfo) > 0 {
|
||||
sections = append(sections, "### Recent Changes\n"+strings.Join(changeInfo, "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get correlation detector for related resources
|
||||
correlationDetector := patrol.GetCorrelationDetector()
|
||||
if correlationDetector != nil {
|
||||
correlations := correlationDetector.GetCorrelationsForResource(resourceID)
|
||||
if len(correlations) > 0 {
|
||||
var corrInfo []string
|
||||
limit := 2
|
||||
if len(correlations) < limit {
|
||||
limit = len(correlations)
|
||||
}
|
||||
for _, corr := range correlations[:limit] {
|
||||
corrInfo = append(corrInfo, fmt.Sprintf("Correlated with **%s**: %s", corr.TargetName, corr.Description))
|
||||
}
|
||||
if len(corrInfo) > 0 {
|
||||
sections = append(sections, "### Related Resources\n"+strings.Join(corrInfo, "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get alert history for this resource (this works immediately, uses existing alert data)
|
||||
s.mu.RLock()
|
||||
alertProvider := s.alertProvider
|
||||
s.mu.RUnlock()
|
||||
|
||||
if alertProvider != nil {
|
||||
// Get active alerts for this resource
|
||||
activeAlerts := alertProvider.GetAlertsByResource(resourceID)
|
||||
|
||||
// Get historical alerts (last 20)
|
||||
historicalAlerts := alertProvider.GetAlertHistory(resourceID, 20)
|
||||
|
||||
if len(activeAlerts) > 0 || len(historicalAlerts) > 0 {
|
||||
var alertInfo []string
|
||||
|
||||
if len(activeAlerts) > 0 {
|
||||
alertInfo = append(alertInfo, fmt.Sprintf("**%d active alert(s)** right now", len(activeAlerts)))
|
||||
for _, a := range activeAlerts {
|
||||
alertInfo = append(alertInfo, fmt.Sprintf("- %s %s: %s (active %s)",
|
||||
strings.ToUpper(a.Level), a.Type, a.Message, a.Duration))
|
||||
}
|
||||
}
|
||||
|
||||
if len(historicalAlerts) > 0 {
|
||||
// Count alerts by type in the last 30 days
|
||||
alertsByType := make(map[string]int)
|
||||
cutoff := time.Now().Add(-30 * 24 * time.Hour)
|
||||
for _, a := range historicalAlerts {
|
||||
if a.ResolvedTime.After(cutoff) {
|
||||
alertsByType[a.Type]++
|
||||
}
|
||||
}
|
||||
|
||||
if len(alertsByType) > 0 {
|
||||
var typeCounts []string
|
||||
for alertType, count := range alertsByType {
|
||||
typeCounts = append(typeCounts, fmt.Sprintf("%d %s", count, alertType))
|
||||
}
|
||||
alertInfo = append(alertInfo, fmt.Sprintf("**Past 30 days:** %s alerts", strings.Join(typeCounts, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
if len(alertInfo) > 0 {
|
||||
sections = append(sections, "### Alert History\n"+strings.Join(alertInfo, "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no sections but baseline store exists, show learning status
|
||||
if len(sections) == 0 {
|
||||
baselineStore := patrol.GetBaselineStore()
|
||||
if baselineStore != nil {
|
||||
// Check if we're still learning baselines for this resource
|
||||
if rb, exists := baselineStore.GetResourceBaseline(resourceID); exists {
|
||||
// Count metrics with enough samples
|
||||
ready := 0
|
||||
learning := 0
|
||||
for _, mb := range rb.Metrics {
|
||||
if mb.SampleCount >= 10 {
|
||||
ready++
|
||||
} else if mb.SampleCount > 0 {
|
||||
learning++
|
||||
}
|
||||
}
|
||||
|
||||
if learning > 0 && ready == 0 {
|
||||
return "\n\n## Historical Intelligence (Pulse Pro)\n*Learning baselines for this resource... Trends and anomaly detection will be available after more data is collected.*"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return "\n\n## Historical Intelligence (Pulse Pro)\n" + strings.Join(sections, "\n\n")
|
||||
}
|
||||
|
||||
// absFloat returns the absolute value of a float64
|
||||
func absFloat(x float64) float64 {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue