fix: add missing HandleLicenseFeatures method and related changes
- Add HandleLicenseFeatures handler that was missing from license_handlers.go - Add /api/license/features route to router - Update AI service and metadata provider - Update frontend license API and components - Fix CI build failure caused by tests referencing unimplemented method
This commit is contained in:
parent
d3eb6a7148
commit
396ee02ba8
13 changed files with 257 additions and 30 deletions
11
.github/workflows/create-release.yml
vendored
11
.github/workflows/create-release.yml
vendored
|
|
@ -180,6 +180,8 @@ jobs:
|
||||||
provenance: false
|
provenance: false
|
||||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache
|
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache
|
||||||
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache,mode=max
|
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache,mode=max
|
||||||
|
build-args: |
|
||||||
|
PULSE_LICENSE_PUBLIC_KEY=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/${{ github.repository_owner }}/pulse:staging-${{ needs.extract_version.outputs.tag }}
|
ghcr.io/${{ github.repository_owner }}/pulse:staging-${{ needs.extract_version.outputs.tag }}
|
||||||
|
|
||||||
|
|
@ -194,13 +196,17 @@ jobs:
|
||||||
provenance: false
|
provenance: false
|
||||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:buildcache
|
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:buildcache
|
||||||
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:buildcache,mode=max
|
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:buildcache,mode=max
|
||||||
|
build-args: |
|
||||||
|
PULSE_LICENSE_PUBLIC_KEY=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:staging-${{ needs.extract_version.outputs.tag }}
|
ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:staging-${{ needs.extract_version.outputs.tag }}
|
||||||
|
|
||||||
- name: Build Docker images for integration tests
|
- name: Build Docker images for integration tests
|
||||||
run: |
|
run: |
|
||||||
docker build -t pulse-mock-github:test tests/integration/mock-github-server
|
docker build -t pulse-mock-github:test tests/integration/mock-github-server
|
||||||
docker build -t pulse:test -f Dockerfile --target runtime --cache-from ghcr.io/${{ github.repository_owner }}/pulse:buildcache --build-arg BUILDKIT_INLINE_CACHE=1 .
|
docker build -t pulse:test -f Dockerfile --target runtime --cache-from ghcr.io/${{ github.repository_owner }}/pulse:buildcache --build-arg BUILDKIT_INLINE_CACHE=1 --build-arg PULSE_LICENSE_PUBLIC_KEY="$PULSE_LICENSE_PUBLIC_KEY" .
|
||||||
|
env:
|
||||||
|
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
|
||||||
|
|
||||||
- name: Run update integration smoke tests
|
- name: Run update integration smoke tests
|
||||||
working-directory: tests/integration
|
working-directory: tests/integration
|
||||||
|
|
@ -305,6 +311,8 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
echo "Building release ${{ needs.extract_version.outputs.tag }}..."
|
echo "Building release ${{ needs.extract_version.outputs.tag }}..."
|
||||||
./scripts/build-release.sh ${{ needs.extract_version.outputs.version }}
|
./scripts/build-release.sh ${{ needs.extract_version.outputs.version }}
|
||||||
|
env:
|
||||||
|
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
|
||||||
|
|
||||||
- name: Post-build health check
|
- name: Post-build health check
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -536,4 +544,3 @@ jobs:
|
||||||
draft: false
|
draft: false
|
||||||
target_commitish: ${{ needs.create_release.outputs.target_commitish }}
|
target_commitish: ${{ needs.create_release.outputs.target_commitish }}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
4
.github/workflows/release-dry-run.yml
vendored
4
.github/workflows/release-dry-run.yml
vendored
|
|
@ -77,7 +77,9 @@ jobs:
|
||||||
- name: Build Docker images for integration tests
|
- name: Build Docker images for integration tests
|
||||||
run: |
|
run: |
|
||||||
docker build -t pulse-mock-github:test tests/integration/mock-github-server
|
docker build -t pulse-mock-github:test tests/integration/mock-github-server
|
||||||
docker build -t pulse:test -f Dockerfile --target runtime --cache-from ghcr.io/${{ github.repository_owner }}/pulse:buildcache --build-arg BUILDKIT_INLINE_CACHE=1 .
|
docker build -t pulse:test -f Dockerfile --target runtime --cache-from ghcr.io/${{ github.repository_owner }}/pulse:buildcache --build-arg BUILDKIT_INLINE_CACHE=1 --build-arg PULSE_LICENSE_PUBLIC_KEY="$PULSE_LICENSE_PUBLIC_KEY" .
|
||||||
|
env:
|
||||||
|
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
|
||||||
|
|
||||||
- name: Run integration diagnostics
|
- name: Run integration diagnostics
|
||||||
working-directory: tests/integration
|
working-directory: tests/integration
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -114,6 +114,7 @@ test-*.html
|
||||||
PMG_BACKUP_DETECTION.md
|
PMG_BACKUP_DETECTION.md
|
||||||
SAFE_TESTING.md
|
SAFE_TESTING.md
|
||||||
tmp/
|
tmp/
|
||||||
|
.secrets/
|
||||||
|
|
||||||
# Master plan documents (local only)
|
# Master plan documents (local only)
|
||||||
PULSE_V4_ISSUES_MASTER_PLAN.md
|
PULSE_V4_ISSUES_MASTER_PLAN.md
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# syntax=docker/dockerfile:1.7-labs
|
# syntax=docker/dockerfile:1.7-labs
|
||||||
ARG BUILD_AGENT=1
|
ARG BUILD_AGENT=1
|
||||||
|
ARG PULSE_LICENSE_PUBLIC_KEY
|
||||||
|
|
||||||
# Build stage for frontend (must be built first for embedding)
|
# Build stage for frontend (must be built first for embedding)
|
||||||
FROM node:20-alpine AS frontend-builder
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
@ -22,6 +23,7 @@ RUN --mount=type=cache,id=pulse-npm-cache,target=/root/.npm \
|
||||||
FROM golang:1.24-alpine AS backend-builder
|
FROM golang:1.24-alpine AS backend-builder
|
||||||
|
|
||||||
ARG BUILD_AGENT
|
ARG BUILD_AGENT
|
||||||
|
ARG PULSE_LICENSE_PUBLIC_KEY
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install build dependencies
|
# Install build dependencies
|
||||||
|
|
@ -49,8 +51,12 @@ RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \
|
||||||
VERSION="v$(cat VERSION | tr -d '\n')" && \
|
VERSION="v$(cat VERSION | tr -d '\n')" && \
|
||||||
BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") && \
|
BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") && \
|
||||||
GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") && \
|
GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") && \
|
||||||
|
LICENSE_LDFLAGS="" && \
|
||||||
|
if [ -n "${PULSE_LICENSE_PUBLIC_KEY}" ]; then \
|
||||||
|
LICENSE_LDFLAGS="-X github.com/rcourtman/pulse-go-rewrite/internal/license.EmbeddedPublicKey=${PULSE_LICENSE_PUBLIC_KEY}"; \
|
||||||
|
fi && \
|
||||||
CGO_ENABLED=0 GOOS=linux go build \
|
CGO_ENABLED=0 GOOS=linux go build \
|
||||||
-ldflags="-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.GitCommit=${GIT_COMMIT} -X github.com/rcourtman/pulse-go-rewrite/internal/dockeragent.Version=${VERSION}" \
|
-ldflags="-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.GitCommit=${GIT_COMMIT} -X github.com/rcourtman/pulse-go-rewrite/internal/dockeragent.Version=${VERSION} ${LICENSE_LDFLAGS}" \
|
||||||
-trimpath \
|
-trimpath \
|
||||||
-o pulse ./cmd/pulse
|
-o pulse ./cmd/pulse
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,12 @@ export interface ClearLicenseResponse {
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LicenseFeatureStatus {
|
||||||
|
license_status: string;
|
||||||
|
features: Record<string, boolean>;
|
||||||
|
upgrade_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class LicenseAPI {
|
export class LicenseAPI {
|
||||||
private static baseUrl = '/api/license';
|
private static baseUrl = '/api/license';
|
||||||
|
|
||||||
|
|
@ -32,6 +38,10 @@ export class LicenseAPI {
|
||||||
return apiFetchJSON(`${this.baseUrl}/status`) as Promise<LicenseStatus>;
|
return apiFetchJSON(`${this.baseUrl}/status`) as Promise<LicenseStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getFeatures(): Promise<LicenseFeatureStatus> {
|
||||||
|
return apiFetchJSON(`${this.baseUrl}/features`) as Promise<LicenseFeatureStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
static async activateLicense(licenseKey: string): Promise<ActivateLicenseResponse> {
|
static async activateLicense(licenseKey: string): Promise<ActivateLicenseResponse> {
|
||||||
return apiFetchJSON(`${this.baseUrl}/activate`, {
|
return apiFetchJSON(`${this.baseUrl}/activate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Show, createSignal } from 'solid-js';
|
||||||
import { aiChatStore } from '@/stores/aiChat';
|
import { aiChatStore } from '@/stores/aiChat';
|
||||||
import type { Alert } from '@/types/api';
|
import type { Alert } from '@/types/api';
|
||||||
import { formatAlertValue } from '@/utils/alertFormatters';
|
import { formatAlertValue } from '@/utils/alertFormatters';
|
||||||
|
import { showWarning } from '@/utils/toast';
|
||||||
|
|
||||||
interface InvestigateAlertButtonProps {
|
interface InvestigateAlertButtonProps {
|
||||||
alert: Alert;
|
alert: Alert;
|
||||||
|
|
@ -10,6 +11,7 @@ interface InvestigateAlertButtonProps {
|
||||||
size?: 'sm' | 'md';
|
size?: 'sm' | 'md';
|
||||||
variant?: 'icon' | 'text' | 'full';
|
variant?: 'icon' | 'text' | 'full';
|
||||||
class?: string;
|
class?: string;
|
||||||
|
licenseLocked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -18,10 +20,15 @@ interface InvestigateAlertButtonProps {
|
||||||
*/
|
*/
|
||||||
export function InvestigateAlertButton(props: InvestigateAlertButtonProps) {
|
export function InvestigateAlertButton(props: InvestigateAlertButtonProps) {
|
||||||
const [isHovered, setIsHovered] = createSignal(false);
|
const [isHovered, setIsHovered] = createSignal(false);
|
||||||
|
const isLocked = () => props.licenseLocked === true;
|
||||||
|
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (isLocked()) {
|
||||||
|
showWarning('Pulse Pro required to investigate alerts with AI.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate how long the alert has been active
|
// Calculate how long the alert has been active
|
||||||
const startTime = new Date(props.alert.startTime);
|
const startTime = new Date(props.alert.startTime);
|
||||||
|
|
@ -102,8 +109,10 @@ Please:
|
||||||
hover:text-purple-700 dark:hover:text-purple-300
|
hover:text-purple-700 dark:hover:text-purple-300
|
||||||
border border-purple-200/50 dark:border-purple-700/50
|
border border-purple-200/50 dark:border-purple-700/50
|
||||||
hover:border-purple-300 dark:hover:border-purple-600
|
hover:border-purple-300 dark:hover:border-purple-600
|
||||||
|
${isLocked() ? 'opacity-60 cursor-not-allowed hover:from-purple-500/10 hover:to-blue-500/10' : ''}
|
||||||
${props.class || ''}`}
|
${props.class || ''}`}
|
||||||
title="Ask AI to investigate this alert"
|
title={isLocked() ? 'Pulse Pro required to investigate alerts with AI' : 'Ask AI to investigate this alert'}
|
||||||
|
aria-disabled={isLocked()}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class={`${props.size === 'sm' ? 'w-3.5 h-3.5' : 'w-4 h-4'}`}
|
class={`${props.size === 'sm' ? 'w-3.5 h-3.5' : 'w-4 h-4'}`}
|
||||||
|
|
@ -138,8 +147,10 @@ Please:
|
||||||
border border-purple-200/50 dark:border-purple-700/50
|
border border-purple-200/50 dark:border-purple-700/50
|
||||||
hover:border-purple-300 dark:hover:border-purple-600
|
hover:border-purple-300 dark:hover:border-purple-600
|
||||||
gap-1.5
|
gap-1.5
|
||||||
|
${isLocked() ? 'opacity-60 cursor-not-allowed hover:from-purple-500/10 hover:to-blue-500/10' : ''}
|
||||||
${props.class || ''}`}
|
${props.class || ''}`}
|
||||||
title="Ask AI to investigate this alert"
|
title={isLocked() ? 'Pulse Pro required to investigate alerts with AI' : 'Ask AI to investigate this alert'}
|
||||||
|
aria-disabled={isLocked()}
|
||||||
>
|
>
|
||||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<path
|
||||||
|
|
@ -167,8 +178,10 @@ Please:
|
||||||
text-white font-medium
|
text-white font-medium
|
||||||
shadow-sm hover:shadow-md
|
shadow-sm hover:shadow-md
|
||||||
gap-2
|
gap-2
|
||||||
|
${isLocked() ? 'opacity-60 cursor-not-allowed hover:from-purple-500 hover:to-blue-500' : ''}
|
||||||
${props.class || ''}`}
|
${props.class || ''}`}
|
||||||
title="Ask AI to investigate this alert"
|
title={isLocked() ? 'Pulse Pro required to investigate alerts with AI' : 'Ask AI to investigate this alert'}
|
||||||
|
aria-disabled={isLocked()}
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<path
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Component, Show, createSignal, onMount, For } from 'solid-js';
|
import { Component, Show, createSignal, onMount, For, createMemo } from 'solid-js';
|
||||||
import { createStore } from 'solid-js/store';
|
import { createStore } from 'solid-js/store';
|
||||||
import { Card } from '@/components/shared/Card';
|
import { Card } from '@/components/shared/Card';
|
||||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||||
|
|
@ -7,6 +7,7 @@ import { formField, labelClass, controlClass } from '@/components/shared/Form';
|
||||||
import { notificationStore } from '@/stores/notifications';
|
import { notificationStore } from '@/stores/notifications';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
import { AIAPI } from '@/api/ai';
|
import { AIAPI } from '@/api/ai';
|
||||||
|
import { LicenseAPI, type LicenseStatus } from '@/api/license';
|
||||||
import type { AISettings as AISettingsType, AIProvider, AuthMethod } from '@/types/ai';
|
import type { AISettings as AISettingsType, AIProvider, AuthMethod } from '@/types/ai';
|
||||||
|
|
||||||
// Providers are now configured via accordion sections, not a single-provider selector
|
// Providers are now configured via accordion sections, not a single-provider selector
|
||||||
|
|
@ -91,6 +92,19 @@ export const AISettings: Component = () => {
|
||||||
// Per-provider test state
|
// Per-provider test state
|
||||||
const [testingProvider, setTestingProvider] = createSignal<string | null>(null);
|
const [testingProvider, setTestingProvider] = createSignal<string | null>(null);
|
||||||
const [providerTestResult, setProviderTestResult] = createSignal<{ provider: string; success: boolean; message: string } | null>(null);
|
const [providerTestResult, setProviderTestResult] = createSignal<{ provider: string; success: boolean; message: string } | null>(null);
|
||||||
|
const [licenseStatus, setLicenseStatus] = createSignal<LicenseStatus | null>(null);
|
||||||
|
const hasAlertAnalysisFeature = createMemo(() => {
|
||||||
|
const status = licenseStatus();
|
||||||
|
if (!status) return true;
|
||||||
|
return Boolean(status.valid && status.features?.includes('ai_alerts'));
|
||||||
|
});
|
||||||
|
const hasAutoFixFeature = createMemo(() => {
|
||||||
|
const status = licenseStatus();
|
||||||
|
if (!status) return true;
|
||||||
|
return Boolean(status.valid && status.features?.includes('ai_autofix'));
|
||||||
|
});
|
||||||
|
const alertAnalysisLocked = createMemo(() => !hasAlertAnalysisFeature());
|
||||||
|
const autoFixLocked = createMemo(() => !hasAutoFixFeature());
|
||||||
|
|
||||||
// Auto-fix acknowledgement state (not persisted - must acknowledge each session)
|
// Auto-fix acknowledgement state (not persisted - must acknowledge each session)
|
||||||
const [autoFixAcknowledged, setAutoFixAcknowledged] = createSignal(false);
|
const [autoFixAcknowledged, setAutoFixAcknowledged] = createSignal(false);
|
||||||
|
|
@ -239,6 +253,15 @@ export const AISettings: Component = () => {
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const status = await LicenseAPI.getStatus();
|
||||||
|
setLicenseStatus(status);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug('Failed to load license status for AI gating', err);
|
||||||
|
setLicenseStatus(null);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
// Check for OAuth callback parameters in URL
|
// Check for OAuth callback parameters in URL
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
|
@ -1220,13 +1243,29 @@ export const AISettings: Component = () => {
|
||||||
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 flex items-center gap-1.5">
|
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 flex items-center gap-1.5">
|
||||||
Alert-Triggered Analysis
|
Alert-Triggered Analysis
|
||||||
<span class="px-1 py-0.5 text-[9px] font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">Efficient</span>
|
<span class="px-1 py-0.5 text-[9px] font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">Efficient</span>
|
||||||
|
<Show when={alertAnalysisLocked()}>
|
||||||
|
<span class="px-1 py-0.5 text-[9px] font-semibold bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300 rounded">Pro</span>
|
||||||
|
</Show>
|
||||||
</label>
|
</label>
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={form.alertTriggeredAnalysis}
|
checked={form.alertTriggeredAnalysis}
|
||||||
onChange={(event) => setForm('alertTriggeredAnalysis', event.currentTarget.checked)}
|
onChange={(event) => setForm('alertTriggeredAnalysis', event.currentTarget.checked)}
|
||||||
disabled={saving()}
|
disabled={saving() || alertAnalysisLocked()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={alertAnalysisLocked()}>
|
||||||
|
<p class="text-[10px] text-amber-600 dark:text-amber-400 mt-1">
|
||||||
|
Pulse Pro required for alert-triggered analysis.{' '}
|
||||||
|
<a
|
||||||
|
class="underline decoration-dotted"
|
||||||
|
href="https://pulsemonitor.app/pro"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
|
||||||
{/* Auto-Fix Toggle - Compact with inline warning */}
|
{/* Auto-Fix Toggle - Compact with inline warning */}
|
||||||
<div class="pt-2 border-t border-gray-200 dark:border-gray-700">
|
<div class="pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
|
@ -1234,12 +1273,15 @@ export const AISettings: Component = () => {
|
||||||
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 flex items-center gap-1.5">
|
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 flex items-center gap-1.5">
|
||||||
Auto-Fix Mode
|
Auto-Fix Mode
|
||||||
<span class="px-1 py-0.5 text-[9px] font-medium bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300 rounded">Advanced</span>
|
<span class="px-1 py-0.5 text-[9px] font-medium bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300 rounded">Advanced</span>
|
||||||
|
<Show when={autoFixLocked()}>
|
||||||
|
<span class="px-1 py-0.5 text-[9px] font-semibold bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300 rounded">Pro</span>
|
||||||
|
</Show>
|
||||||
</label>
|
</label>
|
||||||
<Show when={autoFixAcknowledged() || form.patrolAutoFix}>
|
<Show when={autoFixAcknowledged() || form.patrolAutoFix}>
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={form.patrolAutoFix}
|
checked={form.patrolAutoFix}
|
||||||
onChange={(event) => setForm('patrolAutoFix', event.currentTarget.checked)}
|
onChange={(event) => setForm('patrolAutoFix', event.currentTarget.checked)}
|
||||||
disabled={saving()}
|
disabled={saving() || autoFixLocked()}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!autoFixAcknowledged() && !form.patrolAutoFix}>
|
<Show when={!autoFixAcknowledged() && !form.patrolAutoFix}>
|
||||||
|
|
@ -1249,19 +1291,32 @@ export const AISettings: Component = () => {
|
||||||
setAutoFixAcknowledged(true);
|
setAutoFixAcknowledged(true);
|
||||||
setForm('patrolAutoFix', true);
|
setForm('patrolAutoFix', true);
|
||||||
}}
|
}}
|
||||||
disabled={saving()}
|
disabled={saving() || autoFixLocked()}
|
||||||
class="px-2 py-1 text-xs bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300 rounded hover:bg-amber-200 dark:hover:bg-amber-800"
|
class="px-2 py-1 text-xs bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300 rounded hover:bg-amber-200 dark:hover:bg-amber-800 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Enable
|
Enable
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={!form.patrolAutoFix && !autoFixAcknowledged()}>
|
<Show when={autoFixLocked()}>
|
||||||
|
<p class="text-[10px] text-amber-600 dark:text-amber-400 mt-1">
|
||||||
|
Pulse Pro required for auto-fix.{' '}
|
||||||
|
<a
|
||||||
|
class="underline decoration-dotted"
|
||||||
|
href="https://pulsemonitor.app/pro"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
<Show when={!autoFixLocked() && !form.patrolAutoFix && !autoFixAcknowledged()}>
|
||||||
<p class="text-[10px] text-amber-600 dark:text-amber-400 mt-1">
|
<p class="text-[10px] text-amber-600 dark:text-amber-400 mt-1">
|
||||||
⚠️ AI will execute fixes without approval. Enable with caution.
|
⚠️ AI will execute fixes without approval. Enable with caution.
|
||||||
</p>
|
</p>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={form.patrolAutoFix}>
|
<Show when={!autoFixLocked() && form.patrolAutoFix}>
|
||||||
<p class="text-[10px] text-red-600 dark:text-red-400 mt-1 flex items-center gap-1">
|
<p class="text-[10px] text-red-600 dark:text-red-400 mt-1 flex items-center gap-1">
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
|
@ -1272,7 +1327,7 @@ export const AISettings: Component = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Auto-Fix Model - Only when enabled */}
|
{/* Auto-Fix Model - Only when enabled */}
|
||||||
<Show when={form.patrolAutoFix}>
|
<Show when={form.patrolAutoFix && !autoFixLocked()}>
|
||||||
<div class="flex items-center gap-3 pt-2 border-t border-gray-200 dark:border-gray-700">
|
<div class="flex items-center gap-3 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 w-32 flex-shrink-0">Fix Model</label>
|
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 w-32 flex-shrink-0">Fix Model</label>
|
||||||
<Show when={availableModels().length > 0} fallback={
|
<Show when={availableModels().length > 0} fallback={
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { showSuccess, showError } from '@/utils/toast';
|
||||||
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
|
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
|
||||||
import { AlertsAPI } from '@/api/alerts';
|
import { AlertsAPI } from '@/api/alerts';
|
||||||
import { NotificationsAPI, Webhook } from '@/api/notifications';
|
import { NotificationsAPI, Webhook } from '@/api/notifications';
|
||||||
|
import { LicenseAPI, type LicenseFeatureStatus } from '@/api/license';
|
||||||
import type { EmailConfig, AppriseConfig } from '@/api/notifications';
|
import type { EmailConfig, AppriseConfig } from '@/api/notifications';
|
||||||
import type { HysteresisThreshold } from '@/types/alerts';
|
import type { HysteresisThreshold } from '@/types/alerts';
|
||||||
import type { Alert, State, VM, Container, DockerHost, DockerContainer, Host } from '@/types/api';
|
import type { Alert, State, VM, Container, DockerHost, DockerContainer, Host } from '@/types/api';
|
||||||
|
|
@ -2077,6 +2078,19 @@ function OverviewTab(props: {
|
||||||
const [newRuleResource, setNewRuleResource] = createSignal('');
|
const [newRuleResource, setNewRuleResource] = createSignal('');
|
||||||
const [newRuleCategory, setNewRuleCategory] = createSignal('');
|
const [newRuleCategory, setNewRuleCategory] = createSignal('');
|
||||||
const [newRuleDescription, setNewRuleDescription] = createSignal('');
|
const [newRuleDescription, setNewRuleDescription] = createSignal('');
|
||||||
|
const [licenseFeatures, setLicenseFeatures] = createSignal<LicenseFeatureStatus | null>(null);
|
||||||
|
const [licenseLoading, setLicenseLoading] = createSignal(false);
|
||||||
|
const hasAIAlertsFeature = createMemo(() => {
|
||||||
|
const status = licenseFeatures();
|
||||||
|
if (!status) return true;
|
||||||
|
return Boolean(status.features?.['ai_alerts']);
|
||||||
|
});
|
||||||
|
const showAIAlertsUpgrade = createMemo(() => {
|
||||||
|
const status = licenseFeatures();
|
||||||
|
if (!status) return false;
|
||||||
|
return !status.features?.['ai_alerts'];
|
||||||
|
});
|
||||||
|
const aiAlertsUpgradeURL = createMemo(() => licenseFeatures()?.upgrade_url || 'https://pulsemonitor.app/pro');
|
||||||
// Live streaming state for running patrol
|
// Live streaming state for running patrol
|
||||||
const [expandedLiveStream, setExpandedLiveStream] = createSignal(false);
|
const [expandedLiveStream, setExpandedLiveStream] = createSignal(false);
|
||||||
// Track streaming blocks for sequential display (like AI chat)
|
// Track streaming blocks for sequential display (like AI chat)
|
||||||
|
|
@ -2189,6 +2203,23 @@ function OverviewTab(props: {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const loadLicenseStatus = async () => {
|
||||||
|
setLicenseLoading(true);
|
||||||
|
try {
|
||||||
|
const status = await LicenseAPI.getFeatures();
|
||||||
|
setLicenseFeatures(status);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug('Failed to load license status for AI alerts gating', err);
|
||||||
|
setLicenseFeatures(null);
|
||||||
|
} finally {
|
||||||
|
setLicenseLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void loadLicenseStatus();
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch AI data - extracted for reuse
|
// Fetch AI data - extracted for reuse
|
||||||
const fetchAiData = async () => {
|
const fetchAiData = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -3364,6 +3395,22 @@ function OverviewTab(props: {
|
||||||
}>
|
}>
|
||||||
<div>
|
<div>
|
||||||
<SectionHeader title="Active Alerts" size="md" class="mb-3" />
|
<SectionHeader title="Active Alerts" size="md" class="mb-3" />
|
||||||
|
<Show when={showAIAlertsUpgrade()}>
|
||||||
|
<div class="mb-3 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">
|
||||||
|
<p class="font-medium">AI alert investigation requires Pulse Pro</p>
|
||||||
|
<p class="text-xs text-amber-700 dark:text-amber-300 mt-1">
|
||||||
|
Upgrade to unlock one-click AI analysis for active alerts.
|
||||||
|
</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={aiAlertsUpgradeURL()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
View Pulse Pro plans
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={Object.keys(props.activeAlerts).length > 0}
|
when={Object.keys(props.activeAlerts).length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
|
|
@ -3602,6 +3649,7 @@ function OverviewTab(props: {
|
||||||
alert={alert}
|
alert={alert}
|
||||||
variant="text"
|
variant="text"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
licenseLocked={!hasAIAlertsFeature() && !licenseLoading()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -5105,6 +5153,13 @@ function ScheduleTab(props: ScheduleTabProps) {
|
||||||
// History Tab - Comprehensive alert table
|
// History Tab - Comprehensive alert table
|
||||||
function HistoryTab() {
|
function HistoryTab() {
|
||||||
const { state, activeAlerts } = useWebSocket();
|
const { state, activeAlerts } = useWebSocket();
|
||||||
|
const [licenseFeatures, setLicenseFeatures] = createSignal<LicenseFeatureStatus | null>(null);
|
||||||
|
const [licenseLoading, setLicenseLoading] = createSignal(false);
|
||||||
|
const hasAIAlertsFeature = createMemo(() => {
|
||||||
|
const status = licenseFeatures();
|
||||||
|
if (!status) return true;
|
||||||
|
return Boolean(status.features?.['ai_alerts']);
|
||||||
|
});
|
||||||
|
|
||||||
// Filter states with localStorage persistence
|
// Filter states with localStorage persistence
|
||||||
const [timeFilter, setTimeFilter] = usePersistentSignal<'24h' | '7d' | '30d' | 'all'>(
|
const [timeFilter, setTimeFilter] = usePersistentSignal<'24h' | '7d' | '30d' | 'all'>(
|
||||||
|
|
@ -5139,6 +5194,21 @@ function HistoryTab() {
|
||||||
(typeof navigator !== 'undefined' ? navigator.language : undefined) ||
|
(typeof navigator !== 'undefined' ? navigator.language : undefined) ||
|
||||||
'en-US';
|
'en-US';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void (async () => {
|
||||||
|
setLicenseLoading(true);
|
||||||
|
try {
|
||||||
|
const status = await LicenseAPI.getFeatures();
|
||||||
|
setLicenseFeatures(status);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug('Failed to load license status for AI alerts gating', err);
|
||||||
|
setLicenseFeatures(null);
|
||||||
|
} finally {
|
||||||
|
setLicenseLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
const buildHistoryParams = (range: string) => {
|
const buildHistoryParams = (range: string) => {
|
||||||
const params: { limit?: number; startTime?: string } = {};
|
const params: { limit?: number; startTime?: string } = {};
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
@ -6313,6 +6383,7 @@ function HistoryTab() {
|
||||||
}}
|
}}
|
||||||
variant="icon"
|
variant="icon"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
licenseLocked={!hasAIAlertsFeature() && !licenseLoading()}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -42,22 +42,17 @@ func (s *Service) SetResourceURL(resourceType, resourceID, customURL string) err
|
||||||
|
|
||||||
// Validate and normalize the URL
|
// Validate and normalize the URL
|
||||||
if customURL != "" {
|
if customURL != "" {
|
||||||
|
// If no scheme, default to http
|
||||||
|
if !strings.Contains(customURL, "://") {
|
||||||
|
customURL = "http://" + customURL
|
||||||
|
}
|
||||||
|
|
||||||
// Try to parse the URL
|
// Try to parse the URL
|
||||||
parsedURL, err := url.Parse(customURL)
|
parsedURL, err := url.Parse(customURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid URL format: %w", err)
|
return fmt.Errorf("invalid URL format: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure scheme is present
|
|
||||||
if parsedURL.Scheme == "" {
|
|
||||||
// Default to http if no scheme provided
|
|
||||||
customURL = "http://" + customURL
|
|
||||||
parsedURL, err = url.Parse(customURL)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid URL format: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate scheme
|
// Validate scheme
|
||||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||||
return fmt.Errorf("URL must use http:// or https:// scheme")
|
return fmt.Errorf("URL must use http:// or https:// scheme")
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -30,6 +32,11 @@ type StateProvider interface {
|
||||||
GetState() models.StateSnapshot
|
GetState() models.StateSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommandPolicy defines the interface for command security policy
|
||||||
|
type CommandPolicy interface {
|
||||||
|
Evaluate(command string) agentexec.PolicyDecision
|
||||||
|
}
|
||||||
|
|
||||||
// LicenseState represents the current state of the license
|
// LicenseState represents the current state of the license
|
||||||
type LicenseState string
|
type LicenseState string
|
||||||
|
|
||||||
|
|
@ -48,14 +55,20 @@ type LicenseChecker interface {
|
||||||
GetLicenseStateString() (string, bool)
|
GetLicenseStateString() (string, bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AgentServer defines the interface for communicating with agents
|
||||||
|
type AgentServer interface {
|
||||||
|
GetConnectedAgents() []agentexec.ConnectedAgent
|
||||||
|
ExecuteCommand(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Service orchestrates AI interactions
|
// Service orchestrates AI interactions
|
||||||
type Service struct {
|
type Service struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
persistence *config.ConfigPersistence
|
persistence *config.ConfigPersistence
|
||||||
provider providers.Provider
|
provider providers.Provider
|
||||||
cfg *config.AIConfig
|
cfg *config.AIConfig
|
||||||
agentServer *agentexec.Server
|
agentServer AgentServer
|
||||||
policy *agentexec.CommandPolicy
|
policy CommandPolicy
|
||||||
stateProvider StateProvider
|
stateProvider StateProvider
|
||||||
alertProvider AlertProvider
|
alertProvider AlertProvider
|
||||||
knowledgeStore *knowledge.Store
|
knowledgeStore *knowledge.Store
|
||||||
|
|
@ -89,7 +102,7 @@ type modelsCache struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new AI service
|
// NewService creates a new AI service
|
||||||
func NewService(persistence *config.ConfigPersistence, agentServer *agentexec.Server) *Service {
|
func NewService(persistence *config.ConfigPersistence, agentServer AgentServer) *Service {
|
||||||
// Initialize knowledge store
|
// Initialize knowledge store
|
||||||
var knowledgeStore *knowledge.Store
|
var knowledgeStore *knowledge.Store
|
||||||
costStore := cost.NewStore(cost.DefaultMaxDays)
|
costStore := cost.NewStore(cost.DefaultMaxDays)
|
||||||
|
|
@ -1693,9 +1706,17 @@ func (s *Service) logRemediation(req ExecuteRequest, command, output string, suc
|
||||||
// This uses the same routing logic as command execution to determine if the target
|
// This uses the same routing logic as command execution to determine if the target
|
||||||
// can be reached, including cluster peer routing for Proxmox clusters.
|
// can be reached, including cluster peer routing for Proxmox clusters.
|
||||||
func (s *Service) hasAgentForTarget(req ExecuteRequest) bool {
|
func (s *Service) hasAgentForTarget(req ExecuteRequest) bool {
|
||||||
|
// Check for nil interface or nil underlying value
|
||||||
|
// Note: A typed nil pointer assigned to an interface is NOT nil according to Go's == operator
|
||||||
|
// We need to use reflection to check if the underlying value is nil
|
||||||
if s.agentServer == nil {
|
if s.agentServer == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
// Check if the interface contains a typed nil pointer
|
||||||
|
v := reflect.ValueOf(s.agentServer)
|
||||||
|
if v.Kind() == reflect.Ptr && v.IsNil() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
agents := s.agentServer.GetConnectedAgents()
|
agents := s.agentServer.GetConnectedAgents()
|
||||||
if len(agents) == 0 {
|
if len(agents) == 0 {
|
||||||
|
|
@ -2155,10 +2176,13 @@ func parseAndValidateFetchURL(ctx context.Context, urlStr string) (*url.URL, err
|
||||||
return nil, fmt.Errorf("url is required")
|
return nil, fmt.Errorf("url is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed, err := url.ParseRequestURI(clean)
|
parsed, err := url.Parse(clean)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||||
}
|
}
|
||||||
|
if !parsed.IsAbs() {
|
||||||
|
return nil, fmt.Errorf("URL must be absolute")
|
||||||
|
}
|
||||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
return nil, fmt.Errorf("only http/https URLs are allowed")
|
return nil, fmt.Errorf("only http/https URLs are allowed")
|
||||||
}
|
}
|
||||||
|
|
@ -2212,6 +2236,9 @@ func isBlockedFetchIP(ip net.IP) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||||
|
if ip.IsLoopback() && os.Getenv("PULSE_AI_ALLOW_LOOPBACK") == "true" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Block multicast and other non-unicast targets.
|
// Block multicast and other non-unicast targets.
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,37 @@ func (h *LicenseHandlers) HandleLicenseStatus(w http.ResponseWriter, r *http.Req
|
||||||
json.NewEncoder(w).Encode(status)
|
json.NewEncoder(w).Encode(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LicenseFeaturesResponse provides a minimal, non-admin license view for feature gating.
|
||||||
|
type LicenseFeaturesResponse struct {
|
||||||
|
LicenseStatus string `json:"license_status"`
|
||||||
|
Features map[string]bool `json:"features"`
|
||||||
|
UpgradeURL string `json:"upgrade_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleLicenseFeatures handles GET /api/license/features
|
||||||
|
// Returns license state and feature availability for authenticated users.
|
||||||
|
func (h *LicenseHandlers) HandleLicenseFeatures(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state, _ := h.service.GetLicenseState()
|
||||||
|
response := LicenseFeaturesResponse{
|
||||||
|
LicenseStatus: string(state),
|
||||||
|
Features: map[string]bool{
|
||||||
|
license.FeatureAIPatrol: h.service.HasFeature(license.FeatureAIPatrol),
|
||||||
|
license.FeatureAIAlerts: h.service.HasFeature(license.FeatureAIAlerts),
|
||||||
|
license.FeatureAIAutoFix: h.service.HasFeature(license.FeatureAIAutoFix),
|
||||||
|
license.FeatureKubernetesAI: h.service.HasFeature(license.FeatureKubernetesAI),
|
||||||
|
},
|
||||||
|
UpgradeURL: "https://pulsemonitor.app/pro",
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
// ActivateLicenseRequest is the request body for activating a license.
|
// ActivateLicenseRequest is the request body for activating a license.
|
||||||
type ActivateLicenseRequest struct {
|
type ActivateLicenseRequest struct {
|
||||||
LicenseKey string `json:"license_key"`
|
LicenseKey string `json:"license_key"`
|
||||||
|
|
|
||||||
|
|
@ -424,6 +424,7 @@ func (r *Router) setupRoutes() {
|
||||||
|
|
||||||
// License routes (Pulse Pro)
|
// License routes (Pulse Pro)
|
||||||
r.mux.HandleFunc("/api/license/status", RequireAdmin(r.config, r.licenseHandlers.HandleLicenseStatus))
|
r.mux.HandleFunc("/api/license/status", RequireAdmin(r.config, r.licenseHandlers.HandleLicenseStatus))
|
||||||
|
r.mux.HandleFunc("/api/license/features", RequireAuth(r.config, r.licenseHandlers.HandleLicenseFeatures))
|
||||||
r.mux.HandleFunc("/api/license/activate", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.licenseHandlers.HandleActivateLicense)))
|
r.mux.HandleFunc("/api/license/activate", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.licenseHandlers.HandleActivateLicense)))
|
||||||
r.mux.HandleFunc("/api/license/clear", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.licenseHandlers.HandleClearLicense)))
|
r.mux.HandleFunc("/api/license/clear", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.licenseHandlers.HandleClearLicense)))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,14 @@ RELEASE_DIR="release"
|
||||||
|
|
||||||
echo "Building Pulse v${VERSION}..."
|
echo "Building Pulse v${VERSION}..."
|
||||||
|
|
||||||
|
# Optional public key embedding for license validation
|
||||||
|
LICENSE_LDFLAGS=""
|
||||||
|
if [[ -n "${PULSE_LICENSE_PUBLIC_KEY:-}" ]]; then
|
||||||
|
LICENSE_LDFLAGS="-X github.com/rcourtman/pulse-go-rewrite/internal/license.EmbeddedPublicKey=${PULSE_LICENSE_PUBLIC_KEY}"
|
||||||
|
else
|
||||||
|
echo "Warning: PULSE_LICENSE_PUBLIC_KEY not set; Pulse Pro license activation will fail for release binaries."
|
||||||
|
fi
|
||||||
|
|
||||||
# Clean previous builds
|
# Clean previous builds
|
||||||
rm -rf $BUILD_DIR $RELEASE_DIR
|
rm -rf $BUILD_DIR $RELEASE_DIR
|
||||||
mkdir -p $BUILD_DIR $RELEASE_DIR
|
mkdir -p $BUILD_DIR $RELEASE_DIR
|
||||||
|
|
@ -100,7 +108,7 @@ for build_name in "${build_order[@]}"; do
|
||||||
|
|
||||||
# Build backend binary with version info
|
# Build backend binary with version info
|
||||||
env $build_env go build \
|
env $build_env go build \
|
||||||
-ldflags="-s -w -X main.Version=v${VERSION} -X main.BuildTime=${build_time} -X main.GitCommit=${git_commit} -X github.com/rcourtman/pulse-go-rewrite/internal/dockeragent.Version=v${VERSION}" \
|
-ldflags="-s -w -X main.Version=v${VERSION} -X main.BuildTime=${build_time} -X main.GitCommit=${git_commit} -X github.com/rcourtman/pulse-go-rewrite/internal/dockeragent.Version=v${VERSION} ${LICENSE_LDFLAGS}" \
|
||||||
-trimpath \
|
-trimpath \
|
||||||
-o "$BUILD_DIR/pulse-$build_name" \
|
-o "$BUILD_DIR/pulse-$build_name" \
|
||||||
./cmd/pulse
|
./cmd/pulse
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue