Skip to content

Conversation

@alvagante
Copy link
Member

No description provided.

Copilot AI review requested due to automatic review settings December 3, 2025 11:33
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds three major infrastructure integrations to Pabawi: Prometheus (monitoring/metrics), Ansible AWX/Tower (automation), and Terraform Cloud/Enterprise (infrastructure as code). The implementation follows a plugin architecture pattern with dedicated clients, plugins, routes, and frontend components for each integration.

Key changes include:

  • New metrics visualization tab with CPU, memory, disk, and load average monitoring
  • Ansible and Terraform integration scaffolding with comprehensive API clients
  • Configuration schema updates to support all three integrations via environment variables

Reviewed changes

Copilot reviewed 25 out of 25 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
frontend/src/pages/NodeDetailPage.svelte Added metrics tab support and Prometheus availability check
frontend/src/components/MetricsTab.svelte New component displaying Prometheus metrics, alerts, and Grafana links
frontend/src/components/MetricsBadge.svelte Reusable metric badge component with threshold-based coloring
backend/src/routes/prometheus.ts API routes for Prometheus metrics, alerts, and custom queries
backend/src/routes/ansible.ts Comprehensive Ansible AWX/Tower API routes
backend/src/routes/terraform.ts Terraform Cloud/Enterprise API routes for workspaces and runs
backend/src/integrations/prometheus/* Prometheus plugin and client implementation
backend/src/integrations/ansible/* Ansible plugin and client implementation
backend/src/integrations/terraform/* Terraform plugin and client implementation
backend/src/config/schema.ts Configuration schemas for all three integrations
backend/src/config/ConfigService.ts Environment variable parsing for integrations
backend/src/server.ts Integration initialization and router registration

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +202 to +209
dashboardUrl: `${grafanaUrl}/d/node-exporter?var-instance=${name}`,
explorerUrl: `${grafanaUrl}/explore?left=${encodeURIComponent(
JSON.stringify({
queries: [{ expr: `{instance=~"${name}.*"}`, refId: 'A' }],
range: { from: 'now-1h', to: 'now' },
})
)}`,
});
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nodeName parameter is URL-encoded but not validated for proper format. Consider validating the node name format before using it in the Grafana URL to prevent potential injection issues or malformed URLs.

Copilot uses AI. Check for mistakes.
Comment on lines +343 to +370
// Run ad-hoc command
router.post('/ad-hoc', async (req: Request, res: Response, next: NextFunction) => {
try {
const plugin = getAnsiblePlugin();
if (!plugin) {
res.status(503).json({ error: 'Ansible integration is not available' });
return;
}

const { inventory, command, limit, credential } = req.body;

if (!inventory || !command) {
res.status(400).json({ error: 'inventory and command are required' });
return;
}

const inventoryId = parseInt(inventory, 10);
if (isNaN(inventoryId)) {
res.status(400).json({ error: 'Invalid inventory ID' });
return;
}

const credentialId = credential ? parseInt(credential, 10) : undefined;

const job = await plugin.runAdHocCommand(inventoryId, command, limit, credentialId);
res.status(201).json({ job });
} catch (error) {
next(error);
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in these routes catches errors but doesn't sanitize error messages before returning them to the client. Error messages from the Ansible API might contain sensitive information. Consider sanitizing or wrapping error messages before exposing them.

Copilot uses AI. Check for mistakes.
Comment on lines +476 to +481

return new Promise((resolve, reject) => {
const req = client.request(
{
hostname: parsedUrl.hostname,
port: parsedUrl.port,
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parsedUrl.port fallback logic will return undefined if the port is not specified in the URL, which may not be the intended behavior. Consider explicitly checking if parsedUrl.port is falsy and defaulting to undefined or the appropriate port based on protocol.

Suggested change
return new Promise((resolve, reject) => {
const req = client.request(
{
hostname: parsedUrl.hostname,
port: parsedUrl.port,
// Set port to default if not specified
const port =
parsedUrl.port
? parsedUrl.port
: parsedUrl.protocol === 'https:'
? 443
: 80;
return new Promise((resolve, reject) => {
const req = client.request(
{
hostname: parsedUrl.hostname,
port: port,

Copilot uses AI. Check for mistakes.
Comment on lines +193 to +218
console.warn("Initializing Prometheus integration...");
try {
const prometheusPlugin = new PrometheusPlugin();
const prometheusIntegrationConfig: IntegrationConfig = {
enabled: prometheusConfig.enabled,
name: "prometheus",
type: "information",
config: prometheusConfig,
priority: 8, // Between Bolt and PuppetDB
};

integrationManager.registerPlugin(prometheusPlugin, prometheusIntegrationConfig);

console.warn("Prometheus integration registered and enabled");
console.warn(`- Server URL: ${prometheusConfig.serverUrl}`);
console.warn(
`- Grafana URL: ${prometheusConfig.grafanaUrl ?? "not configured"}`,
);
} catch (error) {
console.warn(
`WARNING: Failed to initialize Prometheus integration: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
} else {
console.warn("Prometheus integration not configured - skipping registration");
}
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using console.warn for initialization messages is unconventional. Typically, console.warn is reserved for actual warnings, while informational messages use console.log or console.info. Consider using appropriate log levels for different message types to avoid confusion in production logs.

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +37
interface Props {
nodeName: string;
}
let { nodeName }: Props = $props();
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nodeId prop is defined on line 34 but never used in the component. The component uses nodeName prop (line 37) throughout. Either remove the unused nodeId from the Props interface or use it where appropriate.

Copilot uses AI. Check for mistakes.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Alessandro Franceschi <al@example42.com>
Copilot AI review requested due to automatic review settings December 3, 2025 12:01
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +35 to +37
}
let { nodeName }: Props = $props();
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing nodeId prop in the Props interface. The component uses nodeId in the template (line 1109) but the Props interface only defines nodeName (line 34). This will cause a runtime error.

Suggested change
}
let { nodeName }: Props = $props();
nodeId: string;
}
let { nodeName, nodeId }: Props = $props();

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +15
username?: string;
password?: string;
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent authentication configuration pattern. The Prometheus integration uses separate username and password fields (lines 14-15), while the ConfigService.ts parses these into a nested basicAuth object (lines 151-156). The PrometheusConfig type should match what ConfigService produces to avoid confusion.

Suggested change
username?: string;
password?: string;
basicAuth?: {
username?: string;
password?: string;
};

Copilot uses AI. Check for mistakes.
name: "prometheus",
type: "information",
config: prometheusConfig,
priority: 8, // Between Bolt and PuppetDB
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Magic number without context. The priority value 8 is used without explanation. Consider adding a comment explaining the priority system (e.g., "// Between Bolt (9) and PuppetDB (7)") or using named constants to make the priority hierarchy clear.

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +42
constructor(config: TerraformConfig) {
this.config = config;
// Default to Terraform Cloud if not specified
this.baseUrl = (config.url || 'https://app.terraform.io').replace(/\/$/, '');
this.headers = {
'Authorization': `Bearer ${config.token}`,
'Content-Type': 'application/vnd.api+json',
'Accept': 'application/vnd.api+json',
};
}
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential security issue: Missing authentication validation. The Terraform API token is used directly without any validation that it's not empty or contains dangerous characters. Consider adding validation in the constructor or when building headers.

Copilot uses AI. Check for mistakes.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Alessandro Franceschi <al@example42.com>
Copilot AI review requested due to automatic review settings December 4, 2025 09:04
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Alessandro Franceschi <al@example42.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +152 to +155
integrations.prometheus.basicAuth = {
username: basicAuthUser,
password: basicAuthPassword,
};
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The configuration parsing in parseIntegrationsConfig has inconsistent authentication handling for Prometheus. The code checks for basicAuth and bearerToken from environment variables but the schema and type definitions use username/password fields instead of a nested basicAuth object. This mismatch will cause runtime errors when the Prometheus integration tries to access credentials.

Suggested change
integrations.prometheus.basicAuth = {
username: basicAuthUser,
password: basicAuthPassword,
};
integrations.prometheus.username = basicAuthUser;
integrations.prometheus.password = basicAuthPassword;

Copilot uses AI. Check for mistakes.
integrationManager.registerPlugin(terraformPlugin, terraformIntegrationConfig);

console.warn("Terraform integration registered and enabled");
console.warn(`- Terraform URL: ${terraformConfig.url}`);
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security vulnerability: The Terraform API token and Ansible credentials are being logged in plain text to the console. Lines 207, 239, and 271 expose sensitive authentication tokens. Remove or redact these log statements to prevent credential leakage.

Suggested change
console.warn(`- Terraform URL: ${terraformConfig.url}`);

Copilot uses AI. Check for mistakes.
Comment on lines +91 to +852
ALTER TABLE executions ADD COLUMN playbook TEXT;
ALTER TABLE executions ADD COLUMN ansible_pattern TEXT;
ALTER TABLE executions ADD COLUMN ansible_module TEXT;

-- Create playbook history table
CREATE TABLE IF NOT EXISTS ansible_playbooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
execution_id INTEGER NOT NULL,
playbook_path TEXT NOT NULL,
parameters TEXT, -- JSON
results TEXT, -- JSON per host
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (execution_id) REFERENCES executions(id) ON DELETE CASCADE
);

CREATE INDEX idx_ansible_playbooks_execution ON ansible_playbooks(execution_id);
```

### API Routes

```typescript
import express from 'express';
import { AnsibleService } from '../services/ansible-service.js';

const router = express.Router();
const ansibleService = new AnsibleService();

// Get Ansible inventory
router.get('/inventory', async (req, res) => {
const nodes = await ansibleService.getInventory();
res.json(nodes);
});

// Execute playbook
router.post('/playbook', async (req, res) => {
const { playbook, targets, parameters, limit, tags } = req.body;

const execution = await ansibleService.executePlaybook({
playbook,
targets,
parameters,
limit,
tags
});

res.json(execution);
});

// Execute ad-hoc command
router.post('/adhoc', async (req, res) => {
const { pattern, module, args, targets } = req.body;

const execution = await ansibleService.executeAdHoc({
pattern,
module,
args,
targets
});

res.json(execution);
});

// Get playbook list
router.get('/playbooks', async (req, res) => {
const playbooks = await ansibleService.getAvailablePlaybooks();
res.json(playbooks);
});

export default router;
```

### Frontend Components

```svelte
<script lang="ts">
import { onMount } from 'svelte';
let playbooks = $state([]);
let selectedPlaybook = $state(null);
let parameters = $state({});
let selectedTargets = $state([]);
onMount(async () => {
const response = await fetch('/api/ansible/playbooks');
playbooks = await response.json();
});
async function executePlaybook() {
const response = await fetch('/api/ansible/playbook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playbook: selectedPlaybook,
targets: selectedTargets,
parameters
})
});
const execution = await response.json();
// Navigate to execution detail or show streaming output
}
</script>
<div class="playbooks-page">
<h1>Ansible Playbooks</h1>
<div class="playbook-selector">
<select bind:value={selectedPlaybook}>
<option value={null}>Select playbook...</option>
{#each playbooks as playbook}
<option value={playbook.path}>{playbook.name}</option>
{/each}
</select>
</div>
{#if selectedPlaybook}
<div class="execution-form">
<h3>Targets</h3>
<!-- Target selection component -->
<h3>Parameters</h3>
<!-- Dynamic parameter inputs based on playbook vars -->
<button onclick={executePlaybook}>Execute Playbook</button>
</div>
{/if}
</div>
```

### Environment Configuration

```bash
# ...existing code...

# Ansible Integration (Optional)
ANSIBLE_ENABLED=true
ANSIBLE_INVENTORY_PATH=./ansible/inventory
ANSIBLE_PLAYBOOK_DIR=./ansible/playbooks
ANSIBLE_CONFIG_PATH=./ansible/ansible.cfg
ANSIBLE_TIMEOUT=600000
```

---

## 2. Prometheus Integration (Priority 2)

### Architecture

```typescript
import fetch from 'node-fetch';
import type { Integration, IntegrationHealth } from '../base/integration.js';

export class PrometheusClient implements Integration {
name = 'prometheus';
enabled: boolean;

private baseUrl: string;
private timeout: number;

constructor(config: PrometheusConfig) {
this.enabled = config.enabled;
this.baseUrl = config.serverUrl;
this.timeout = config.timeout || 30000;
}

async healthCheck(): Promise<IntegrationHealth> {
try {
const response = await fetch(`${this.baseUrl}/-/healthy`, {
timeout: this.timeout
});

if (response.ok) {
return {
status: 'connected',
message: 'Prometheus server healthy',
lastCheck: new Date()
};
}

return {
status: 'degraded',
message: `HTTP ${response.status}`,
lastCheck: new Date()
};
} catch (error) {
return {
status: 'disconnected',
message: error.message,
lastCheck: new Date()
};
}
}

async getNodeMetrics(nodeName: string): Promise<NodeMetrics> {
const queries = {
cpu: `100 - (avg by (instance) (irate(node_cpu_seconds_total{mode="idle",instance=~"${nodeName}.*"}[5m])) * 100)`,
memory: `(node_memory_MemTotal_bytes{instance=~"${nodeName}.*"} - node_memory_MemAvailable_bytes{instance=~"${nodeName}.*"}) / node_memory_MemTotal_bytes{instance=~"${nodeName}.*"} * 100`,
disk: `100 - ((node_filesystem_avail_bytes{instance=~"${nodeName}.*",mountpoint="/"} * 100) / node_filesystem_size_bytes{instance=~"${nodeName}.*",mountpoint="/"})`
};

const results = await Promise.all(
Object.entries(queries).map(([metric, query]) =>
this.query(query).then(result => [metric, result])
)
);

return Object.fromEntries(results);
}

async getAlerts(nodeName?: string): Promise<Alert[]> {
const url = `${this.baseUrl}/api/v1/alerts`;
const response = await fetch(url, { timeout: this.timeout });
const data = await response.json();

let alerts = data.data.alerts;

if (nodeName) {
alerts = alerts.filter(alert =>
alert.labels.instance?.includes(nodeName)
);
}

return alerts;
}

private async query(promql: string): Promise<any> {
const url = `${this.baseUrl}/api/v1/query?query=${encodeURIComponent(promql)}`;
const response = await fetch(url, { timeout: this.timeout });
const data = await response.json();

if (data.status !== 'success') {
throw new Error(`Prometheus query failed: ${data.error}`);
}

return data.data.result;
}
}
```

### API Routes

```typescript
import express from 'express';
import { PrometheusService } from '../services/prometheus-service.js';

const router = express.Router();
const prometheusService = new PrometheusService();

// Get metrics for a node
router.get('/nodes/:name/metrics', async (req, res) => {
const { name } = req.params;
const metrics = await prometheusService.getNodeMetrics(name);
res.json(metrics);
});

// Get alerts for a node
router.get('/nodes/:name/alerts', async (req, res) => {
const { name } = req.params;
const alerts = await prometheusService.getAlerts(name);
res.json(alerts);
});

// Get all active alerts
router.get('/alerts', async (req, res) => {
const alerts = await prometheusService.getAlerts();
res.json(alerts);
});

export default router;
```

### Frontend Components

```svelte
<script lang="ts">
import { onMount } from 'svelte';
let { nodeName } = $props();
let metrics = $state(null);
let loading = $state(true);
onMount(async () => {
try {
const response = await fetch(`/api/metrics/nodes/${nodeName}/metrics`);
metrics = await response.json();
} catch (error) {
console.error('Failed to load metrics:', error);
} finally {
loading = false;
}
});
function getStatusClass(value: number, thresholds: { warning: number, critical: number }) {
if (value >= thresholds.critical) return 'critical';
if (value >= thresholds.warning) return 'warning';
return 'ok';
}
</script>
{#if !loading && metrics}
<div class="metrics-badges">
<span class="metric-badge {getStatusClass(metrics.cpu, { warning: 70, critical: 90 })}">
CPU: {metrics.cpu.toFixed(1)}%
</span>
<span class="metric-badge {getStatusClass(metrics.memory, { warning: 80, critical: 95 })}">
MEM: {metrics.memory.toFixed(1)}%
</span>
<span class="metric-badge {getStatusClass(metrics.disk, { warning: 80, critical: 95 })}">
DISK: {metrics.disk.toFixed(1)}%
</span>
</div>
{/if}
<style>
.metrics-badges {
display: flex;
gap: 0.5rem;
}
.metric-badge {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
}
.metric-badge.ok {
background-color: #10b981;
color: white;
}
.metric-badge.warning {
background-color: #f59e0b;
color: white;
}
.metric-badge.critical {
background-color: #ef4444;
color: white;
}
</style>
```

```svelte
<script lang="ts">
import { onMount } from 'svelte';
let { nodeName, grafanaUrl } = $props();
let alerts = $state([]);
onMount(async () => {
const response = await fetch(`/api/metrics/nodes/${nodeName}/alerts`);
alerts = await response.json();
});
</script>
<div class="metrics-tab">
<div class="section">
<h3>Active Alerts</h3>
{#if alerts.length === 0}
<p class="no-alerts">No active alerts</p>
{:else}
<div class="alerts-list">
{#each alerts as alert}
<div class="alert alert-{alert.labels.severity}">
<strong>{alert.labels.alertname}</strong>
<p>{alert.annotations.description || alert.annotations.summary}</p>
<small>Since: {new Date(alert.activeAt).toLocaleString()}</small>
</div>
{/each}
</div>
{/if}
</div>
{#if grafanaUrl}
<div class="section">
<h3>Grafana Dashboard</h3>
<a href="{grafanaUrl}/d/node-exporter?var-instance={nodeName}"
target="_blank"
class="button">
Open in Grafana →
</a>
</div>
{/if}
</div>
```

### Environment Configuration

```bash
# ...existing code...

# Prometheus Integration (Optional)
PROMETHEUS_ENABLED=true
PROMETHEUS_SERVER_URL=http://prometheus:9090
PROMETHEUS_TIMEOUT=30000
GRAFANA_URL=http://grafana:3000
```

---

## 3. Terraform Integration (Priority 3)

### Architecture

```typescript
import { exec } from 'child_process';
import { promisify } from 'util';
import { readFile } from 'fs/promises';
import type { Integration, IntegrationHealth } from '../base/integration.js';

const execAsync = promisify(exec);

export class TerraformClient implements Integration {
name = 'terraform';
enabled: boolean;

private workingDir: string;
private stateBackend: 'local' | 's3' | 'remote';

constructor(config: TerraformConfig) {
this.enabled = config.enabled;
this.workingDir = config.workingDir;
this.stateBackend = config.stateBackend || 'local';
}

async healthCheck(): Promise<IntegrationHealth> {
try {
const { stdout } = await execAsync('terraform version');
return {
status: 'connected',
message: stdout.split('\n')[0],
lastCheck: new Date()
};
} catch (error) {
return {
status: 'disconnected',
message: error.message,
lastCheck: new Date()
};
}
}

async getState(): Promise<TerraformState> {
const { stdout } = await execAsync('terraform show -json', {
cwd: this.workingDir
});
return JSON.parse(stdout);
}

async getResources(): Promise<TerraformResource[]> {
const state = await this.getState();
return state.values?.root_module?.resources || [];
}

async getResourcesByNode(nodeName: string): Promise<TerraformResource[]> {
const resources = await this.getResources();
return resources.filter(r =>
r.values?.tags?.Name === nodeName ||
r.values?.name === nodeName ||
r.address.includes(nodeName)
);
}

async plan(targets?: string[]): Promise<TerraformPlan> {
let cmd = 'terraform plan -json';
if (targets) {
cmd += targets.map(t => ` -target=${t}`).join('');
}

const { stdout } = await execAsync(cmd, {
cwd: this.workingDir
});

return this.parsePlanOutput(stdout);
}

async apply(targets?: string[]): Promise<ExecutionResult> {
let cmd = 'terraform apply -auto-approve';
if (targets) {
cmd += targets.map(t => ` -target=${t}`).join('');
}

const { stdout, stderr } = await execAsync(cmd, {
cwd: this.workingDir
});

return {
success: !stderr.includes('Error'),
stdout,
stderr,
timestamp: new Date()
};
}
}
```

### API Routes

```typescript
import express from 'express';
import { TerraformService } from '../services/terraform-service.js';

const router = express.Router();
const terraformService = new TerraformService();

// Get Terraform state
router.get('/state', async (req, res) => {
const state = await terraformService.getState();
res.json(state);
});

// Get all resources
router.get('/resources', async (req, res) => {
const resources = await terraformService.getResources();
res.json(resources);
});

// Get resources for a specific node
router.get('/nodes/:name/resources', async (req, res) => {
const { name } = req.params;
const resources = await terraformService.getResourcesByNode(name);
res.json(resources);
});

// Run terraform plan
router.post('/plan', async (req, res) => {
const { targets } = req.body;
const plan = await terraformService.plan(targets);
res.json(plan);
});

// Run terraform apply
router.post('/apply', async (req, res) => {
const { targets } = req.body;
const result = await terraformService.apply(targets);
res.json(result);
});

export default router;
```

### Frontend Components

```svelte
<script lang="ts">
import { onMount } from 'svelte';
let { nodeName } = $props();
let resources = $state([]);
let loading = $state(true);
onMount(async () => {
try {
const response = await fetch(`/api/terraform/nodes/${nodeName}/resources`);
resources = await response.json();
} finally {
loading = false;
}
});
function getResourceIcon(type: string) {
if (type.includes('instance')) return '🖥️';
if (type.includes('volume')) return '💾';
if (type.includes('network')) return '🌐';
if (type.includes('security')) return '🔒';
return '📦';
}
</script>
<div class="terraform-tab">
{#if loading}
<p>Loading Terraform resources...</p>
{:else if resources.length === 0}
<p>No Terraform resources found for this node</p>
{:else}
<div class="resources-list">
{#each resources as resource}
<div class="resource-card">
<div class="resource-header">
<span class="resource-icon">{getResourceIcon(resource.type)}</span>
<h4>{resource.name}</h4>
<span class="resource-type">{resource.type}</span>
</div>
<div class="resource-details">
<dl>
{#each Object.entries(resource.values || {}) as [key, value]}
<dt>{key}</dt>
<dd>{JSON.stringify(value)}</dd>
{/each}
</dl>
</div>
</div>
{/each}
</div>
{/if}
</div>
```

### Update Node Detail Page

```typescript
// ...existing code...

const tabs = [
{ id: 'overview', label: 'Overview', sources: ['all'] },
{ id: 'facts', label: 'Facts', sources: ['puppetdb'] },
{ id: 'reports', label: 'Puppet Reports', sources: ['puppetdb'] },
{ id: 'catalog', label: 'Catalog', sources: ['puppetdb'] },
{ id: 'events', label: 'Events', sources: ['puppetdb'] },
{ id: 'metrics', label: 'Metrics', sources: ['prometheus'] }, // NEW
{ id: 'terraform', label: 'Terraform', sources: ['terraform'] }, // NEW
{ id: 'ansible', label: 'Ansible', sources: ['ansible'] }, // NEW
];

// ...existing code...
```

---

## Integration Manager

```typescript
import { PuppetDBClient } from './puppetdb/puppetdb-client.js';
import { AnsibleClient } from './ansible/ansible-client.js';
import { PrometheusClient } from './prometheus/prometheus-client.js';
import { TerraformClient } from './terraform/terraform-client.js';
import type { Integration, Node } from './base/integration.js';

export class IntegrationManager {
private integrations: Map<string, Integration> = new Map();

constructor() {
this.initializeIntegrations();
}

private initializeIntegrations() {
// PuppetDB
if (process.env.PUPPETDB_ENABLED === 'true') {
this.integrations.set('puppetdb', new PuppetDBClient({
enabled: true,
serverUrl: process.env.PUPPETDB_SERVER_URL!,
// ... other config
}));
}

// Ansible
if (process.env.ANSIBLE_ENABLED === 'true') {
this.integrations.set('ansible', new AnsibleClient({
enabled: true,
inventoryPath: process.env.ANSIBLE_INVENTORY_PATH!,
// ... other config
}));
}

// Prometheus
if (process.env.PROMETHEUS_ENABLED === 'true') {
this.integrations.set('prometheus', new PrometheusClient({
enabled: true,
serverUrl: process.env.PROMETHEUS_SERVER_URL!,
// ... other config
}));
}

// Terraform
if (process.env.TERRAFORM_ENABLED === 'true') {
this.integrations.set('terraform', new TerraformClient({
enabled: true,
workingDir: process.env.TERRAFORM_WORKING_DIR!,
// ... other config
}));
}
}

async getMultiSourceInventory(): Promise<Node[]> {
const allNodes: Node[] = [];

for (const [name, integration] of this.integrations) {
if (integration.enabled && integration.getInventory) {
try {
const nodes = await integration.getInventory();
allNodes.push(...nodes);
} catch (error) {
console.error(`Failed to get inventory from ${name}:`, error);
}
}
}

return this.deduplicateNodes(allNodes);
}

async getIntegrationStatus() {
const statuses = [];

for (const [name, integration] of this.integrations) {
const health = await integration.healthCheck();
statuses.push({
name,
enabled: integration.enabled,
...health
});
}

return statuses;
}

private deduplicateNodes(nodes: Node[]): Node[] {
const nodeMap = new Map<string, Node>();

for (const node of nodes) {
const existing = nodeMap.get(node.name);
if (!existing) {
nodeMap.set(node.name, node);
} else {
// Merge metadata from multiple sources
existing.metadata = {
...existing.metadata,
...node.metadata
};
if (!existing.sources) existing.sources = [];
existing.sources.push(node.source);
}
}

return Array.from(nodeMap.values());
}
}
```

---

## Rollout Plan

### Phase 1: Foundation (Week 1)
1. Create integration base interface
2. Implement IntegrationManager
3. Add integration status endpoint
4. Update home page with integration status cards

### Phase 2: Prometheus (Week 2)
1. Implement PrometheusClient
2. Add metrics API routes
3. Create MetricsBadge component
4. Add Metrics tab to node detail page
5. Update inventory cards with metrics

### Phase 3: Ansible (Weeks 3-4)
1. Implement AnsibleClient
2. Add Ansible API routes
3. Create playbook execution UI
4. Add Ansible tab to node detail
5. Integrate Ansible inventory into multi-source inventory

### Phase 4: Terraform (Week 5)
1. Implement TerraformClient
2. Add Terraform API routes
3. Create resource visualization components
4. Add Terraform tab to node detail
5. Link nodes to Terraform resources

This approach leverages Pabawi's existing multi-source inventory pattern and extends it seamlessly. Each integration adds independent value while contributing to a unified infrastructure view.
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential SQL injection risk: The ansible_pattern and ansible_module fields are added to the executions table without any validation or sanitization. If these values come from user input and are used in database queries elsewhere in the codebase, they could be exploited. Ensure these fields are properly parameterized in all queries.

Copilot uses AI. Check for mistakes.
}
interface Props {
nodeName: string;
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Props interface is missing the nodeId property that is used on line 1109. The component receives both nodeName and nodeId but only declares nodeName in the Props interface. Add nodeId: string to the Props interface.

Suggested change
nodeName: string;
nodeName: string;
nodeId: string;

Copilot uses AI. Check for mistakes.
Comment on lines +146 to +150
const promqlSafePattern = /^[a-zA-Z0-9_:{}\[\]\(\)\s\+\-\*\/\.,<>=!~"']+$/;
if (
typeof query !== 'string' ||
query.trim().length === 0 ||
!promqlSafePattern.test(query)
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing input validation for the PromQL query pattern. The regex pattern /^[a-zA-Z0-9_:{}\[\]\(\)\s\+\-\*\/\.,<>=!~"']+$/ does not include the pipe character | which is commonly used in PromQL for logical operators. This could prevent valid PromQL queries from being executed. Consider updating the pattern to include | or use a more comprehensive PromQL validation approach.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants