Pulse/frontend-modern/src/components/shared/AIOverviewTable.tsx
rcourtman 352a6d4213 Fix ESLint errors breaking CI
- 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
2025-12-21 00:41:33 +00:00

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>
);
};