-
Notifications
You must be signed in to change notification settings - Fork 0
Copilot #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this 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.
| 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' }, | ||
| }) | ||
| )}`, | ||
| }); |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
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.
| // 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); |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
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.
|
|
||
| return new Promise((resolve, reject) => { | ||
| const req = client.request( | ||
| { | ||
| hostname: parsedUrl.hostname, | ||
| port: parsedUrl.port, |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
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.
| 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, |
| 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"); | ||
| } |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
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.
| interface Props { | ||
| nodeName: string; | ||
| } | ||
| let { nodeName }: Props = $props(); |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
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.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Alessandro Franceschi <al@example42.com>
There was a problem hiding this 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.
| } | ||
| let { nodeName }: Props = $props(); |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
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.
| } | |
| let { nodeName }: Props = $props(); | |
| nodeId: string; | |
| } | |
| let { nodeName, nodeId }: Props = $props(); |
| username?: string; | ||
| password?: string; |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
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.
| username?: string; | |
| password?: string; | |
| basicAuth?: { | |
| username?: string; | |
| password?: string; | |
| }; |
| name: "prometheus", | ||
| type: "information", | ||
| config: prometheusConfig, | ||
| priority: 8, // Between Bolt and PuppetDB |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
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.
| 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', | ||
| }; | ||
| } |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
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.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Alessandro Franceschi <al@example42.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Alessandro Franceschi <al@example42.com>
There was a problem hiding this 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.
| integrations.prometheus.basicAuth = { | ||
| username: basicAuthUser, | ||
| password: basicAuthPassword, | ||
| }; |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
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.
| integrations.prometheus.basicAuth = { | |
| username: basicAuthUser, | |
| password: basicAuthPassword, | |
| }; | |
| integrations.prometheus.username = basicAuthUser; | |
| integrations.prometheus.password = basicAuthPassword; |
| integrationManager.registerPlugin(terraformPlugin, terraformIntegrationConfig); | ||
|
|
||
| console.warn("Terraform integration registered and enabled"); | ||
| console.warn(`- Terraform URL: ${terraformConfig.url}`); |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
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.
| console.warn(`- Terraform URL: ${terraformConfig.url}`); |
| 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. |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
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.
| } | ||
| interface Props { | ||
| nodeName: string; |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
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.
| nodeName: string; | |
| nodeName: string; | |
| nodeId: string; |
| const promqlSafePattern = /^[a-zA-Z0-9_:{}\[\]\(\)\s\+\-\*\/\.,<>=!~"']+$/; | ||
| if ( | ||
| typeof query !== 'string' || | ||
| query.trim().length === 0 || | ||
| !promqlSafePattern.test(query) |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
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.
No description provided.