fix(ai): improve patrol timing accuracy and status reporting

This commit is contained in:
rcourtman 2025-12-19 17:04:14 +00:00
parent 3b55639fbe
commit a535b22849
5 changed files with 171 additions and 84 deletions

View file

@ -41,6 +41,8 @@ export interface FindingsSummary {
info: number; info: number;
} }
export type LicenseStatus = 'none' | 'active' | 'expired' | 'grace_period';
export interface PatrolStatus { export interface PatrolStatus {
running: boolean; running: boolean;
enabled: boolean; enabled: boolean;
@ -53,6 +55,9 @@ export interface PatrolStatus {
error_count: number; error_count: number;
healthy: boolean; healthy: boolean;
interval_ms: number; // Patrol interval in milliseconds interval_ms: number; // Patrol interval in milliseconds
license_required?: boolean;
license_status?: LicenseStatus;
upgrade_url?: string;
summary: FindingsSummary; summary: FindingsSummary;
} }

View file

@ -49,7 +49,17 @@ export function AIStatusIndicator() {
const tooltipText = createMemo(() => { const tooltipText = createMemo(() => {
const s = status(); const s = status();
if (!s || !s.enabled) return 'AI Patrol disabled'; if (!s) return 'AI Patrol status unavailable';
if (!s.enabled) return 'AI Patrol disabled';
if (s.license_required) {
if (s.license_status === 'active') {
return 'AI Patrol is not included in this license tier';
}
if (s.license_status === 'expired') {
return 'AI Patrol license expired - upgrade to restore';
}
return 'AI Patrol requires Pulse Pro';
}
if (!s.running) return 'AI Patrol not running'; if (!s.running) return 'AI Patrol not running';
const parts: string[] = []; const parts: string[] = [];

View file

@ -9,7 +9,7 @@ type SettingsPanelProps = {
bodyClass?: string; bodyClass?: string;
tone?: 'default' | 'muted' | 'info' | 'success' | 'warning' | 'danger'; tone?: 'default' | 'muted' | 'info' | 'success' | 'warning' | 'danger';
padding?: 'none' | 'sm' | 'md' | 'lg'; padding?: 'none' | 'sm' | 'md' | 'lg';
} & JSX.HTMLAttributes<HTMLDivElement>; } & Omit<JSX.HTMLAttributes<HTMLDivElement>, 'title'>;
export function SettingsPanel(props: SettingsPanelProps) { export function SettingsPanel(props: SettingsPanelProps) {
const [local, rest] = splitProps(props, [ const [local, rest] = splitProps(props, [

View file

@ -2088,6 +2088,25 @@ function OverviewTab(props: {
const [liveStreamBlocks, setLiveStreamBlocks] = createSignal<StreamBlock[]>([]); const [liveStreamBlocks, setLiveStreamBlocks] = createSignal<StreamBlock[]>([]);
const [currentThinking, setCurrentThinking] = createSignal(''); const [currentThinking, setCurrentThinking] = createSignal('');
let liveStreamUnsubscribe: (() => void) | null = null; let liveStreamUnsubscribe: (() => void) | null = null;
const patrolRequiresLicense = createMemo(() => patrolStatus()?.license_required === true);
const patrolUpgradeURL = createMemo(() => patrolStatus()?.upgrade_url || 'https://pulsemonitor.app/pro');
const patrolLicenseNote = createMemo(() => {
if (!patrolRequiresLicense()) return '';
const status = patrolStatus()?.license_status;
if (status === 'active') {
return 'Your license is active but does not include AI Patrol.';
}
if (status === 'expired') {
return 'Your license has expired. Renew to restore AI Patrol.';
}
if (status === 'none') {
return 'No Pulse Pro license is active.';
}
if (status === 'grace_period') {
return 'Your license is in the grace period.';
}
return 'AI Patrol insights require Pulse Pro.';
});
// Effect to manage live stream subscription when expanded // Effect to manage live stream subscription when expanded
createEffect(() => { createEffect(() => {
@ -2460,8 +2479,10 @@ function OverviewTab(props: {
<button <button
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-all bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 border border-purple-300 dark:border-purple-700 hover:bg-purple-200 dark:hover:bg-purple-900/60 disabled:opacity-50 disabled:cursor-not-allowed" class="px-3 py-1.5 text-xs font-medium rounded-lg transition-all bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 border border-purple-300 dark:border-purple-700 hover:bg-purple-200 dark:hover:bg-purple-900/60 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => handleForcePatrol(true)} onClick={() => handleForcePatrol(true)}
disabled={forcePatrolLoading() || patrolStatus()?.running} disabled={forcePatrolLoading() || patrolStatus()?.running || patrolRequiresLicense()}
title={patrolStatus()?.running ? 'Patrol in progress - see table below' : 'Run a patrol check now'} title={patrolRequiresLicense()
? 'Pulse Pro required to run AI Patrol'
: (patrolStatus()?.running ? 'Patrol in progress - see table below' : 'Run a patrol check now')}
> >
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -2473,6 +2494,30 @@ function OverviewTab(props: {
</button> </button>
</div> </div>
<Show when={patrolRequiresLicense()}>
<div class="mb-4 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3">
<div class="flex items-start gap-3">
<svg class="w-4 h-4 text-amber-600 dark:text-amber-300 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11V7a4 4 0 00-8 0v4m12 0a2 2 0 012 2v6a2 2 0 01-2 2H8a2 2 0 01-2-2v-6a2 2 0 012-2h8z" />
</svg>
<div class="flex-1">
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">Pulse Pro required</p>
<p class="text-xs text-amber-700 dark:text-amber-300 mt-1">
{patrolLicenseNote() || 'AI Patrol insights require Pulse Pro.'}
</p>
<a
class="inline-flex items-center gap-1 mt-2 text-xs font-medium text-amber-800 dark:text-amber-200 hover:underline"
href={patrolUpgradeURL()}
target="_blank"
rel="noreferrer"
>
Upgrade to Pulse Pro
</a>
</div>
</div>
</div>
</Show>
{/* Summary Stats Bar */} {/* Summary Stats Bar */}
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3 mb-4 flex flex-wrap items-center gap-4 text-xs"> <div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3 mb-4 flex flex-wrap items-center gap-4 text-xs">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
@ -2506,34 +2551,35 @@ function OverviewTab(props: {
</div> </div>
</Show> </Show>
</div> </div>
<div class="space-y-2"> <Show when={!patrolRequiresLicense()}>
<Show <div class="space-y-2">
when={aiFindings().length > 0} <Show
fallback={ when={aiFindings().length > 0}
<div class="text-center py-6 border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 rounded-lg"> fallback={
<div class="flex justify-center mb-2"> <div class="text-center py-6 border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 rounded-lg">
<svg class="w-10 h-10 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="flex justify-center mb-2">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" /> <svg class="w-10 h-10 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4" /> <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" />
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4" />
</svg>
</div>
<p class="text-sm font-medium text-green-700 dark:text-green-400">All Systems Healthy</p>
<p class="text-xs text-green-600 dark:text-green-500 mt-1">AI patrol found no issues to report</p>
</div> </div>
<p class="text-sm font-medium text-green-700 dark:text-green-400">All Systems Healthy</p> }
<p class="text-xs text-green-600 dark:text-green-500 mt-1">AI patrol found no issues to report</p> >
</div> <For each={aiFindings().filter(f => !pendingFixFindings().has(f.id))}>
} {(finding) => {
> const colors = severityColors[finding.severity];
<For each={aiFindings().filter(f => !pendingFixFindings().has(f.id))}> const [isExpanded, setIsExpanded] = createSignal(false);
{(finding) => { return (
const colors = severityColors[finding.severity]; <div
const [isExpanded, setIsExpanded] = createSignal(false); class="border rounded-lg transition-all"
return ( style={{
<div 'background-color': colors.bg,
class="border rounded-lg transition-all" 'border-color': colors.border,
style={{ }}
'background-color': colors.bg, >
'border-color': colors.border,
}}
>
{/* Compact header - always visible, clickable */} {/* Compact header - always visible, clickable */}
<div <div
class="flex items-center gap-3 p-3 cursor-pointer hover:opacity-80" class="flex items-center gap-3 p-3 cursor-pointer hover:opacity-80"
@ -2767,36 +2813,38 @@ function OverviewTab(props: {
</div> </div>
</Show> </Show>
</div> </div>
); );
}} }}
</For> </For>
</Show> </Show>
</div>
{/* Suppression Rules - What's being ignored */}
<div class="mt-6">
<div class="flex items-center justify-between">
<button
class="flex items-center gap-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors"
onClick={() => setShowSuppressionRules(!showSuppressionRules())}
>
<svg
class={`w-4 h-4 transition-transform ${showSuppressionRules() ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
🔇 Suppression Rules ({suppressionRules().length} active)
</button>
<button
class="text-xs text-blue-600 dark:text-blue-400 hover:underline"
onClick={() => setShowAddRuleForm(!showAddRuleForm())}
>
+ Add Rule
</button>
</div> </div>
</Show>
<Show when={!patrolRequiresLicense()}>
{/* Suppression Rules - What's being ignored */}
<div class="mt-6">
<div class="flex items-center justify-between">
<button
class="flex items-center gap-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors"
onClick={() => setShowSuppressionRules(!showSuppressionRules())}
>
<svg
class={`w-4 h-4 transition-transform ${showSuppressionRules() ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
🔇 Suppression Rules ({suppressionRules().length} active)
</button>
<button
class="text-xs text-blue-600 dark:text-blue-400 hover:underline"
onClick={() => setShowAddRuleForm(!showAddRuleForm())}
>
+ Add Rule
</button>
</div>
{/* Add rule form */} {/* Add rule form */}
<Show when={showAddRuleForm()}> <Show when={showAddRuleForm()}>
@ -2921,29 +2969,31 @@ function OverviewTab(props: {
)} )}
</For> </For>
</div> </div>
</Show> </Show>
</div> </div>
</Show>
{/* Patrol Check History */} <Show when={!patrolRequiresLicense()}>
<div class="mt-6"> {/* Patrol Check History */}
<div class="flex items-center justify-between"> <div class="mt-6">
<button <div class="flex items-center justify-between">
class="flex items-center gap-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors" <button
onClick={() => setShowRunHistory(!showRunHistory())} class="flex items-center gap-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors"
> onClick={() => setShowRunHistory(!showRunHistory())}
<svg
class={`w-4 h-4 transition-transform ${showRunHistory() ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <svg
</svg> class={`w-4 h-4 transition-transform ${showRunHistory() ? 'rotate-90' : ''}`}
Patrol Check History ({filteredPatrolHistory().length} runs) fill="none"
<Show when={patrolRunHistory().length > 0 && patrolRunHistory()[0].status !== 'healthy'}> stroke="currentColor"
<span class="ml-1 px-1.5 py-0.5 text-[10px] bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300 rounded">Issues Found</span> viewBox="0 0 24 24"
</Show> >
</button> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
Patrol Check History ({filteredPatrolHistory().length} runs)
<Show when={patrolRunHistory().length > 0 && patrolRunHistory()[0].status !== 'healthy'}>
<span class="ml-1 px-1.5 py-0.5 text-[10px] bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300 rounded">Issues Found</span>
</Show>
</button>
{/* Next Patrol Timer */} {/* Next Patrol Timer */}
<Show when={nextPatrolIn()}> <Show when={nextPatrolIn()}>
@ -3303,8 +3353,9 @@ function OverviewTab(props: {
</div> </div>
</Show> </Show>
</div> </div>
</Show> </Show>
</div> </div>
</Show>
</div> </div>
</Show > </Show >

View file

@ -20,6 +20,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/ai/cost" "github.com/rcourtman/pulse-go-rewrite/internal/ai/cost"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/license"
"github.com/rcourtman/pulse-go-rewrite/internal/utils" "github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -136,6 +137,11 @@ func (h *AISettingsHandler) GetAlertTriggeredAnalyzer() *ai.AlertTriggeredAnalyz
return h.aiService.GetAlertTriggeredAnalyzer() return h.aiService.GetAlertTriggeredAnalyzer()
} }
// SetLicenseChecker sets the license checker for Pro feature gating
func (h *AISettingsHandler) SetLicenseChecker(checker ai.LicenseChecker) {
h.aiService.SetLicenseChecker(checker)
}
// AISettingsResponse is returned by GET /api/settings/ai // AISettingsResponse is returned by GET /api/settings/ai
// API keys are masked for security // API keys are masked for security
type AISettingsResponse struct { type AISettingsResponse struct {
@ -1957,7 +1963,11 @@ type PatrolStatusResponse struct {
FindingsCount int `json:"findings_count"` FindingsCount int `json:"findings_count"`
Healthy bool `json:"healthy"` Healthy bool `json:"healthy"`
IntervalMs int64 `json:"interval_ms"` // Patrol interval in milliseconds IntervalMs int64 `json:"interval_ms"` // Patrol interval in milliseconds
Summary struct { // License status for Pro feature gating
LicenseRequired bool `json:"license_required"` // True if Pro license needed for full features
LicenseStatus string `json:"license_status"` // "active", "expired", "grace_period", "none"
UpgradeURL string `json:"upgrade_url,omitempty"`
Summary struct {
Critical int `json:"critical"` Critical int `json:"critical"`
Warning int `json:"warning"` Warning int `json:"warning"`
Watch int `json:"watch"` Watch int `json:"watch"`
@ -1989,6 +1999,12 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http
status := patrol.GetStatus() status := patrol.GetStatus()
summary := patrol.GetFindingsSummary() summary := patrol.GetFindingsSummary()
// Determine license status for Pro feature gating
// GetLicenseState returns accurate state: none, active, expired, grace_period
licenseStatus, _ := h.aiService.GetLicenseState()
// Check specifically for ai_patrol feature using canonical license constant
hasPatrolFeature := h.aiService.HasLicenseFeature(license.FeatureAIPatrol)
response := PatrolStatusResponse{ response := PatrolStatusResponse{
Running: status.Running, Running: status.Running,
Enabled: h.aiService.IsEnabled(), Enabled: h.aiService.IsEnabled(),
@ -1999,6 +2015,11 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http
FindingsCount: status.FindingsCount, FindingsCount: status.FindingsCount,
Healthy: status.Healthy, Healthy: status.Healthy,
IntervalMs: status.IntervalMs, IntervalMs: status.IntervalMs,
LicenseRequired: !hasPatrolFeature,
LicenseStatus: licenseStatus,
}
if !hasPatrolFeature {
response.UpgradeURL = "https://pulsemonitor.app/pro"
} }
response.Summary.Critical = summary.Critical response.Summary.Critical = summary.Critical
response.Summary.Warning = summary.Warning response.Summary.Warning = summary.Warning