- KubernetesClusters.tsx: Escape -> as → in JSX text to fix parsing error - Settings.tsx: Remove unused HostProxySummary interface (deprecated in v5) - AIOverviewTable.tsx: Prefix unused summarizeAction with underscore
675 lines
33 KiB
TypeScript
675 lines
33 KiB
TypeScript
import { Component, createEffect, createSignal, For, Show } from 'solid-js';
|
|
import { AIAPI } from '@/api/ai';
|
|
import type { FailurePrediction, InfrastructureChange, RemediationRecord, RemediationStats, ResourceCorrelation } from '@/types/aiIntelligence';
|
|
|
|
const DEFAULT_UPGRADE_URL = 'https://pulsemonitor.app/pro';
|
|
|
|
interface InsightRow {
|
|
id: string;
|
|
type: 'prediction' | 'impact' | 'memory';
|
|
typeBadge: string;
|
|
typeBadgeClass: string;
|
|
title: string;
|
|
subtitle: string;
|
|
timestamp: string;
|
|
confidence?: number;
|
|
locked: boolean;
|
|
badgeClass: string;
|
|
}
|
|
|
|
/**
|
|
* AIOverviewTable displays AI Insights, Pulse AI Impact, and Operational Memory
|
|
* in a unified, scannable table format
|
|
*/
|
|
export const AIOverviewTable: Component<{ showWhenEmpty?: boolean }> = (props) => {
|
|
const [predictions, setPredictions] = createSignal<FailurePrediction[]>([]);
|
|
const [correlations, setCorrelations] = createSignal<ResourceCorrelation[]>([]);
|
|
const [remediations, setRemediations] = createSignal<RemediationRecord[]>([]);
|
|
const [remediationStats, setRemediationStats] = createSignal<RemediationStats | null>(null);
|
|
const [changes, setChanges] = createSignal<InfrastructureChange[]>([]);
|
|
const [loading, setLoading] = createSignal(false);
|
|
const [error, setError] = createSignal('');
|
|
|
|
// Locked states
|
|
const [insightsLocked, setInsightsLocked] = createSignal(false);
|
|
const [impactLocked, setImpactLocked] = createSignal(false);
|
|
const [memoryLocked, setMemoryLocked] = createSignal(false);
|
|
const [upgradeUrl, setUpgradeUrl] = createSignal(DEFAULT_UPGRADE_URL);
|
|
|
|
// Locked counts (for locked state display)
|
|
const [insightsLockedCount, setInsightsLockedCount] = createSignal(0);
|
|
const [memoryLockedCount, setMemoryLockedCount] = createSignal(0);
|
|
|
|
const showWhenEmpty = () => Boolean(props.showWhenEmpty);
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const [predResp, corrResp, remResp, changesResp] = await Promise.all([
|
|
AIAPI.getPredictions(),
|
|
AIAPI.getCorrelations(),
|
|
AIAPI.getRemediations({ hours: 168, limit: 6 }),
|
|
AIAPI.getRecentChanges(24),
|
|
]);
|
|
|
|
// Handle insights lock
|
|
const insightsLockedState = Boolean(predResp.license_required || corrResp.license_required);
|
|
setInsightsLocked(insightsLockedState);
|
|
if (insightsLockedState) {
|
|
const predCount = predResp.count || 0;
|
|
const corrCount = corrResp.count || 0;
|
|
setInsightsLockedCount(predCount + corrCount);
|
|
setPredictions([]);
|
|
setCorrelations([]);
|
|
} else {
|
|
setInsightsLockedCount(0);
|
|
setPredictions(predResp.predictions || []);
|
|
setCorrelations(corrResp.correlations || []);
|
|
}
|
|
|
|
// Handle impact lock
|
|
setImpactLocked(Boolean(remResp.license_required));
|
|
setRemediationStats(remResp.stats || null);
|
|
setRemediations(remResp.remediations || []);
|
|
|
|
// Handle memory lock
|
|
const memoryLockedState = Boolean(changesResp.license_required);
|
|
setMemoryLocked(memoryLockedState);
|
|
if (memoryLockedState) {
|
|
setMemoryLockedCount(changesResp.count || 0);
|
|
setChanges([]);
|
|
} else {
|
|
setMemoryLockedCount(0);
|
|
setChanges(changesResp.changes || []);
|
|
}
|
|
|
|
// Set upgrade URL from any response
|
|
setUpgradeUrl(
|
|
predResp.upgrade_url || corrResp.upgrade_url || remResp.upgrade_url || changesResp.upgrade_url || DEFAULT_UPGRADE_URL
|
|
);
|
|
} catch (e) {
|
|
console.error('Failed to load AI overview data:', e);
|
|
setError('Failed to load AI overview data.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
createEffect(() => {
|
|
void loadData();
|
|
});
|
|
|
|
// Format relative time
|
|
const formatRelativeTime = (ts: string) => {
|
|
const date = new Date(ts);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMs / 3600000);
|
|
const diffDays = Math.floor(diffMs / 86400000);
|
|
|
|
if (diffMins < 1) return 'just now';
|
|
if (diffMins < 60) return `${diffMins}m ago`;
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
|
if (diffDays < 7) return `${diffDays}d ago`;
|
|
return date.toLocaleDateString();
|
|
};
|
|
|
|
// Format days until
|
|
const formatDaysUntil = (days: number) => {
|
|
if (days < 0) return 'Overdue';
|
|
if (days < 1) return 'Today';
|
|
if (days < 2) return 'Tomorrow';
|
|
return `In ${Math.round(days)} days`;
|
|
};
|
|
|
|
// Build unified rows
|
|
const unifiedRows = (): InsightRow[] => {
|
|
const rows: InsightRow[] = [];
|
|
|
|
// Predictions
|
|
for (const pred of predictions()) {
|
|
const colorClass = pred.is_overdue || pred.days_until < 0
|
|
? 'text-red-600 dark:text-red-400'
|
|
: pred.days_until < 3
|
|
? 'text-amber-600 dark:text-amber-400'
|
|
: pred.days_until < 7
|
|
? 'text-yellow-600 dark:text-yellow-400'
|
|
: 'text-blue-600 dark:text-blue-400';
|
|
|
|
rows.push({
|
|
id: `pred-${pred.resource_id}-${pred.event_type}`,
|
|
type: 'prediction',
|
|
typeBadge: 'Prediction',
|
|
typeBadgeClass: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
|
title: formatEventName(pred.event_type),
|
|
subtitle: pred.basis,
|
|
timestamp: formatDaysUntil(pred.days_until),
|
|
confidence: pred.confidence,
|
|
locked: false,
|
|
badgeClass: colorClass,
|
|
});
|
|
}
|
|
|
|
// Correlations
|
|
for (const corr of correlations()) {
|
|
rows.push({
|
|
id: `corr-${corr.source_id}-${corr.target_id}`,
|
|
type: 'prediction',
|
|
typeBadge: 'Dependency',
|
|
typeBadgeClass: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
|
|
title: `${corr.source_name || corr.source_id} → ${corr.target_name || corr.target_id}`,
|
|
subtitle: corr.description || `${corr.event_pattern} (${corr.occurrences} observations)`,
|
|
timestamp: `Delay: ${corr.avg_delay}`,
|
|
confidence: corr.confidence,
|
|
locked: false,
|
|
badgeClass: 'text-indigo-600 dark:text-indigo-400',
|
|
});
|
|
}
|
|
|
|
// Remediations - ONLY show actual ACTIONS (restarts, resizes, cleanups, fixes)
|
|
// Skip diagnostic commands (df, grep, cat, tail, ps) - they don't provide lasting value
|
|
const isActionableCommand = (action: string): boolean => {
|
|
const cmd = action.trim().replace(/^\[[^\]]+\]\s*/, ''); // Strip [host] prefix
|
|
const actionPatterns = [
|
|
'docker restart', 'docker start', 'docker stop', 'docker rm',
|
|
'docker compose up', 'docker compose down', 'docker compose restart',
|
|
'systemctl restart', 'systemctl start', 'systemctl stop', 'systemctl enable', 'systemctl disable',
|
|
'service restart', 'service start', 'service stop',
|
|
'pct resize', 'pct start', 'pct stop', 'pct shutdown', 'pct reboot',
|
|
'qm resize', 'qm start', 'qm stop', 'qm shutdown', 'qm reboot',
|
|
'rm -', 'rm /',
|
|
'chmod', 'chown',
|
|
'mkdir',
|
|
'mv ', 'cp ',
|
|
'apt install', 'apt upgrade', 'apt remove',
|
|
'yum install', 'dnf install',
|
|
'pip install', 'npm install',
|
|
'kill ', 'pkill ', 'killall ',
|
|
'reboot', 'shutdown',
|
|
];
|
|
return actionPatterns.some(pattern => cmd.includes(pattern));
|
|
};
|
|
|
|
const actionableRemediations = remediations().filter(rem => isActionableCommand(rem.action));
|
|
|
|
for (const rem of actionableRemediations) {
|
|
const outcomeBadgeClass: Record<string, string> = {
|
|
resolved: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
|
partial: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
|
failed: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
|
};
|
|
|
|
rows.push({
|
|
id: `rem-${rem.finding_id}-${rem.timestamp}`,
|
|
type: 'impact',
|
|
typeBadge: rem.outcome === 'resolved' ? 'Fixed' : rem.outcome === 'failed' ? 'Failed' : 'Action',
|
|
typeBadgeClass: outcomeBadgeClass[rem.outcome] || 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300',
|
|
title: rem.summary || summarizeValue(rem.problem, rem.action), // Use backend summary or fallback
|
|
subtitle: rem.resource_name || '',
|
|
timestamp: formatRelativeTime(rem.timestamp),
|
|
locked: false,
|
|
badgeClass: 'text-emerald-600 dark:text-emerald-400',
|
|
});
|
|
}
|
|
|
|
// Changes - only show meaningful changes, not just "created" (infrastructure detection)
|
|
// Valuable changes: config, status, migrated, restarted, deleted, backed_up
|
|
// Skip: created (just means AI patrol discovered the resource)
|
|
const meaningfulChanges = changes().filter(c => c.change_type !== 'created');
|
|
for (const change of meaningfulChanges.slice(0, 6)) {
|
|
const changeTypeBadgeClass: Record<string, string> = {
|
|
deleted: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
|
config: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
|
status: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
|
migrated: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
|
restarted: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
|
|
backed_up: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
|
};
|
|
|
|
rows.push({
|
|
id: `change-${change.resource_id}-${change.detected_at}`,
|
|
type: 'memory',
|
|
typeBadge: formatChangeType(change.change_type),
|
|
typeBadgeClass: changeTypeBadgeClass[change.change_type] || 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
|
title: change.resource_name || change.resource_id,
|
|
subtitle: change.description || 'Change detected by AI Patrol.',
|
|
timestamp: formatRelativeTime(change.detected_at),
|
|
locked: false,
|
|
badgeClass: 'text-teal-600 dark:text-teal-400',
|
|
});
|
|
}
|
|
|
|
return rows;
|
|
};
|
|
|
|
const formatEventName = (eventType: string) => {
|
|
const names: Record<string, string> = {
|
|
high_memory: 'High Memory',
|
|
high_cpu: 'High CPU',
|
|
disk_full: 'Disk Full',
|
|
oom: 'Out of Memory',
|
|
restart: 'Restart',
|
|
unresponsive: 'Unresponsive',
|
|
backup_failed: 'Backup Failure',
|
|
};
|
|
if (names[eventType]) return names[eventType];
|
|
return eventType
|
|
.split(/[_-]/)
|
|
.map(part => {
|
|
if (part === 'cpu') return 'CPU';
|
|
if (part === 'vm') return 'VM';
|
|
if (part === 'pbs') return 'PBS';
|
|
if (part === 'raid') return 'RAID';
|
|
return part.charAt(0).toUpperCase() + part.slice(1);
|
|
})
|
|
.join(' ');
|
|
};
|
|
|
|
const formatChangeType = (changeType: string) => {
|
|
const labels: Record<string, string> = {
|
|
created: 'Created',
|
|
deleted: 'Deleted',
|
|
config: 'Config',
|
|
status: 'Status',
|
|
migrated: 'Migrated',
|
|
restarted: 'Restarted',
|
|
backed_up: 'Backup',
|
|
};
|
|
if (labels[changeType]) return labels[changeType];
|
|
return changeType.split(/[_-]/).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ');
|
|
};
|
|
|
|
// Summarize a shell command action into a human-readable description
|
|
// Prefixed with underscore - not currently used but kept for future expansion
|
|
const _summarizeAction = (action: string): { title: string; subtitle: string } => {
|
|
const cmd = action.trim();
|
|
|
|
// Common command patterns and their summaries
|
|
if (cmd.includes('df -h') || cmd.includes('du -s')) {
|
|
const pathMatch = cmd.match(/\/([\w/.-]+)/);
|
|
const path = pathMatch ? pathMatch[0] : 'storage';
|
|
return {
|
|
title: 'Checked disk usage',
|
|
subtitle: `Analyzed space on ${path}`
|
|
};
|
|
}
|
|
if (cmd.includes('docker ps') || cmd.includes('docker status')) {
|
|
const containerMatch = cmd.match(/name[=:]?\s*(\w+)/i);
|
|
const container = containerMatch ? containerMatch[1] : 'containers';
|
|
return {
|
|
title: 'Checked container status',
|
|
subtitle: `Verified ${container} is running`
|
|
};
|
|
}
|
|
if (cmd.includes('docker restart') || cmd.includes('docker start')) {
|
|
const containerMatch = cmd.match(/(?:restart|start)\s+(\w+)/i);
|
|
const container = containerMatch ? containerMatch[1] : 'container';
|
|
return {
|
|
title: 'Restarted container',
|
|
subtitle: `Restored ${container} service`
|
|
};
|
|
}
|
|
if (cmd.includes('systemctl restart') || (cmd.includes('service') && cmd.includes('restart'))) {
|
|
const serviceMatch = cmd.match(/(?:restart|start)\s+(\S+)/i);
|
|
const service = serviceMatch ? serviceMatch[1] : 'service';
|
|
return {
|
|
title: 'Restarted service',
|
|
subtitle: `Restored ${service}`
|
|
};
|
|
}
|
|
if (cmd.includes('grep') && cmd.includes('config')) {
|
|
return {
|
|
title: 'Checked configuration',
|
|
subtitle: 'Verified config settings'
|
|
};
|
|
}
|
|
if (cmd.includes('grep')) {
|
|
return {
|
|
title: 'Searched logs/config',
|
|
subtitle: 'Retrieved diagnostic information'
|
|
};
|
|
}
|
|
if (cmd.includes('tail') || cmd.includes('cat') || cmd.includes('head')) {
|
|
return {
|
|
title: 'Inspected logs/files',
|
|
subtitle: 'Retrieved diagnostic information'
|
|
};
|
|
}
|
|
if (cmd.includes('ps aux') || cmd.includes('top') || cmd.includes('htop')) {
|
|
return {
|
|
title: 'Checked processes',
|
|
subtitle: 'Analyzed running processes'
|
|
};
|
|
}
|
|
if (cmd.includes('free') || cmd.includes('memory')) {
|
|
return {
|
|
title: 'Checked memory usage',
|
|
subtitle: 'Analyzed memory allocation'
|
|
};
|
|
}
|
|
if (cmd.includes('ping') || cmd.includes('curl') || cmd.includes('wget')) {
|
|
return {
|
|
title: 'Tested connectivity',
|
|
subtitle: 'Verified network connection'
|
|
};
|
|
}
|
|
if (cmd.startsWith('[host]') || cmd.startsWith('[')) {
|
|
// Agent command - extract the actual command
|
|
const innerCmd = cmd.replace(/^\[[\w\s]+\]\s*/, '');
|
|
const inner = _summarizeAction(innerCmd);
|
|
return {
|
|
title: inner.title,
|
|
subtitle: `${inner.subtitle} (via agent)`
|
|
};
|
|
}
|
|
|
|
// Fallback: truncate and show the command
|
|
const truncated = cmd.length > 50 ? cmd.substring(0, 47) + '...' : cmd;
|
|
return {
|
|
title: 'Executed command',
|
|
subtitle: truncated
|
|
};
|
|
};
|
|
|
|
// Create a meaningful summary from the ACTION (command) only
|
|
// The problem field is just chat context and not useful here
|
|
const summarizeValue = (_problem: string, action: string): string => {
|
|
const cmd = action.trim();
|
|
|
|
// Extract target/context from the command (container names, paths, services)
|
|
const extractTarget = (): string | null => {
|
|
// Container name from docker commands
|
|
const containerMatch = cmd.match(/name[=:]?\s*(\w+)/i) || cmd.match(/filter.*?(\w+)/);
|
|
if (containerMatch) return containerMatch[1];
|
|
|
|
// Service name from systemctl
|
|
const serviceMatch = cmd.match(/systemctl\s+\w+\s+(\S+)/);
|
|
if (serviceMatch) return serviceMatch[1];
|
|
|
|
// Path for disk commands - get the meaningful part
|
|
const pathMatch = cmd.match(/\/([\w]+)(?:\/[\w_-]+)*(?:\s|$)/);
|
|
if (pathMatch) {
|
|
const fullPath = cmd.match(/\/[\w\/_-]+/)?.[0] || '';
|
|
// Extract meaningful name from path
|
|
if (fullPath.includes('frigate')) return 'Frigate';
|
|
if (fullPath.includes('plex')) return 'Plex';
|
|
if (fullPath.includes('docker')) return 'Docker';
|
|
if (fullPath.includes('recordings')) return 'recordings';
|
|
if (fullPath.includes('media')) return 'media';
|
|
if (fullPath.includes('config')) return 'config';
|
|
return fullPath.split('/').filter(Boolean).pop() || null;
|
|
}
|
|
|
|
// Config file reference
|
|
if (cmd.includes('config.yml') || cmd.includes('config.yaml')) return 'config';
|
|
|
|
return null;
|
|
};
|
|
|
|
const target = extractTarget();
|
|
const targetStr = target ? ` (${target})` : '';
|
|
|
|
// Generate meaningful descriptions based on command type
|
|
if (cmd.includes('docker restart') || cmd.includes('docker start')) {
|
|
return `Restarted container${targetStr}`;
|
|
}
|
|
if (cmd.includes('docker stop')) {
|
|
return `Stopped container${targetStr}`;
|
|
}
|
|
if (cmd.includes('docker ps') || cmd.includes('docker status')) {
|
|
return `Verified container status${targetStr}`;
|
|
}
|
|
if (cmd.includes('docker logs')) {
|
|
return `Retrieved container logs${targetStr}`;
|
|
}
|
|
if (cmd.includes('systemctl restart') || cmd.includes('service') && cmd.includes('restart')) {
|
|
return `Restarted service${targetStr}`;
|
|
}
|
|
if (cmd.includes('systemctl status')) {
|
|
return `Checked service status${targetStr}`;
|
|
}
|
|
if ((cmd.includes('df') || cmd.includes('du')) && target) {
|
|
return `Analyzed storage usage${targetStr}`;
|
|
}
|
|
if (cmd.includes('df') || cmd.includes('du')) {
|
|
return 'Analyzed disk space';
|
|
}
|
|
if (cmd.includes('grep') && (cmd.includes('config') || cmd.includes('.yml') || cmd.includes('.yaml'))) {
|
|
return `Inspected configuration${targetStr}`;
|
|
}
|
|
if (cmd.includes('grep') || cmd.includes('tail -f') || cmd.includes('journalctl')) {
|
|
return `Reviewed logs${targetStr}`;
|
|
}
|
|
if (cmd.includes('tail') || cmd.includes('cat') || cmd.includes('head')) {
|
|
return `Retrieved file contents${targetStr}`;
|
|
}
|
|
if (cmd.includes('ping') || cmd.includes('curl') || cmd.includes('wget')) {
|
|
return 'Tested network connectivity';
|
|
}
|
|
if (cmd.includes('free') || cmd.includes('/proc/meminfo')) {
|
|
return 'Checked memory usage';
|
|
}
|
|
if (cmd.includes('ps aux') || cmd.includes('top') || cmd.includes('htop')) {
|
|
return 'Analyzed running processes';
|
|
}
|
|
if (cmd.includes('chmod') || cmd.includes('chown')) {
|
|
return `Fixed permissions${targetStr}`;
|
|
}
|
|
if (cmd.includes('rm ') || cmd.includes('rm -')) {
|
|
return `Cleaned up files${targetStr}`;
|
|
}
|
|
if (cmd.includes('mkdir')) {
|
|
return `Created directory${targetStr}`;
|
|
}
|
|
if (cmd.startsWith('[host]') || cmd.startsWith('[')) {
|
|
// Agent command - recurse with inner command
|
|
const innerCmd = cmd.replace(/^\[[\w\s]+\]\s*/, '');
|
|
return summarizeValue('', innerCmd);
|
|
}
|
|
|
|
return 'Ran diagnostic';
|
|
};
|
|
|
|
const totalItems = () => unifiedRows().length;
|
|
const anyLocked = () => insightsLocked() || impactLocked() || memoryLocked();
|
|
|
|
const shouldShow = () => showWhenEmpty() || loading() || totalItems() > 0 || anyLocked();
|
|
|
|
// Stats for quick summary
|
|
const statsDisplay = () => {
|
|
const stats = remediationStats();
|
|
if (!stats) return null;
|
|
return stats;
|
|
};
|
|
|
|
return (
|
|
<Show when={shouldShow()}>
|
|
<div class="bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden shadow-sm">
|
|
{/* Header */}
|
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-purple-50 via-emerald-50 to-teal-50 dark:from-purple-900/20 dark:via-emerald-900/20 dark:to-teal-900/20">
|
|
<div class="flex items-center justify-between flex-wrap gap-2">
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex items-center gap-1.5">
|
|
<svg class="w-5 h-5 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="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>
|
|
<span class="font-semibold text-gray-900 dark:text-gray-100">AI Intelligence Summary</span>
|
|
</div>
|
|
<Show when={totalItems() > 0}>
|
|
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-full">
|
|
{totalItems()} items
|
|
</span>
|
|
</Show>
|
|
<Show when={anyLocked()}>
|
|
<span class="px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 rounded-full">
|
|
Some Locked
|
|
</span>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* Quick Stats */}
|
|
<Show when={statsDisplay()}>
|
|
<div class="flex items-center gap-3 text-xs">
|
|
<div class="flex items-center gap-1">
|
|
<span class="text-green-600 dark:text-green-400">✓</span>
|
|
<span class="text-gray-600 dark:text-gray-400">{statsDisplay()!.resolved} resolved</span>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<span class="text-blue-600 dark:text-blue-400">⚡</span>
|
|
<span class="text-gray-600 dark:text-gray-400">{statsDisplay()!.automatic} auto-fix</span>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Loading */}
|
|
<Show when={loading()}>
|
|
<div class="px-4 py-6 text-center">
|
|
<div class="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
|
<span class="h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
|
Loading AI intelligence...
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Error */}
|
|
<Show when={error() && !loading()}>
|
|
<div class="px-4 py-4">
|
|
<div class="text-sm text-red-600 dark:text-red-400">{error()}</div>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Locked notice (if all are locked) */}
|
|
<Show when={insightsLocked() && impactLocked() && memoryLocked() && !loading()}>
|
|
<div class="px-4 py-4">
|
|
<div class="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 text-sm text-amber-800 dark:text-amber-200">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div>
|
|
<p class="font-medium">Pulse Pro required</p>
|
|
<p class="text-xs text-amber-700 dark:text-amber-300 mt-1">
|
|
Full AI intelligence features including predictions, impact tracking, and operational memory require Pulse Pro.
|
|
</p>
|
|
</div>
|
|
<a
|
|
class="text-xs font-medium text-amber-800 dark:text-amber-200 underline whitespace-nowrap"
|
|
href={upgradeUrl()}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
Upgrade
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Table */}
|
|
<Show when={!loading() && (totalItems() > 0 || !anyLocked())}>
|
|
<Show
|
|
when={totalItems() > 0}
|
|
fallback={
|
|
<div class="px-4 py-8 text-center">
|
|
<div class="inline-flex flex-col items-center gap-2">
|
|
<svg class="w-10 h-10 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
No AI insights yet. The AI will learn patterns and surface intelligence over time.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="bg-gray-50 dark:bg-gray-800/80 text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
|
|
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider w-24">Type</th>
|
|
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider">Details</th>
|
|
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wider w-24">When</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
|
<For each={unifiedRows()}>
|
|
{(row) => (
|
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/40 transition-colors">
|
|
<td class="px-4 py-2.5">
|
|
<span class={`inline-flex px-2 py-0.5 text-[10px] font-semibold rounded-full whitespace-nowrap ${row.typeBadgeClass}`}>
|
|
{row.typeBadge}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-2.5">
|
|
<div class="flex flex-col gap-0.5">
|
|
<span class={`font-medium text-gray-800 dark:text-gray-200 ${row.badgeClass}`}>
|
|
{row.title}
|
|
</span>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 line-clamp-1">
|
|
{row.subtitle}
|
|
</span>
|
|
</div>
|
|
<Show when={row.confidence !== undefined}>
|
|
<span class="text-[10px] text-gray-400 dark:text-gray-500">
|
|
{Math.round(row.confidence! * 100)}% confidence
|
|
</span>
|
|
</Show>
|
|
</td>
|
|
<td class="px-4 py-2.5 text-right">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
|
{row.timestamp}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</For>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Show>
|
|
</Show>
|
|
|
|
{/* Section locked notices (partial lock) */}
|
|
<Show when={!loading() && anyLocked() && !(insightsLocked() && impactLocked() && memoryLocked())}>
|
|
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-amber-50/50 dark:bg-amber-900/10">
|
|
<div class="flex flex-wrap items-center gap-4 text-xs">
|
|
<Show when={insightsLocked()}>
|
|
<div class="flex items-center gap-1 text-amber-700 dark:text-amber-300">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15V11m0 0V7m0 4h4m-4 0H8" />
|
|
</svg>
|
|
<span>{insightsLockedCount()} AI Insights locked</span>
|
|
</div>
|
|
</Show>
|
|
<Show when={impactLocked()}>
|
|
<div class="flex items-center gap-1 text-amber-700 dark:text-amber-300">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
<span>Impact details locked</span>
|
|
</div>
|
|
</Show>
|
|
<Show when={memoryLocked()}>
|
|
<div class="flex items-center gap-1 text-amber-700 dark:text-amber-300">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 4H7a2 2 0 01-2-2V7a2 2 0 012-2h5l5 5v9a2 2 0 01-2 2z" />
|
|
</svg>
|
|
<span>{memoryLockedCount()} changes locked</span>
|
|
</div>
|
|
</Show>
|
|
<a
|
|
class="ml-auto text-amber-800 dark:text-amber-200 font-medium underline"
|
|
href={upgradeUrl()}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
Upgrade to Pulse Pro
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
);
|
|
};
|