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

View file

@ -49,7 +49,17 @@ export function AIStatusIndicator() {
const tooltipText = createMemo(() => {
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';
const parts: string[] = [];

View file

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

View file

@ -2088,6 +2088,25 @@ function OverviewTab(props: {
const [liveStreamBlocks, setLiveStreamBlocks] = createSignal<StreamBlock[]>([]);
const [currentThinking, setCurrentThinking] = createSignal('');
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
createEffect(() => {
@ -2460,8 +2479,10 @@ function OverviewTab(props: {
<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"
onClick={() => handleForcePatrol(true)}
disabled={forcePatrolLoading() || patrolStatus()?.running}
title={patrolStatus()?.running ? 'Patrol in progress - see table below' : 'Run a patrol check now'}
disabled={forcePatrolLoading() || patrolStatus()?.running || patrolRequiresLicense()}
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">
<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>
</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 */}
<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">
@ -2506,34 +2551,35 @@ function OverviewTab(props: {
</div>
</Show>
</div>
<div class="space-y-2">
<Show
when={aiFindings().length > 0}
fallback={
<div class="text-center py-6 border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div class="flex justify-center mb-2">
<svg class="w-10 h-10 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4" />
</svg>
<Show when={!patrolRequiresLicense()}>
<div class="space-y-2">
<Show
when={aiFindings().length > 0}
fallback={
<div class="text-center py-6 border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div class="flex justify-center mb-2">
<svg class="w-10 h-10 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" />
<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>
<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];
const [isExpanded, setIsExpanded] = createSignal(false);
return (
<div
class="border rounded-lg transition-all"
style={{
'background-color': colors.bg,
'border-color': colors.border,
}}
>
}
>
<For each={aiFindings().filter(f => !pendingFixFindings().has(f.id))}>
{(finding) => {
const colors = severityColors[finding.severity];
const [isExpanded, setIsExpanded] = createSignal(false);
return (
<div
class="border rounded-lg transition-all"
style={{
'background-color': colors.bg,
'border-color': colors.border,
}}
>
{/* Compact header - always visible, clickable */}
<div
class="flex items-center gap-3 p-3 cursor-pointer hover:opacity-80"
@ -2767,36 +2813,38 @@ function OverviewTab(props: {
</div>
</Show>
</div>
);
}}
</For>
</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>
);
}}
</For>
</Show>
</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 */}
<Show when={showAddRuleForm()}>
@ -2921,29 +2969,31 @@ function OverviewTab(props: {
)}
</For>
</div>
</Show>
</div>
</Show>
</div>
</Show>
{/* Patrol Check History */}
<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={() => setShowRunHistory(!showRunHistory())}
>
<svg
class={`w-4 h-4 transition-transform ${showRunHistory() ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<Show when={!patrolRequiresLicense()}>
{/* Patrol Check History */}
<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={() => setShowRunHistory(!showRunHistory())}
>
<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>
<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>
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 */}
<Show when={nextPatrolIn()}>
@ -3303,8 +3353,9 @@ function OverviewTab(props: {
</div>
</Show>
</div>
</Show>
</div>
</Show>
</div>
</Show>
</div>
</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/providers"
"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/rs/zerolog/log"
)
@ -136,6 +137,11 @@ func (h *AISettingsHandler) GetAlertTriggeredAnalyzer() *ai.AlertTriggeredAnalyz
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
// API keys are masked for security
type AISettingsResponse struct {
@ -1957,7 +1963,11 @@ type PatrolStatusResponse struct {
FindingsCount int `json:"findings_count"`
Healthy bool `json:"healthy"`
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"`
Warning int `json:"warning"`
Watch int `json:"watch"`
@ -1989,6 +1999,12 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http
status := patrol.GetStatus()
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{
Running: status.Running,
Enabled: h.aiService.IsEnabled(),
@ -1999,6 +2015,11 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http
FindingsCount: status.FindingsCount,
Healthy: status.Healthy,
IntervalMs: status.IntervalMs,
LicenseRequired: !hasPatrolFeature,
LicenseStatus: licenseStatus,
}
if !hasPatrolFeature {
response.UpgradeURL = "https://pulsemonitor.app/pro"
}
response.Summary.Critical = summary.Critical
response.Summary.Warning = summary.Warning