mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
feat: Add centralized agent configuration management (Pro)
Allows administrators to create configuration profiles and assign them to agents for centralized fleet management. - Configuration profiles with customizable settings (Docker, K8s, Proxmox monitoring, log level, reporting interval) - Profile assignment to agents by ID - Agent-side remote config client to fetch settings on startup - Full CRUD API at /api/admin/profiles - Settings UI panel in Settings → Agents → Agent Profiles - Automatic cleanup of assignments when profiles are deleted
This commit is contained in:
112
docs/CENTRALIZED_MANAGEMENT.md
Normal file
112
docs/CENTRALIZED_MANAGEMENT.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Centralized Agent Management (Pulse Pro)
|
||||
|
||||
Pulse Pro supports centralized management of agent configurations, allowing administrators to define "Configuration Profiles" and assign them to specific agents. This enables bulk updates and consistent configuration across your fleet without manually editing configuration files on each host.
|
||||
|
||||
Profiles are managed in the UI: **Settings → Agents → Agent Profiles**.
|
||||
|
||||
## Concepts
|
||||
|
||||
- **Agent Profile**: A named collection of configuration settings (e.g., "Production Servers", "Debug Mode").
|
||||
- **Assignment**: A link between a specific Agent ID and an Agent Profile.
|
||||
- **Precedence**: Server-side profile settings override local agent flags/environment for supported keys.
|
||||
|
||||
## Supported Configuration Keys
|
||||
|
||||
The following settings can be controlled remotely via profiles:
|
||||
|
||||
| Key | Type | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `enable_docker` | boolean | Enable/Disable Docker monitoring |
|
||||
| `enable_kubernetes` | boolean | Enable/Disable Kubernetes monitoring |
|
||||
| `enable_proxmox` | boolean | Enable/Disable Proxmox monitoring |
|
||||
| `proxmox_type` | string | Set Proxmox type (`pve` or `pbs`) |
|
||||
| `log_level` | string | Set agent log level (`debug`, `info`, `warn`, `error`) |
|
||||
| `interval` | string | Set reporting interval (e.g., "30s", "1m") |
|
||||
|
||||
Notes:
|
||||
- `interval` accepts a duration string. If you send a JSON number, it is interpreted as seconds.
|
||||
- Docker auto-detection can still enable Docker monitoring if the agent is not explicitly configured. To force-disable Docker, set `PULSE_ENABLE_DOCKER=false` or install with `--disable-docker` on the host.
|
||||
- `commandsEnabled` (AI command execution) is controlled separately per agent in **Settings → Agents → Unified Agents** and is applied live on report. It is not part of profile settings.
|
||||
|
||||
## API Usage
|
||||
|
||||
All endpoints require **Admin** authentication and a **Pulse Pro** license.
|
||||
|
||||
### 1. Create a Profile
|
||||
|
||||
```http
|
||||
POST /api/admin/profiles/
|
||||
Authorization: Bearer <admin-token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Production Servers",
|
||||
"config": {
|
||||
"enable_docker": true,
|
||||
"log_level": "info",
|
||||
"interval": "60s"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Assign Profile to Agent
|
||||
|
||||
You need the Agent ID (typically the machine ID, visible in the Pulse UI or agent logs).
|
||||
|
||||
```http
|
||||
POST /api/admin/profiles/assignments
|
||||
Authorization: Bearer <admin-token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"agent_id": "01234567-89ab-cdef-0123-456789abcdef",
|
||||
"profile_id": "prod-servers"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. List Profiles
|
||||
|
||||
```http
|
||||
GET /api/admin/profiles/
|
||||
Authorization: Bearer <admin-token>
|
||||
```
|
||||
|
||||
### 4. List Assignments
|
||||
|
||||
```http
|
||||
GET /api/admin/profiles/assignments
|
||||
Authorization: Bearer <admin-token>
|
||||
```
|
||||
|
||||
### 5. Unassign Profile
|
||||
|
||||
```http
|
||||
DELETE /api/admin/profiles/assignments/{agent_id}
|
||||
Authorization: Bearer <admin-token>
|
||||
```
|
||||
|
||||
### 6. Get Agent Config (Debugging)
|
||||
|
||||
To see what configuration an agent receives:
|
||||
|
||||
```http
|
||||
GET /api/agents/host/{agent_id}/config
|
||||
Authorization: Bearer <agent-or-admin-token>
|
||||
```
|
||||
|
||||
## Agent Behavior
|
||||
|
||||
1. On startup, the agent computes its Agent ID.
|
||||
2. It contacts the Pulse server to fetch its configuration profile.
|
||||
3. If successful, it applies the remote settings, overriding local flags/env for supported keys.
|
||||
4. If the server is unreachable or returns an error, the agent proceeds with its local configuration.
|
||||
5. Profile changes take effect on the next agent restart. Command execution toggles are applied dynamically.
|
||||
|
||||
## Storage
|
||||
|
||||
Profiles and assignments are stored in the Pulse config directory:
|
||||
|
||||
- `agent_profiles.json`
|
||||
- `agent_profile_assignments.json`
|
||||
|
||||
Deleting a profile automatically removes its assignments.
|
||||
@@ -1176,13 +1176,22 @@ function AppLayout(props: {
|
||||
const hasSettingsAccess = !scopes || scopes.length === 0 ||
|
||||
scopes.includes('*') || scopes.includes('settings:read');
|
||||
|
||||
const tabs = [
|
||||
const tabs: Array<{
|
||||
id: 'alerts' | 'settings';
|
||||
label: string;
|
||||
route: string;
|
||||
tooltip: string;
|
||||
badge: 'update' | null;
|
||||
count: number | undefined;
|
||||
breakdown: { warning: number; critical: number } | undefined;
|
||||
icon: JSX.Element;
|
||||
}> = [
|
||||
{
|
||||
id: 'alerts' as const,
|
||||
id: 'alerts',
|
||||
label: 'Alerts',
|
||||
route: '/alerts',
|
||||
tooltip: 'Review active alerts and automation rules',
|
||||
badge: null as 'update' | null,
|
||||
badge: null,
|
||||
count: activeAlertCount,
|
||||
breakdown,
|
||||
icon: <BellIcon class="w-4 h-4 shrink-0" />,
|
||||
@@ -1192,11 +1201,11 @@ function AppLayout(props: {
|
||||
// Only show settings tab if user has access
|
||||
if (hasSettingsAccess) {
|
||||
tabs.push({
|
||||
id: 'settings' as const,
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
route: '/settings',
|
||||
tooltip: 'Configure Pulse preferences and integrations',
|
||||
badge: updateStore.isUpdateVisible() ? ('update' as const) : null,
|
||||
badge: updateStore.isUpdateVisible() ? 'update' : null,
|
||||
count: undefined,
|
||||
breakdown: undefined,
|
||||
icon: <SettingsIcon class="w-4 h-4 shrink-0" />,
|
||||
@@ -1385,12 +1394,12 @@ function AppLayout(props: {
|
||||
}
|
||||
return (
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{tab.breakdown?.critical > 0 && (
|
||||
{tab.breakdown && tab.breakdown.critical > 0 && (
|
||||
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-bold text-white bg-red-600 dark:bg-red-500 rounded-full">
|
||||
{tab.breakdown.critical}
|
||||
</span>
|
||||
)}
|
||||
{tab.breakdown?.warning > 0 && (
|
||||
{tab.breakdown && tab.breakdown.warning > 0 && (
|
||||
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-semibold text-amber-900 dark:text-amber-100 bg-amber-200 dark:bg-amber-500/80 rounded-full">
|
||||
{tab.breakdown.warning}
|
||||
</span>
|
||||
|
||||
154
frontend-modern/src/api/agentProfiles.ts
Normal file
154
frontend-modern/src/api/agentProfiles.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { apiFetch, apiFetchJSON } from '@/utils/apiClient';
|
||||
|
||||
/**
|
||||
* Agent profile for centralized configuration management.
|
||||
*/
|
||||
export interface AgentProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assignment linking an agent to a profile.
|
||||
*/
|
||||
export interface AgentProfileAssignment {
|
||||
agent_id: string;
|
||||
profile_id: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API client for agent profiles (Pro feature).
|
||||
* Endpoints are gated behind license - returns 402 if not licensed.
|
||||
*/
|
||||
export class AgentProfilesAPI {
|
||||
private static baseUrl = '/api/admin/profiles';
|
||||
|
||||
/**
|
||||
* List all agent profiles.
|
||||
*/
|
||||
static async listProfiles(): Promise<AgentProfile[]> {
|
||||
try {
|
||||
const response = await apiFetchJSON<AgentProfile[]>(this.baseUrl);
|
||||
return response || [];
|
||||
} catch (err) {
|
||||
// Handle 402 gracefully - means not licensed
|
||||
if (err instanceof Error && err.message.includes('402')) {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single profile by ID.
|
||||
*/
|
||||
static async getProfile(id: string): Promise<AgentProfile | null> {
|
||||
try {
|
||||
return await apiFetchJSON<AgentProfile>(`${this.baseUrl}/${encodeURIComponent(id)}`);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('404')) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new profile.
|
||||
*/
|
||||
static async createProfile(name: string, config: Record<string, unknown>): Promise<AgentProfile> {
|
||||
const response = await apiFetch(this.baseUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, config }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Failed to create profile: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing profile.
|
||||
*/
|
||||
static async updateProfile(id: string, name: string, config: Record<string, unknown>): Promise<AgentProfile> {
|
||||
const response = await apiFetch(`${this.baseUrl}/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, name, config }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Failed to update profile: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a profile.
|
||||
*/
|
||||
static async deleteProfile(id: string): Promise<void> {
|
||||
const response = await apiFetch(`${this.baseUrl}/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Failed to delete profile: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all profile assignments.
|
||||
*/
|
||||
static async listAssignments(): Promise<AgentProfileAssignment[]> {
|
||||
try {
|
||||
const response = await apiFetchJSON<AgentProfileAssignment[]>(`${this.baseUrl}/assignments`);
|
||||
return response || [];
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('402')) {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a profile to an agent.
|
||||
*/
|
||||
static async assignProfile(agentId: string, profileId: string): Promise<void> {
|
||||
const response = await apiFetch(`${this.baseUrl}/assignments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ agent_id: agentId, profile_id: profileId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Failed to assign profile: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove profile assignment from an agent.
|
||||
*/
|
||||
static async unassignProfile(agentId: string): Promise<void> {
|
||||
const response = await apiFetch(`${this.baseUrl}/assignments/${encodeURIComponent(agentId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Failed to unassign profile: ${response.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
583
frontend-modern/src/components/Settings/AgentProfilesPanel.tsx
Normal file
583
frontend-modern/src/components/Settings/AgentProfilesPanel.tsx
Normal file
@@ -0,0 +1,583 @@
|
||||
import { Component, createSignal, createMemo, onMount, Show, For } from 'solid-js';
|
||||
import { useWebSocket } from '@/App';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { AgentProfilesAPI, type AgentProfile, type AgentProfileAssignment } from '@/api/agentProfiles';
|
||||
import { LicenseAPI } from '@/api/license';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import Plus from 'lucide-solid/icons/plus';
|
||||
import Pencil from 'lucide-solid/icons/pencil';
|
||||
import Trash2 from 'lucide-solid/icons/trash-2';
|
||||
import Crown from 'lucide-solid/icons/crown';
|
||||
import Users from 'lucide-solid/icons/users';
|
||||
import Settings from 'lucide-solid/icons/settings';
|
||||
|
||||
// Known agent settings with their types and descriptions
|
||||
interface BooleanSetting {
|
||||
key: string;
|
||||
type: 'boolean';
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface SelectSetting {
|
||||
key: string;
|
||||
type: 'select';
|
||||
label: string;
|
||||
description: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
interface DurationSetting {
|
||||
key: string;
|
||||
type: 'duration';
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
type KnownSetting = BooleanSetting | SelectSetting | DurationSetting;
|
||||
|
||||
const KNOWN_SETTINGS: KnownSetting[] = [
|
||||
{ key: 'enable_docker', type: 'boolean', label: 'Enable Docker Monitoring', description: 'Monitor Docker containers on this agent' },
|
||||
{ key: 'enable_kubernetes', type: 'boolean', label: 'Enable Kubernetes Monitoring', description: 'Monitor Kubernetes workloads' },
|
||||
{ key: 'enable_proxmox', type: 'boolean', label: 'Enable Proxmox Mode', description: 'Auto-detect and configure Proxmox API access' },
|
||||
{ key: 'log_level', type: 'select', label: 'Log Level', description: 'Agent logging verbosity', options: ['debug', 'info', 'warn', 'error'] },
|
||||
{ key: 'interval', type: 'duration', label: 'Reporting Interval', description: 'How often the agent reports metrics (e.g., 30s, 1m)' },
|
||||
];
|
||||
|
||||
export const AgentProfilesPanel: Component = () => {
|
||||
const { state } = useWebSocket();
|
||||
|
||||
// License state
|
||||
const [hasFeature, setHasFeature] = createSignal(false);
|
||||
const [checkingLicense, setCheckingLicense] = createSignal(true);
|
||||
|
||||
// Data state
|
||||
const [profiles, setProfiles] = createSignal<AgentProfile[]>([]);
|
||||
const [assignments, setAssignments] = createSignal<AgentProfileAssignment[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
|
||||
// Modal state
|
||||
const [showModal, setShowModal] = createSignal(false);
|
||||
const [editingProfile, setEditingProfile] = createSignal<AgentProfile | null>(null);
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
|
||||
// Form state
|
||||
const [formName, setFormName] = createSignal('');
|
||||
const [formSettings, setFormSettings] = createSignal<Record<string, unknown>>({});
|
||||
|
||||
// Connected agents from WebSocket state
|
||||
const connectedAgents = createMemo(() => {
|
||||
const hosts = state.hosts || [];
|
||||
return hosts.map(h => ({
|
||||
id: h.id,
|
||||
hostname: h.hostname || 'Unknown',
|
||||
displayName: h.displayName,
|
||||
status: h.status || 'unknown',
|
||||
lastSeen: h.lastSeen,
|
||||
}));
|
||||
});
|
||||
|
||||
// Get assignment for a specific agent
|
||||
const getAgentAssignment = (agentId: string) => {
|
||||
return assignments().find(a => a.agent_id === agentId);
|
||||
};
|
||||
|
||||
// Get profile by ID
|
||||
const getProfileById = (profileId: string) => {
|
||||
return profiles().find(p => p.id === profileId);
|
||||
};
|
||||
|
||||
// Count agents assigned to a profile
|
||||
const getAssignmentCount = (profileId: string) => {
|
||||
return assignments().filter(a => a.profile_id === profileId).length;
|
||||
};
|
||||
|
||||
// Count settings in a profile
|
||||
const getSettingsCount = (profile: AgentProfile) => {
|
||||
return Object.keys(profile.config || {}).length;
|
||||
};
|
||||
|
||||
// Load data
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [profilesData, assignmentsData] = await Promise.all([
|
||||
AgentProfilesAPI.listProfiles(),
|
||||
AgentProfilesAPI.listAssignments(),
|
||||
]);
|
||||
setProfiles(profilesData);
|
||||
setAssignments(assignmentsData);
|
||||
} catch (err) {
|
||||
logger.error('Failed to load agent profiles', err);
|
||||
notificationStore.error('Failed to load agent profiles');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Check license on mount
|
||||
onMount(async () => {
|
||||
try {
|
||||
const features = await LicenseAPI.getFeatures();
|
||||
setHasFeature(features.features?.['agent_profiles'] === true);
|
||||
} catch (err) {
|
||||
logger.error('Failed to check license', err);
|
||||
setHasFeature(false);
|
||||
} finally {
|
||||
setCheckingLicense(false);
|
||||
}
|
||||
|
||||
if (hasFeature()) {
|
||||
await loadData();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Open modal for creating a new profile
|
||||
const handleCreate = () => {
|
||||
setEditingProfile(null);
|
||||
setFormName('');
|
||||
setFormSettings({});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// Open modal for editing a profile
|
||||
const handleEdit = (profile: AgentProfile) => {
|
||||
setEditingProfile(profile);
|
||||
setFormName(profile.name);
|
||||
setFormSettings({ ...profile.config });
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// Delete a profile
|
||||
const handleDelete = async (profile: AgentProfile) => {
|
||||
const assignedCount = getAssignmentCount(profile.id);
|
||||
const confirmMsg = assignedCount > 0
|
||||
? `Delete "${profile.name}"? ${assignedCount} agent(s) will be unassigned.`
|
||||
: `Delete "${profile.name}"?`;
|
||||
|
||||
if (!confirm(confirmMsg)) return;
|
||||
|
||||
try {
|
||||
await AgentProfilesAPI.deleteProfile(profile.id);
|
||||
notificationStore.success(`Profile "${profile.name}" deleted`);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete profile', err);
|
||||
notificationStore.error('Failed to delete profile');
|
||||
}
|
||||
};
|
||||
|
||||
// Save profile (create or update)
|
||||
const handleSave = async () => {
|
||||
const name = formName().trim();
|
||||
if (!name) {
|
||||
notificationStore.error('Profile name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const config = formSettings();
|
||||
const existing = editingProfile();
|
||||
|
||||
if (existing) {
|
||||
await AgentProfilesAPI.updateProfile(existing.id, name, config);
|
||||
notificationStore.success(`Profile "${name}" updated`);
|
||||
} else {
|
||||
await AgentProfilesAPI.createProfile(name, config);
|
||||
notificationStore.success(`Profile "${name}" created`);
|
||||
}
|
||||
|
||||
setShowModal(false);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
logger.error('Failed to save profile', err);
|
||||
notificationStore.error('Failed to save profile');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Assign profile to agent
|
||||
const handleAssign = async (agentId: string, profileId: string) => {
|
||||
try {
|
||||
if (profileId === '') {
|
||||
await AgentProfilesAPI.unassignProfile(agentId);
|
||||
notificationStore.success('Profile unassigned');
|
||||
} else {
|
||||
await AgentProfilesAPI.assignProfile(agentId, profileId);
|
||||
const profile = getProfileById(profileId);
|
||||
notificationStore.success(`Assigned "${profile?.name || profileId}"`);
|
||||
}
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
logger.error('Failed to assign profile', err);
|
||||
notificationStore.error('Failed to assign profile');
|
||||
}
|
||||
};
|
||||
|
||||
// Update a setting in the form
|
||||
const updateSetting = (key: string, value: unknown) => {
|
||||
if (value === '' || value === undefined) {
|
||||
const updated = { ...formSettings() };
|
||||
delete updated[key];
|
||||
setFormSettings(updated);
|
||||
} else {
|
||||
setFormSettings({ ...formSettings(), [key]: value });
|
||||
}
|
||||
};
|
||||
|
||||
// Render license gate
|
||||
if (checkingLicense()) {
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500" />
|
||||
<span class="ml-3 text-gray-600 dark:text-gray-400">Checking license...</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasFeature()) {
|
||||
return (
|
||||
<Card padding="lg" class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-900/30">
|
||||
<Crown class="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Agent Profiles</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Pro feature</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Create reusable configuration profiles for your agents. Manage settings like Docker monitoring,
|
||||
logging levels, and reporting intervals from a central location.
|
||||
</p>
|
||||
<a
|
||||
href="https://www.yourpulse.io/pro"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 px-4 py-2 text-sm font-medium text-white transition-all hover:from-amber-600 hover:to-orange-600"
|
||||
>
|
||||
<Crown class="w-4 h-4" />
|
||||
Upgrade to Pro
|
||||
</a>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Profiles Section */}
|
||||
<Card padding="lg" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
||||
<Settings class="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Configuration Profiles</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Reusable agent configurations</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
New Profile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={loading()}>
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500" />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && profiles().length === 0}>
|
||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<Settings class="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p class="text-sm">No profiles yet. Create one to get started.</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && profiles().length > 0}>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Name</th>
|
||||
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Settings</th>
|
||||
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Agents</th>
|
||||
<th class="text-right py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={profiles()}>
|
||||
{(profile) => (
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<td class="py-3 px-3">
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{profile.name}</span>
|
||||
</td>
|
||||
<td class="py-3 px-3">
|
||||
<span class="text-gray-600 dark:text-gray-400">{getSettingsCount(profile)}</span>
|
||||
</td>
|
||||
<td class="py-3 px-3">
|
||||
<span class="inline-flex items-center gap-1 text-gray-600 dark:text-gray-400">
|
||||
<Users class="w-4 h-4" />
|
||||
{getAssignmentCount(profile.id)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 px-3 text-right">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEdit(profile)}
|
||||
class="p-1.5 rounded-md text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/30"
|
||||
title="Edit profile"
|
||||
>
|
||||
<Pencil class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(profile)}
|
||||
class="p-1.5 rounded-md text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/30"
|
||||
title="Delete profile"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
|
||||
{/* Assignments Section */}
|
||||
<Card padding="lg" class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-green-100 dark:bg-green-900/30">
|
||||
<Users class="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Agent Assignments</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Assign profiles to connected agents</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={connectedAgents().length === 0}>
|
||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<Users class="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p class="text-sm">No agents connected. Install an agent to assign profiles.</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={connectedAgents().length > 0}>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Agent</th>
|
||||
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Profile</th>
|
||||
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Status</th>
|
||||
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={connectedAgents()}>
|
||||
{(agent) => {
|
||||
const assignment = () => getAgentAssignment(agent.id);
|
||||
const isOnline = () => agent.status?.toLowerCase() === 'online' || agent.status?.toLowerCase() === 'running';
|
||||
|
||||
return (
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<td class="py-3 px-3">
|
||||
<div>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{agent.displayName || agent.hostname}
|
||||
</span>
|
||||
<Show when={agent.displayName && agent.hostname !== agent.displayName}>
|
||||
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
({agent.hostname})
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-3">
|
||||
<select
|
||||
value={assignment()?.profile_id || ''}
|
||||
onChange={(e) => handleAssign(agent.id, e.currentTarget.value)}
|
||||
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
>
|
||||
<option value="">No profile</option>
|
||||
<For each={profiles()}>
|
||||
{(profile) => (
|
||||
<option value={profile.id}>{profile.name}</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</td>
|
||||
<td class="py-3 px-3">
|
||||
<span class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${isOnline()
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
||||
}`}>
|
||||
{isOnline() ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 px-3 text-gray-600 dark:text-gray-400">
|
||||
{agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'Never'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
|
||||
{/* Profile Modal */}
|
||||
<Show when={showModal()}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div class="w-full max-w-lg bg-white dark:bg-gray-900 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 mx-4">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{editingProfile() ? 'Edit Profile' : 'New Profile'}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
class="p-1.5 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 space-y-4 max-h-[60vh] overflow-y-auto">
|
||||
{/* Profile Name */}
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Profile Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formName()}
|
||||
onInput={(e) => setFormName(e.currentTarget.value)}
|
||||
placeholder="e.g., Production Servers"
|
||||
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-800/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<div class="space-y-3">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Settings
|
||||
</label>
|
||||
|
||||
<For each={KNOWN_SETTINGS}>
|
||||
{(setting) => (
|
||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700 p-3 space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{setting.label}
|
||||
</label>
|
||||
<Show when={setting.type === 'boolean'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = formSettings()[setting.key];
|
||||
if (current === undefined) {
|
||||
updateSetting(setting.key, true);
|
||||
} else if (current === true) {
|
||||
updateSetting(setting.key, false);
|
||||
} else {
|
||||
updateSetting(setting.key, undefined);
|
||||
}
|
||||
}}
|
||||
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${formSettings()[setting.key] === true
|
||||
? 'bg-blue-600'
|
||||
: formSettings()[setting.key] === false
|
||||
? 'bg-gray-400'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
class={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${formSettings()[setting.key] === true
|
||||
? 'translate-x-6'
|
||||
: formSettings()[setting.key] === false
|
||||
? 'translate-x-1'
|
||||
: 'translate-x-3'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={setting.type === 'select'}>
|
||||
<select
|
||||
value={(formSettings()[setting.key] as string) || ''}
|
||||
onChange={(e) => updateSetting(setting.key, e.currentTarget.value || undefined)}
|
||||
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<For each={(setting as SelectSetting).options}>
|
||||
{(opt) => <option value={opt}>{opt}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</Show>
|
||||
<Show when={setting.type === 'duration'}>
|
||||
<input
|
||||
type="text"
|
||||
value={(formSettings()[setting.key] as string) || ''}
|
||||
onInput={(e) => updateSetting(setting.key, e.currentTarget.value || undefined)}
|
||||
placeholder="30s"
|
||||
class="w-24 rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{setting.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving() || !formName().trim()}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{saving() ? 'Saving...' : editingProfile() ? 'Update Profile' : 'Create Profile'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentProfilesPanel;
|
||||
316
internal/api/config_profiles.go
Normal file
316
internal/api/config_profiles.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// ConfigProfileHandler handles configuration profile operations
|
||||
type ConfigProfileHandler struct {
|
||||
persistence *config.ConfigPersistence
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewConfigProfileHandler creates a new handler
|
||||
func NewConfigProfileHandler(persistence *config.ConfigPersistence) *ConfigProfileHandler {
|
||||
return &ConfigProfileHandler{
|
||||
persistence: persistence,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP implements the http.Handler interface
|
||||
func (h *ConfigProfileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Simple routing
|
||||
path := strings.TrimSuffix(r.URL.Path, "/")
|
||||
|
||||
if path == "" || path == "/" {
|
||||
if r.Method == http.MethodGet {
|
||||
h.ListProfiles(w, r)
|
||||
return
|
||||
} else if r.Method == http.MethodPost {
|
||||
h.CreateProfile(w, r)
|
||||
return
|
||||
}
|
||||
} else if path == "/assignments" {
|
||||
if r.Method == http.MethodGet {
|
||||
h.ListAssignments(w, r)
|
||||
return
|
||||
} else if r.Method == http.MethodPost {
|
||||
h.AssignProfile(w, r)
|
||||
return
|
||||
}
|
||||
} else if strings.HasPrefix(path, "/assignments/") {
|
||||
if r.Method == http.MethodDelete {
|
||||
agentID := strings.TrimPrefix(path, "/assignments/")
|
||||
h.UnassignProfile(w, r, agentID)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// ID parameters
|
||||
// Expecting /{id}
|
||||
id := strings.TrimPrefix(path, "/")
|
||||
if r.Method == http.MethodPut {
|
||||
h.UpdateProfile(w, r, id)
|
||||
return
|
||||
} else if r.Method == http.MethodDelete {
|
||||
h.DeleteProfile(w, r, id)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
// ListProfiles returns all profiles
|
||||
func (h *ConfigProfileHandler) ListProfiles(w http.ResponseWriter, r *http.Request) {
|
||||
profiles, err := h.persistence.LoadAgentProfiles()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to load profiles")
|
||||
http.Error(w, "Failed to load profiles", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Return empty array instead of null
|
||||
if profiles == nil {
|
||||
profiles = []models.AgentProfile{}
|
||||
}
|
||||
json.NewEncoder(w).Encode(profiles)
|
||||
}
|
||||
|
||||
// CreateProfile creates a new profile
|
||||
func (h *ConfigProfileHandler) CreateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var input models.AgentProfile
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if input.Name == "" {
|
||||
http.Error(w, "Name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
profiles, err := h.persistence.LoadAgentProfiles()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to load profiles", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
input.ID = uuid.New().String()
|
||||
input.CreatedAt = time.Now()
|
||||
input.UpdatedAt = time.Now()
|
||||
|
||||
profiles = append(profiles, input)
|
||||
|
||||
if err := h.persistence.SaveAgentProfiles(profiles); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save profiles")
|
||||
http.Error(w, "Failed to save profile", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(input)
|
||||
}
|
||||
|
||||
// UpdateProfile updates an existing profile
|
||||
func (h *ConfigProfileHandler) UpdateProfile(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if id == "" {
|
||||
http.Error(w, "ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var input models.AgentProfile
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
profiles, err := h.persistence.LoadAgentProfiles()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to load profiles", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, p := range profiles {
|
||||
if p.ID == id {
|
||||
profiles[i].Name = input.Name
|
||||
profiles[i].Config = input.Config
|
||||
profiles[i].UpdatedAt = time.Now()
|
||||
input = profiles[i]
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
http.Error(w, "Profile not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.persistence.SaveAgentProfiles(profiles); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save profiles")
|
||||
http.Error(w, "Failed to save profile", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(input)
|
||||
}
|
||||
|
||||
// DeleteProfile deletes a profile
|
||||
func (h *ConfigProfileHandler) DeleteProfile(w http.ResponseWriter, r *http.Request, id string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
profiles, err := h.persistence.LoadAgentProfiles()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to load profiles", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
newProfiles := []models.AgentProfile{}
|
||||
for _, p := range profiles {
|
||||
if p.ID != id {
|
||||
newProfiles = append(newProfiles, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(newProfiles) == len(profiles) {
|
||||
http.Error(w, "Profile not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.persistence.SaveAgentProfiles(newProfiles); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save profiles")
|
||||
http.Error(w, "Failed to delete profile", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
assignments, err := h.persistence.LoadAgentProfileAssignments()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to load assignments for profile cleanup")
|
||||
http.Error(w, "Failed to delete profile assignments", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
cleaned := assignments[:0]
|
||||
for _, a := range assignments {
|
||||
if a.ProfileID != id {
|
||||
cleaned = append(cleaned, a)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cleaned) != len(assignments) {
|
||||
if err := h.persistence.SaveAgentProfileAssignments(cleaned); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to clean up assignments for deleted profile")
|
||||
http.Error(w, "Failed to delete profile assignments", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// ListAssignments returns all assignments
|
||||
func (h *ConfigProfileHandler) ListAssignments(w http.ResponseWriter, r *http.Request) {
|
||||
assignments, err := h.persistence.LoadAgentProfileAssignments()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to load assignments")
|
||||
http.Error(w, "Failed to load assignments", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Return empty array instead of null
|
||||
if assignments == nil {
|
||||
assignments = []models.AgentProfileAssignment{}
|
||||
}
|
||||
json.NewEncoder(w).Encode(assignments)
|
||||
}
|
||||
|
||||
// AssignProfile assigns a profile to an agent
|
||||
func (h *ConfigProfileHandler) AssignProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var input models.AgentProfileAssignment
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if input.AgentID == "" || input.ProfileID == "" {
|
||||
http.Error(w, "AgentID and ProfileID are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
assignments, err := h.persistence.LoadAgentProfileAssignments()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to load assignments", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove existing assignment for this agent if exists
|
||||
newAssignments := []models.AgentProfileAssignment{}
|
||||
for _, a := range assignments {
|
||||
if a.AgentID != input.AgentID {
|
||||
newAssignments = append(newAssignments, a)
|
||||
}
|
||||
}
|
||||
|
||||
input.UpdatedAt = time.Now()
|
||||
newAssignments = append(newAssignments, input)
|
||||
|
||||
if err := h.persistence.SaveAgentProfileAssignments(newAssignments); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save assignments")
|
||||
http.Error(w, "Failed to save assignment", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(input)
|
||||
}
|
||||
|
||||
// UnassignProfile removes a profile assignment for an agent.
|
||||
func (h *ConfigProfileHandler) UnassignProfile(w http.ResponseWriter, r *http.Request, agentID string) {
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
if agentID == "" {
|
||||
http.Error(w, "AgentID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
assignments, err := h.persistence.LoadAgentProfileAssignments()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to load assignments", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
newAssignments := assignments[:0]
|
||||
for _, a := range assignments {
|
||||
if a.AgentID != agentID {
|
||||
newAssignments = append(newAssignments, a)
|
||||
}
|
||||
}
|
||||
|
||||
if len(newAssignments) != len(assignments) {
|
||||
if err := h.persistence.SaveAgentProfileAssignments(newAssignments); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save assignments")
|
||||
http.Error(w, "Failed to save assignment", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
236
internal/api/config_profiles_test.go
Normal file
236
internal/api/config_profiles_test.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
)
|
||||
|
||||
func TestConfigProfileHandlers(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
persistence := config.NewConfigPersistence(tempDir)
|
||||
if err := persistence.EnsureConfigDir(); err != nil {
|
||||
t.Fatalf("EnsureConfigDir: %v", err)
|
||||
}
|
||||
|
||||
handler := NewConfigProfileHandler(persistence)
|
||||
|
||||
// 1. List Profiles (Empty)
|
||||
t.Run("ListProfilesEmpty", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var profiles []models.AgentProfile
|
||||
if err := json.NewDecoder(rec.Body).Decode(&profiles); err != nil {
|
||||
t.Fatalf("failed to decode: %v", err)
|
||||
}
|
||||
if len(profiles) != 0 {
|
||||
t.Errorf("expected 0 profiles, got %d", len(profiles))
|
||||
}
|
||||
})
|
||||
|
||||
var profileID string
|
||||
|
||||
// 2. Create Profile
|
||||
t.Run("CreateProfile", func(t *testing.T) {
|
||||
profile := models.AgentProfile{
|
||||
Name: "Test Profile",
|
||||
Config: map[string]interface{}{
|
||||
"log_level": "debug",
|
||||
"interval": "30s",
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(profile)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var created models.AgentProfile
|
||||
if err := json.NewDecoder(rec.Body).Decode(&created); err != nil {
|
||||
t.Fatalf("failed to decode: %v", err)
|
||||
}
|
||||
|
||||
if created.Name != "Test Profile" {
|
||||
t.Errorf("expected name 'Test Profile', got %q", created.Name)
|
||||
}
|
||||
if created.ID == "" {
|
||||
t.Error("expected non-empty ID")
|
||||
}
|
||||
profileID = created.ID
|
||||
})
|
||||
|
||||
// 3. List Profiles (1 Profile)
|
||||
t.Run("ListProfilesOne", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
var profiles []models.AgentProfile
|
||||
json.NewDecoder(rec.Body).Decode(&profiles)
|
||||
if len(profiles) != 1 {
|
||||
t.Errorf("expected 1 profile, got %d", len(profiles))
|
||||
}
|
||||
if profiles[0].ID != profileID {
|
||||
t.Errorf("expected ID %s, got %s", profileID, profiles[0].ID)
|
||||
}
|
||||
})
|
||||
|
||||
// 4. Update Profile
|
||||
t.Run("UpdateProfile", func(t *testing.T) {
|
||||
update := models.AgentProfile{
|
||||
Name: "Updated Profile",
|
||||
Config: map[string]interface{}{
|
||||
"log_level": "info",
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(update)
|
||||
req := httptest.NewRequest(http.MethodPut, "/"+profileID, bytes.NewBuffer(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var updated models.AgentProfile
|
||||
json.NewDecoder(rec.Body).Decode(&updated)
|
||||
if updated.Name != "Updated Profile" {
|
||||
t.Errorf("expected updated name, got %q", updated.Name)
|
||||
}
|
||||
})
|
||||
|
||||
// 5. List Assignments (Empty)
|
||||
t.Run("ListAssignmentsEmpty", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/assignments", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
var assignments []models.AgentProfileAssignment
|
||||
json.NewDecoder(rec.Body).Decode(&assignments)
|
||||
if len(assignments) != 0 {
|
||||
t.Errorf("expected 0 assignments, got %d", len(assignments))
|
||||
}
|
||||
})
|
||||
|
||||
// 6. Assign Profile
|
||||
t.Run("AssignProfile", func(t *testing.T) {
|
||||
assignment := models.AgentProfileAssignment{
|
||||
AgentID: "test-agent",
|
||||
ProfileID: profileID,
|
||||
}
|
||||
body, _ := json.Marshal(assignment)
|
||||
req := httptest.NewRequest(http.MethodPost, "/assignments", bytes.NewBuffer(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var created models.AgentProfileAssignment
|
||||
json.NewDecoder(rec.Body).Decode(&created)
|
||||
if created.AgentID != "test-agent" || created.ProfileID != profileID {
|
||||
t.Errorf("assignment mismatch: %+v", created)
|
||||
}
|
||||
})
|
||||
|
||||
// 7. List Assignments (1 Assignment)
|
||||
t.Run("ListAssignmentsOne", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/assignments", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
var assignments []models.AgentProfileAssignment
|
||||
if err := json.NewDecoder(rec.Body).Decode(&assignments); err != nil {
|
||||
t.Fatalf("failed to decode: %v", err)
|
||||
}
|
||||
if len(assignments) != 1 {
|
||||
t.Errorf("expected 1 assignment, got %d", len(assignments))
|
||||
}
|
||||
})
|
||||
|
||||
// 8. Unassign Profile
|
||||
t.Run("UnassignProfile", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodDelete, "/assignments/test-agent", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected status 204, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/assignments", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
var assignments []models.AgentProfileAssignment
|
||||
if err := json.NewDecoder(rec.Body).Decode(&assignments); err != nil {
|
||||
t.Fatalf("failed to decode: %v", err)
|
||||
}
|
||||
if len(assignments) != 0 {
|
||||
t.Errorf("expected 0 assignments after unassign, got %d", len(assignments))
|
||||
}
|
||||
})
|
||||
|
||||
// 9. Assign Profile (Cleanup on Delete)
|
||||
t.Run("AssignProfileForDeleteCleanup", func(t *testing.T) {
|
||||
assignment := models.AgentProfileAssignment{
|
||||
AgentID: "cleanup-agent",
|
||||
ProfileID: profileID,
|
||||
}
|
||||
body, _ := json.Marshal(assignment)
|
||||
req := httptest.NewRequest(http.MethodPost, "/assignments", bytes.NewBuffer(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
// 10. Delete Profile
|
||||
t.Run("DeleteProfile", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodDelete, "/"+profileID, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Verify deleted
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
var profiles []models.AgentProfile
|
||||
json.NewDecoder(rec.Body).Decode(&profiles)
|
||||
if len(profiles) != 0 {
|
||||
t.Errorf("expected 0 profiles after delete, got %d", len(profiles))
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/assignments", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
var assignments []models.AgentProfileAssignment
|
||||
if err := json.NewDecoder(rec.Body).Decode(&assignments); err != nil {
|
||||
t.Fatalf("failed to decode: %v", err)
|
||||
}
|
||||
if len(assignments) != 0 {
|
||||
t.Errorf("expected 0 assignments after profile delete, got %d", len(assignments))
|
||||
}
|
||||
})
|
||||
}
|
||||
25
internal/models/profiles.go
Normal file
25
internal/models/profiles.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// AgentProfile represents a reusable configuration profile for agents.
|
||||
type AgentProfile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Config AgentConfigMap `json:"config"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AgentConfigMap represents the key-value configuration overrides
|
||||
// (e.g., {"interval": "10s", "enable_docker": true})
|
||||
type AgentConfigMap map[string]interface{}
|
||||
|
||||
// AgentProfileAssignment maps an agent to a profile
|
||||
type AgentProfileAssignment struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
93
internal/monitoring/monitor_profiles_test.go
Normal file
93
internal/monitoring/monitor_profiles_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
)
|
||||
|
||||
func TestGetHostAgentConfig_WithProfiles(t *testing.T) {
|
||||
// Setup temp dir for persistence
|
||||
tmpDir, err := os.MkdirTemp("", "monitor_profiles_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Initialize persistence
|
||||
persistence := config.NewConfigPersistence(tmpDir)
|
||||
|
||||
// Create a profile
|
||||
profile := models.AgentProfile{
|
||||
ID: "profile-1",
|
||||
Name: "Test Profile",
|
||||
Config: map[string]interface{}{
|
||||
"enable_docker": true,
|
||||
"log_level": "debug",
|
||||
},
|
||||
}
|
||||
if err := persistence.SaveAgentProfiles([]models.AgentProfile{profile}); err != nil {
|
||||
t.Fatalf("Failed to save profiles: %v", err)
|
||||
}
|
||||
|
||||
// Create an assignment
|
||||
assignment := models.AgentProfileAssignment{
|
||||
AgentID: "agent-123",
|
||||
ProfileID: "profile-1",
|
||||
}
|
||||
if err := persistence.SaveAgentProfileAssignments([]models.AgentProfileAssignment{assignment}); err != nil {
|
||||
t.Fatalf("Failed to save assignments: %v", err)
|
||||
}
|
||||
|
||||
// Initialize Monitor with persistence
|
||||
m := &Monitor{
|
||||
persistence: persistence,
|
||||
// minimal dependencies
|
||||
config: &config.Config{},
|
||||
}
|
||||
|
||||
// Test Case 1: Agent with assigned profile
|
||||
t.Run("Agent with profile assignment", func(t *testing.T) {
|
||||
cfg := m.GetHostAgentConfig("agent-123")
|
||||
|
||||
if cfg.Settings == nil {
|
||||
t.Fatal("Expected Settings to be non-nil")
|
||||
}
|
||||
|
||||
if val, ok := cfg.Settings["enable_docker"]; !ok || val != true {
|
||||
t.Errorf("Expected enable_docker=true, got %v", val)
|
||||
}
|
||||
|
||||
if val, ok := cfg.Settings["log_level"]; !ok || val != "debug" {
|
||||
t.Errorf("Expected log_level='debug', got %v", val)
|
||||
}
|
||||
})
|
||||
|
||||
// Test Case 2: Agent without assignment
|
||||
t.Run("Agent without assignment", func(t *testing.T) {
|
||||
cfg := m.GetHostAgentConfig("other-agent")
|
||||
|
||||
if len(cfg.Settings) != 0 {
|
||||
t.Errorf("Expected empty Settings for unassigned agent, got %v", cfg.Settings)
|
||||
}
|
||||
})
|
||||
|
||||
// Test Case 3: Agent assigned to non-existent profile
|
||||
t.Run("Agent assigned to missing profile", func(t *testing.T) {
|
||||
badAssignment := models.AgentProfileAssignment{
|
||||
AgentID: "agent-bad",
|
||||
ProfileID: "missing-profile",
|
||||
}
|
||||
if err := persistence.SaveAgentProfileAssignments([]models.AgentProfileAssignment{assignment, badAssignment}); err != nil {
|
||||
t.Fatalf("Failed to save assignments: %v", err)
|
||||
}
|
||||
|
||||
cfg := m.GetHostAgentConfig("agent-bad")
|
||||
|
||||
if len(cfg.Settings) != 0 {
|
||||
t.Errorf("Expected empty Settings for missing profile, got %v", cfg.Settings)
|
||||
}
|
||||
})
|
||||
}
|
||||
156
internal/remoteconfig/client.go
Normal file
156
internal/remoteconfig/client.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package remoteconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// Config holds configuration for the remote config client.
|
||||
type Config struct {
|
||||
PulseURL string
|
||||
APIToken string
|
||||
AgentID string
|
||||
Hostname string
|
||||
InsecureSkipVerify bool
|
||||
Logger zerolog.Logger
|
||||
}
|
||||
|
||||
// Client handles fetching remote configuration from the Pulse server.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Response represents the JSON response from the config endpoint.
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
HostID string `json:"hostId"`
|
||||
Config struct {
|
||||
CommandsEnabled *bool `json:"commandsEnabled,omitempty"`
|
||||
Settings map[string]interface{} `json:"settings,omitempty"`
|
||||
} `json:"config"`
|
||||
}
|
||||
|
||||
// New creates a new remote config client.
|
||||
func New(cfg Config) *Client {
|
||||
if cfg.PulseURL == "" {
|
||||
cfg.PulseURL = "http://localhost:7655"
|
||||
}
|
||||
cfg.PulseURL = strings.TrimRight(cfg.PulseURL, "/")
|
||||
|
||||
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if cfg.InsecureSkipVerify {
|
||||
//nolint:gosec // Insecure mode is explicitly user-controlled.
|
||||
tlsConfig.InsecureSkipVerify = true
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
TLSClientConfig: tlsConfig,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return fmt.Errorf("server returned redirect to %s", req.URL)
|
||||
},
|
||||
}
|
||||
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch retrieves the remote configuration for this agent.
|
||||
// It returns a map of settings to apply, or an error if the fetch fails.
|
||||
// Returns (settings, commandsEnabled, error)
|
||||
func (c *Client) Fetch(ctx context.Context) (map[string]interface{}, *bool, error) {
|
||||
if c.cfg.AgentID == "" {
|
||||
return nil, nil, fmt.Errorf("agent ID is required to fetch remote config")
|
||||
}
|
||||
|
||||
hostID := c.cfg.AgentID
|
||||
if resolved, err := c.resolveHostID(ctx); err != nil {
|
||||
return nil, nil, err
|
||||
} else if resolved != "" {
|
||||
hostID = resolved
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/agents/host/%s/config", c.cfg.PulseURL, hostID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.cfg.APIToken)
|
||||
req.Header.Set("X-API-Token", c.cfg.APIToken)
|
||||
req.Header.Set("User-Agent", "pulse-agent-config-client")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("do request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, nil, fmt.Errorf("server responded with status %s", resp.Status)
|
||||
}
|
||||
|
||||
var configResp Response
|
||||
if err := json.NewDecoder(resp.Body).Decode(&configResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
|
||||
return configResp.Config.Settings, configResp.Config.CommandsEnabled, nil
|
||||
}
|
||||
|
||||
func (c *Client) resolveHostID(ctx context.Context) (string, error) {
|
||||
hostname := strings.TrimSpace(c.cfg.Hostname)
|
||||
if hostname == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/agents/host/lookup?hostname=%s", c.cfg.PulseURL, hostname)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create host lookup request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.cfg.APIToken)
|
||||
req.Header.Set("X-API-Token", c.cfg.APIToken)
|
||||
req.Header.Set("User-Agent", "pulse-agent-config-client")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("host lookup request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return "", nil
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("host lookup responded with status %s", resp.Status)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Success bool `json:"success"`
|
||||
Host struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"host"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", fmt.Errorf("decode host lookup response: %w", err)
|
||||
}
|
||||
if !payload.Success {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimSpace(payload.Host.ID), nil
|
||||
}
|
||||
80
internal/remoteconfig/client_test.go
Normal file
80
internal/remoteconfig/client_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package remoteconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClient_Fetch(t *testing.T) {
|
||||
t.Run("successful fetch with full config", func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/agents/host/agent-1/config" {
|
||||
t.Errorf("Expected path /api/agents/host/agent-1/config, got %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer token-123" {
|
||||
t.Errorf("Expected Authorization header 'Bearer token-123', got %s", r.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{
|
||||
"success": true,
|
||||
"hostId": "agent-1",
|
||||
"config": {
|
||||
"commandsEnabled": true,
|
||||
"settings": {
|
||||
"interval": "1m",
|
||||
"enable_docker": false
|
||||
}
|
||||
}
|
||||
}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := New(Config{
|
||||
PulseURL: ts.URL,
|
||||
APIToken: "token-123",
|
||||
AgentID: "agent-1",
|
||||
})
|
||||
|
||||
settings, commandsEnabled, err := client.Fetch(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch failed: %v", err)
|
||||
}
|
||||
|
||||
if commandsEnabled == nil || *commandsEnabled != true {
|
||||
t.Errorf("Expected commandsEnabled=true, got %v", commandsEnabled)
|
||||
}
|
||||
|
||||
if settings["interval"] != "1m" {
|
||||
t.Errorf("Expected interval='1m', got %v", settings["interval"])
|
||||
}
|
||||
if settings["enable_docker"] != false {
|
||||
t.Errorf("Expected enable_docker=false, got %v", settings["enable_docker"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("server error", func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := New(Config{PulseURL: ts.URL, APIToken: "t", AgentID: "a"})
|
||||
_, _, err := client.Fetch(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing agent ID", func(t *testing.T) {
|
||||
client := New(Config{PulseURL: "http://localhost", APIToken: "t", AgentID: ""})
|
||||
_, _, err := client.Fetch(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing agent ID, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user